1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Fixing Cluster Settings layout (#651)

* A bit of cleaning in Add Cluster page

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Adding head-col to WizardLayout

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Fixing Cluster Settings general layout bugs

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Cluster Status view refactoring

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Install Metrics component refactoring

Using notifications for error, removed picking
button icon method, simplified button generation.

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Remove icons / checks from RemoveClusterButton

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Fixing colorError in Input styles

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Preventing Input's spellchecking

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* ClusterNameSettings refactoring

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* ClusterWorkspaceSettings refactoring/fixing

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* ClusterProxySetting refactoring

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* ClusterPrometheusSetting refactoring

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Clean up Removal section

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Glued InstallMetrics & InstallUserMode into 1 component

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Removing unused styles in Cluster Settings

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Cluster Settings styling

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Adding close button to settings header

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* ClusterHomeDirSetting refactoring

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* FilePicker restyling

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Fixing Prometheus selector

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Fixing Hashicon

Passing cluster name instead of cluster id to prevent
icon changing while typing new
cluster name

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Minor ClusterSettings fixes

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Increasing opacity for non-interactive icons

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Keep feature install loading state

Waiting for props to change before
disabling loading state (gray button
width spinner)

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Remove arrays in disposeOnUnmount()

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Fix Cluster select behavior

Now clicking cluster icon in sidebar
always leads to / dashboard. And
'Settings' submenu switches active
cluster at first and only the showing
Cluster Settings

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Using structuralComparator in feature installer

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Saving input fields on blur

Signed-off-by: alexfront <alex.andreev.email@gmail.com>

* Setting Select color same as Input color

Signed-off-by: alexfront <alex.andreev.email@gmail.com>
This commit is contained in:
Alex Andreev 2020-08-07 15:57:16 +03:00 committed by GitHub
parent 1f5acdb9cd
commit 0f4248de68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 497 additions and 753 deletions

View File

@ -86,6 +86,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return Array.from(this.clusters.values()); return Array.from(this.clusters.values());
} }
setActive(id: ClusterId) {
this.activeClusterId = id;
}
hasClusters() { hasClusters() {
return this.clusters.size > 0; return this.clusters.size > 0;
} }

View File

@ -124,7 +124,7 @@ export class AddCluster extends React.Component {
to allow you to operate easily on multiple clusters and/or contexts. to allow you to operate easily on multiple clusters and/or contexts.
</p> </p>
<p> <p>
For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/" target="_blank">Kubernetes docs</a> For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/" target="_blank">Kubernetes docs</a>.
</p> </p>
<p> <p>
NOTE: Any manually added cluster is not merged into your kubeconfig file. NOTE: Any manually added cluster is not merged into your kubeconfig file.
@ -137,13 +137,12 @@ export class AddCluster extends React.Component {
app. app.
</p> </p>
<a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator" target="_blank"> <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator" target="_blank">
<h4>OIDC (OpenID Connect)</h4> <h3>OIDC (OpenID Connect)</h3>
</a> </a>
<div>
<p> <p>
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account. When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
</p> </p>
<b>Dedicated refresh token</b> <p><b>Dedicated refresh token</b></p>
<p> <p>
As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes. As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes.
If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user. If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user.
@ -151,8 +150,7 @@ export class AddCluster extends React.Component {
(both <code>id_token</code> and <code>refresh_token</code>) from (both <code>id_token</code> and <code>refresh_token</code>) from
the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token. the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token.
</p> </p>
</div> <h3>Exec auth plugins</h3>
<h4>Exec auth plugins</h4>
<p> <p>
When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration" target="_blank">exec auth</a> plugins make sure the paths that are used to call When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration" target="_blank">exec auth</a> plugins make sure the paths that are used to call
any binaries any binaries

View File

@ -1,86 +1,85 @@
.ClusterSettings { .ClusterSettings {
overflow-y: scroll;
grid-template-columns: unset; grid-template-columns: unset;
padding: 0;
.head-col {
justify-content: space-between;
:nth-child(2) {
flex: 1 0 0;
}
a {
text-decoration: none;
color: $grey-600;
}
}
.info-col { .info-col {
display: none; display: none;
} }
.content-col { .content-col {
margin-right: unset; margin: 0;
padding-top: $padding * 3;
background-color: transparent;
.SubTitle {
text-transform: none;
} }
* { .settings-wrapper {
margin-top: 40px; margin: 0 auto;
width: 60%;
min-width: 570px;
max-width: 1000px;
&:first-child { > div {
margin-top: 0px; margin-top: $margin * 5;
}
.admin-note {
font-size: small;
opacity: 0.5;
margin-left: $margin;
}
.button-area {
margin-top: $margin * 2;
} }
} }
h4 { .file-loader {
margin-top: 20px; margin-top: $margin * 2;
}
.hint {
font-size: smaller;
opacity: 0.8;
}
} }
.status-table { .status-table {
margin-top: 20px; margin: $margin * 3 0;
display: grid;
grid-template-columns: 1fr 3fr; .Table {
grid-gap: 10px; border: 1px solid var(--drawerSubtitleBackground);
border-radius: $radius;
.TableRow {
&:not(:last-of-type) {
border-bottom: 1px solid var(--drawerSubtitleBackground);
} }
.loading { .value {
margin-top: 20px; flex-grow: 2;
text-align: center; color: var(--textColorSecondary);
}
.Spinner { }
display: inline-block;
} }
} }
.Input,.Select { .Input,.Select {
margin-top: 10px; margin-top: 10px;
} }
.Icon:not(.updated):not(.clean) {
color: #ad0000;
}
.Icon.updated {
color: #00dd1d;
}
.updated {
animation: updated-name 1s 1;
animation-fill-mode: forwards;
animation-delay: 3s;
}
@keyframes updated-name {
from {opacity :1;}
to {opacity :0;}
}
.center {
text-align: center;
}
input[type="text"]::placeholder {
font-size: small;
color: #707070;
}
input[type="text"] {
color: white;
}
button {
margin-top: 5px;
.Spinner {
width: 10px;
height: 10px;
border-color: transparent black;
}
}
} }

View File

@ -1,25 +1,43 @@
import "./cluster-settings.scss" import "./cluster-settings.scss";
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Features } from "./features" import { Features } from "./features";
import { Removal } from "./removal" import { Removal } from "./removal";
import { Status } from "./status" import { Status } from "./status";
import { General } from "./general" import { General } from "./general";
import { getHostedCluster } from "../../../common/cluster-store" import { getHostedCluster } from "../../../common/cluster-store";
import { WizardLayout } from "../layout/wizard-layout"; import { WizardLayout } from "../layout/wizard-layout";
import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon";
@observer @observer
export class ClusterSettings extends React.Component { export class ClusterSettings extends React.Component {
render() { render() {
const cluster = getHostedCluster(); const cluster = getHostedCluster();
const header = (
<>
<ClusterIcon
cluster={cluster}
showErrors={false}
showTooltip={false}
/>
<h2>{cluster.preferences.clusterName}</h2>
<Link to="/">
<Icon material="close" big />
</Link>
</>
);
return ( return (
<WizardLayout className="ClusterSettings"> <WizardLayout header={header} className="ClusterSettings">
<div className="settings-wrapper">
<Status cluster={cluster}></Status> <Status cluster={cluster}></Status>
<General cluster={cluster}></General> <General cluster={cluster}></General>
<Features cluster={cluster}></Features> <Features cluster={cluster}></Features>
<Removal cluster={cluster}></Removal> <Removal cluster={cluster}></Removal>
</div>
</WizardLayout> </WizardLayout>
) );
} }
} }

View File

@ -1,14 +1,9 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { Spinner } from "../../spinner";
import { clusterStore } from "../../../../common/cluster-store"
import { Icon } from "../../icon";
import { Tooltip, TooltipPosition } from "../../tooltip";
import { autobind } from "../../../utils";
import { TextInputStatus } from "./statuses"
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { SubTitle } from "../../layout/sub-title";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -17,70 +12,32 @@ interface Props {
@observer @observer
export class ClusterHomeDirSetting extends React.Component<Props> { export class ClusterHomeDirSetting extends React.Component<Props> {
@observable directory = this.props.cluster.preferences.terminalCWD || ""; @observable directory = this.props.cluster.preferences.terminalCWD || "";
@observable status = TextInputStatus.CLEAN;
@observable errorText?: string; save = () => {
this.props.cluster.preferences.terminalCWD = this.directory;
};
onChange = (value: string) => {
this.directory = value;
}
render() { render() {
return <> return (
<h4>Working Directory</h4> <>
<p>Set initial working directory for terminals. When set it will the `pwd` when a new terminal instance is opened for this cluster.</p> <SubTitle title="Working Directory"/>
<p>Terminal working directory.</p>
<Input <Input
theme="round-black" theme="round-black"
className="box grow"
value={this.directory} value={this.directory}
onSubmit={this.onWorkingDirectorySubmit} onChange={this.onChange}
onChange={this.onWorkingDirectoryChange} onBlur={this.save}
iconRight={this.getIconRight()}
placeholder="$HOME" placeholder="$HOME"
/> />
</>; <span className="hint">
} An explicit start path where the terminal will be launched,{" "}
this is used as the current working directory (cwd) for the shell process.
@autobind() </span>
onWorkingDirectoryChange(directory: string, _e: React.ChangeEvent) { </>
if (this.status === TextInputStatus.UPDATING) { );
console.log("prevent changing cluster directory while updating");
return;
}
this.status = this.dirDiffers(directory);
this.directory = directory;
}
dirDiffers(directory: string): TextInputStatus {
const { terminalCWD = "" } = this.props.cluster.preferences;
return directory === terminalCWD ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
}
getIconRight(): React.ReactNode {
switch (this.status) {
case TextInputStatus.CLEAN:
return null;
case TextInputStatus.DIRTY:
return <Icon size="16px" material="fiber_manual_record"/>;
case TextInputStatus.UPDATED:
return <Icon size="16px" className="updated" material="done"/>;
case TextInputStatus.UPDATING:
return <Spinner />;
case TextInputStatus.ERROR:
return <Icon id="cluster-directory-setting-error-icon" size="16px" material="error">
<Tooltip targetId="cluster-directory-setting-error-icon" position={TooltipPosition.TOP}>
{this.errorText}
</Tooltip>
</Icon>
}
}
@autobind()
onWorkingDirectorySubmit(directory: string) {
if (this.dirDiffers(directory) !== TextInputStatus.DIRTY) {
return;
}
this.status = TextInputStatus.UPDATING
this.props.cluster.preferences.terminalCWD = directory;
this.directory = directory;
this.status = TextInputStatus.UPDATED
} }
} }

View File

@ -1,13 +1,17 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster"; import { Cluster } from "../../../../main/cluster";
import { clusterStore } from "../../../../common/cluster-store"
import { Icon } from "../../icon";
import { FilePicker, OverSizeLimitStyle } from "../../file-picker"; import { FilePicker, OverSizeLimitStyle } from "../../file-picker";
import { autobind } from "../../../utils"; import { autobind } from "../../../utils";
import { Button } from "../../button"; import { Button } from "../../button";
import { GeneralInputStatus } from "./statuses"
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { SubTitle } from "../../layout/sub-title";
import { ClusterIcon } from "../../cluster-icon";
enum GeneralInputStatus {
CLEAN = "clean",
ERROR = "error",
}
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -21,7 +25,6 @@ export class ClusterIconSetting extends React.Component<Props> {
@autobind() @autobind()
async onIconPick([file]: File[]) { async onIconPick([file]: File[]) {
const { cluster } = this.props; const { cluster } = this.props;
try { try {
if (file) { if (file) {
const buf = Buffer.from(await file.arrayBuffer()); const buf = Buffer.from(await file.arrayBuffer());
@ -38,35 +41,36 @@ export class ClusterIconSetting extends React.Component<Props> {
} }
getClearButton() { getClearButton() {
const { cluster } = this.props; if (this.props.cluster.preferences.icon) {
return <Button tooltip="Revert back to default icon" accent onClick={() => this.onIconPick([])}>Clear</Button>
if (cluster.preferences.icon) {
return <Button accent onClick={() => this.onIconPick([])}>Clear</Button>
} }
} }
render() { render() {
return <> const label = (
<h4>Cluster Icon</h4> <>
<p>Set cluster icon. By default it is automatically generated. {this.getIconRight()}</p> <ClusterIcon
<div className="center"> cluster={this.props.cluster}
showErrors={false}
showTooltip={false}
/>
{"Browse for new icon..."}
</>
);
return (
<>
<SubTitle title="Cluster Icon" />
<p>Define cluster icon. By default automatically generated.</p>
<div className="file-loader">
<FilePicker <FilePicker
accept="image/*" accept="image/*"
labelText="Browse for new icon..." label={label}
onOverSizeLimit={OverSizeLimitStyle.FILTER} onOverSizeLimit={OverSizeLimitStyle.FILTER}
handler={this.onIconPick} handler={this.onIconPick}
/> />
{this.getClearButton()} {this.getClearButton()}
</div> </div>
</>; </>
} );
getIconRight(): React.ReactNode {
switch (this.status) {
case GeneralInputStatus.CLEAN:
return null;
case GeneralInputStatus.ERROR:
return <Icon size="16px" material="error" title={this.errorText}></Icon>
}
} }
} }

View File

@ -1,14 +1,10 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster"; import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input"; import { Input } from "../../input";
import { Spinner } from "../../spinner";
import { clusterStore } from "../../../../common/cluster-store"
import { Icon } from "../../icon";
import { Tooltip, TooltipPosition } from "../../tooltip";
import { autobind } from "../../../utils";
import { TextInputStatus } from "./statuses"
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { SubTitle } from "../../layout/sub-title";
import { isRequired } from "../../input/input.validators";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -17,69 +13,28 @@ interface Props {
@observer @observer
export class ClusterNameSetting extends React.Component<Props> { export class ClusterNameSetting extends React.Component<Props> {
@observable name = this.props.cluster.preferences.clusterName || ""; @observable name = this.props.cluster.preferences.clusterName || "";
@observable status = TextInputStatus.CLEAN;
@observable errorText?: string; save = () => {
this.props.cluster.preferences.clusterName = this.name;
};
onChange = (value: string) => {
this.name = value;
}
render() { render() {
return <> return (
<h4>Cluster Name</h4> <>
<p>Change cluster name:</p> <SubTitle title="Cluster Name"/>
<p>Define cluster name.</p>
<Input <Input
theme="round-black" theme="round-black"
className="box grow" validators={isRequired}
value={this.name} value={this.name}
onSubmit={this.onClusterNameSubmit} onChange={this.onChange}
onChange={this.onClusterNameChange} onBlur={this.save}
iconRight={this.getIconRight()}
/> />
</>; </>
} );
@autobind()
onClusterNameChange(name: string, _e: React.ChangeEvent) {
if (this.status === TextInputStatus.UPDATING) {
console.log("prevent changing cluster name while updating");
return;
}
this.status = this.nameDiffers(name)
this.name = name;
}
nameDiffers(name: string): TextInputStatus {
const { clusterName } = this.props.cluster.preferences;
return name === clusterName ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
}
getIconRight(): React.ReactNode {
switch (this.status) {
case TextInputStatus.CLEAN:
return null;
case TextInputStatus.DIRTY:
return <Icon size="16px" material="fiber_manual_record"/>;
case TextInputStatus.UPDATED:
return <Icon size="16px" className="updated" material="done"/>;
case TextInputStatus.UPDATING:
return <Spinner/>;
case TextInputStatus.ERROR:
return <Icon id="cluster-name-setting-error-icon" size="16px" material="error">
<Tooltip targetId="cluster-name-setting-error-icon" position={TooltipPosition.TOP}>
{this.errorText}
</Tooltip>
</Icon>
}
}
@autobind()
onClusterNameSubmit(name: string) {
if (this.nameDiffers(name) !== TextInputStatus.DIRTY) {
return;
}
this.status = TextInputStatus.UPDATING
this.props.cluster.preferences.clusterName = name;
this.name = name;
this.status = TextInputStatus.UPDATED
} }
} }

View File

@ -1,13 +1,11 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster"; import merge from "lodash/merge";
import { clusterStore } from "../../../../common/cluster-store"
import { Select, SelectOption, SelectProps } from "../../select";
import { prometheusProviders } from "../../../../common/prometheus-providers";
import { autobind } from "../../../utils";
import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { prometheusProviders } from "../../../../common/prometheus-providers";
import { Cluster } from "../../../../main/cluster";
import { SubTitle } from "../../layout/sub-title";
import { Select, SelectOption } from "../../select";
const prometheusGuide = "https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md";
const options: SelectOption<string>[] = [ const options: SelectOption<string>[] = [
{ value: "", label: "Auto detect" }, { value: "", label: "Auto detect" },
...prometheusProviders.map(pp => ({value: pp.id, label: pp.name})) ...prometheusProviders.map(pp => ({value: pp.id, label: pp.name}))
@ -19,23 +17,28 @@ interface Props {
@observer @observer
export class ClusterPrometheusSetting extends React.Component<Props> { export class ClusterPrometheusSetting extends React.Component<Props> {
@observable prometheusProvider = this.props.cluster.preferences.prometheusProvider?.type || "";
render() { render() {
return <> return (
<h4>Cluster Prometheus</h4> <>
<p>Use pre-installed Prometheus service for metrics. Please refer to <a href={prometheusGuide}>this guide</a> for possible configuration changes.</p> <SubTitle title="Prometheus"/>
<p>
Use pre-installed Prometheus service for metrics. Please refer to the{" "}
<a href="https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md" target="_blank">guide</a>{" "}
for possible configuration changes.
</p>
<Select <Select
value={this.prometheusProvider} value={this.props.cluster.preferences.prometheusProvider?.type || ""}
options={options} onChange={({value}) => {
onChange={this.changePrometheusProvider} const provider = {
/> prometheusProvider: {
</>; type: value
} }
}
@autobind() merge(this.props.cluster.preferences, provider);
changePrometheusProvider({ value: prometheusProvider }: SelectProps<string>) { }}
this.prometheusProvider = prometheusProvider; options={options}
this.props.cluster.preferences.prometheusProvider = { type: prometheusProvider }; />
</>
);
} }
} }

View File

@ -1,14 +1,10 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { Spinner } from "../../spinner";
import { clusterStore } from "../../../../common/cluster-store"
import { Icon } from "../../icon";
import { Tooltip, TooltipPosition } from "../../tooltip";
import { autobind } from "../../../utils";
import { TextInputStatus } from "./statuses"
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { isUrl } from "../../input/input.validators";
import { SubTitle } from "../../layout/sub-title";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -17,89 +13,29 @@ interface Props {
@observer @observer
export class ClusterProxySetting extends React.Component<Props> { export class ClusterProxySetting extends React.Component<Props> {
@observable proxy = this.props.cluster.preferences.httpsProxy || ""; @observable proxy = this.props.cluster.preferences.httpsProxy || "";
@observable status = TextInputStatus.CLEAN;
@observable errorText?: string; save = () => {
this.props.cluster.preferences.httpsProxy = this.proxy;
};
onChange = (value: string) => {
this.proxy = value;
}
render() { render() {
return <> return (
<h4>HTTPS Proxy</h4> <>
<p>HTTPS Proxy server. Used for communicating with Kubernetes API.</p> <SubTitle title="HTTP Proxy"/>
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
<Input <Input
theme="round-black" theme="round-black"
className="box grow"
value={this.proxy} value={this.proxy}
onSubmit={this.updateClusterProxy} onChange={this.onChange}
onChange={this.changeProxyState} onBlur={this.save}
iconRight={this.getIconRight()} placeholder="http://<address>:<port>"
placeholder="https://<address>:<port>" validators={isUrl}
/> />
</>; </>
} );
@autobind()
changeProxyState(proxy: string, _e: React.ChangeEvent) {
if (this.status === TextInputStatus.UPDATING) {
console.log("prevent changing cluster proxy while updating");
return;
}
this.status = this.proxyDiffers(proxy);
this.proxy = proxy;
}
proxyDiffers(proxy: string): TextInputStatus {
const { httpsProxy = "" } = this.props.cluster.preferences;
return proxy === httpsProxy ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
}
getIconRight(): React.ReactNode {
switch (this.status) {
case TextInputStatus.CLEAN:
return null;
case TextInputStatus.DIRTY:
return <Icon size="16px" material="fiber_manual_record"/>;
case TextInputStatus.UPDATED:
return <Icon size="16px" className="updated" material="done"/>;
case TextInputStatus.UPDATING:
return <Spinner />;
case TextInputStatus.ERROR:
return <Icon id="cluster-proxy-setting-error-icon" size="16px" material="error">
<Tooltip targetId="cluster-proxy-setting-error-icon" position={TooltipPosition.TOP}>
{this.errorText}
</Tooltip>
</Icon>
}
}
@autobind()
updateClusterProxy(proxy: string) {
if (this.proxyDiffers(proxy) !== TextInputStatus.DIRTY) {
return;
}
try {
const url = new URL(proxy);
if (url.protocol !== "https") {
this.status = TextInputStatus.ERROR
this.errorText= `Proxy's protocol should be "https"`
return
}
if (url.port === "") {
this.status = TextInputStatus.ERROR
this.errorText= "Proxy should include a port"
return
}
} catch (e) {
this.status = TextInputStatus.ERROR
this.errorText= "Invalid URL"
return
}
this.status = TextInputStatus.UPDATING
this.props.cluster.preferences.httpsProxy = proxy;
this.proxy = proxy;
this.status = TextInputStatus.UPDATED
} }
} }

View File

@ -1,12 +1,11 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster";
import { clusterStore } from "../../../../common/cluster-store"
import { workspaceStore } from "../../../../common/workspace-store"
import { Select, SelectOption } from "../../../components/select";
import { GeneralInputStatus } from "./statuses"
import { observable } from "mobx";
import { autobind } from "../../../utils";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { workspacesURL } from "../../+workspaces";
import { workspaceStore } from "../../../../common/workspace-store";
import { Cluster } from "../../../../main/cluster";
import { Select } from "../../../components/select";
import { SubTitle } from "../../layout/sub-title";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -14,23 +13,24 @@ interface Props {
@observer @observer
export class ClusterWorkspaceSetting extends React.Component<Props> { export class ClusterWorkspaceSetting extends React.Component<Props> {
@observable workspace = this.props.cluster.workspace;
render() { render() {
return <> return (
<h4>Cluster Workspace</h4> <>
<p>Change cluster workspace:</p> <SubTitle title="Cluster Workspace"/>
<p>
Define cluster{" "}
<Link to={workspacesURL()}>
workspace
</Link>.
</p>
<Select <Select
value={workspaceStore.currentWorkspaceId} value={this.props.cluster.workspace}
options={workspaceStore.workspacesList.map(w => ({value: w.id, label: <span>{w.name}</span>}))} onChange={({value}) => this.props.cluster.workspace = value}
onChange={this.changeWorkspace} options={workspaceStore.workspacesList.map(w =>
({value: w.id, label: w.name})
)}
/> />
</>; </>
} );
@autobind()
changeWorkspace({ value: workspace }: SelectOption<string>) {
this.workspace = workspace;
this.props.cluster.workspace = workspace;
} }
} }

View File

@ -0,0 +1,93 @@
import React from "react";
import { observable, reaction, comparer } from "mobx";
import { observer, disposeOnUnmount } from "mobx-react";
import { clusterIpc } from "../../../../common/cluster-ipc";
import { Cluster } from "../../../../main/cluster";
import { Button } from "../../button";
import { Notifications } from "../../notifications";
import { Spinner } from "../../spinner";
interface Props {
cluster: Cluster
feature: string
}
@observer
export class InstallFeature extends React.Component<Props> {
@observable loading = false;
componentDidMount() {
disposeOnUnmount(this,
reaction(() => this.props.cluster.features[this.props.feature], () => {
this.loading = false;
}, { equals: comparer.structural })
);
}
getActionButtons() {
const { cluster, feature } = this.props;
const features = cluster.features[feature];
const disabled = !cluster.isAdmin || this.loading;
const loadingIcon = this.loading ? <Spinner/> : null;
if (!features) return null;
return (
<div className="flex gaps align-center">
{features.canUpgrade &&
<Button
primary
disabled={disabled}
onClick={this.runAction(() =>
clusterIpc.upgradeFeature.invokeFromRenderer(cluster.id, feature))
}
>
Upgrade
</Button>
}
{features.installed &&
<Button
accent
disabled={disabled}
onClick={this.runAction(() =>
clusterIpc.uninstallFeature.invokeFromRenderer(cluster.id, feature))
}
>
Uninstall
</Button>
}
{!features.installed && !features.canUpgrade &&
<Button
primary
disabled={disabled}
onClick={this.runAction(() =>
clusterIpc.installFeature.invokeFromRenderer(cluster.id, feature))
}
>
Install
</Button>
}
{loadingIcon}
{!cluster.isAdmin && <span className='admin-note'>Actions can only be performed by admins.</span>}
</div>
);
}
runAction(action: () => Promise<any>): () => Promise<void> {
return async () => {
try {
this.loading = true;
await action();
} catch (err) {
Notifications.error(err.toString());
}
};
}
render() {
return (
<>
{this.props.children}
<div className="button-area">{this.getActionButtons()}</div>
</>
);
}
}

View File

@ -1,109 +0,0 @@
import React from "react";
import { Cluster } from "../../../../main/cluster";
import { Button } from "../../button";
import { autobind } from "../../../utils";
import { Tooltip, TooltipPosition } from "../../tooltip";
import { MetricsFeature } from "../../../../features/metrics";
import { Spinner } from "../../spinner";
import { Icon } from "../../icon";
import { clusterIpc } from "../../../../common/cluster-ipc";
import { observable } from "mobx";
import { ActionStatus } from "./statuses"
import { observer } from "mobx-react";
interface Props {
cluster: Cluster;
}
@observer
export class InstallMetrics extends React.Component<Props> {
@observable status = ActionStatus.IDLE;
@observable errorText?: string;
render() {
return <>
<h4>Metrics</h4>
<p>
User Mode feature enables non-admin users to see namespaces they have access to.
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
</p>
<div className="center">
{this.getActionButtons()}
</div>
</>;
}
getStatusIcon(): React.ReactNode {
switch (this.status) {
case ActionStatus.IDLE:
return null;
case ActionStatus.PROCESSING:
return <Spinner />;
case ActionStatus.ERROR:
return <Icon size="16px" material="error" title={this.errorText}></Icon>
}
}
getDisabledToolTip(id: string, action: string): React.ReactNode {
const { cluster } = this.props;
if (cluster.isAdmin) {
return null;
}
return (
<Tooltip targetId={id} position={TooltipPosition.TOP}>
{action} only allowed by admins
</Tooltip>
);
}
getActionButtons(): React.ReactNode[] {
const { cluster } = this.props
const buttons = [];
if (cluster.features[MetricsFeature.id]?.canUpgrade) {
buttons.push(
<Button key="upgrade" id="cluster-feature-metrics-upgrade" disabled={!cluster.isAdmin} primary onClick={this.runAction("upgradeFeature")}>
Upgrade {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-metrics-upgrade", "Upgrading")}
</Button>
);
}
if (cluster.features[MetricsFeature.id]?.installed) {
buttons.push(
<Button key="uninstall" id="cluster-feature-metrics-uninstall" disabled={!cluster.isAdmin} primary onClick={this.runAction("uninstallFeature")}>
Uninstall {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-metrics-uninstall", "Uninstalling")}
</Button>
);
} else {
buttons.push(
<Button key="install" id="cluster-feature-metrics-install" disabled={!cluster.isAdmin} primary onClick={this.runAction("installFeature")}>
Install {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-metrics-install", "Installing")}
</Button>
);
}
return buttons;
}
runAction(action: keyof typeof clusterIpc): () => Promise<void> {
return async () => {
const { cluster } = this.props;
console.log(`running ${action} ${MetricsFeature.id} onto ${cluster.preferences.clusterName}`);
try {
this.status = ActionStatus.PROCESSING
await clusterIpc[action].invokeFromRenderer(cluster.id, MetricsFeature.id);
try {
await cluster.refresh();
} catch (err) {
console.error(err);
}
this.status = ActionStatus.IDLE
} catch (err) {
this.status = ActionStatus.ERROR
this.errorText = err.toString()
}
};
}
}

View File

@ -1,108 +0,0 @@
import React from "react";
import { Cluster } from "../../../../main/cluster";
import { Button } from "../../button";
import { autobind } from "../../../utils";
import { Tooltip, TooltipPosition } from "../../tooltip";
import { Spinner } from "../../spinner";
import { Icon } from "../../icon";
import { UserModeFeature } from "../../../../features/user-mode";
import { clusterIpc } from "../../../../common/cluster-ipc";
import { observable } from "mobx";
import { ActionStatus } from "./statuses"
import { observer } from "mobx-react";
interface Props {
cluster: Cluster;
}
@observer
export class InstallUserMode extends React.Component<Props> {
@observable status = ActionStatus.IDLE;
@observable errorText?: string;
render() {
return <>
<h4>User Mode</h4>
<p>
User Mode feature enables non-admin users to see namespaces they have access to.
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
</p>
<div className="center">
{this.getActionButtons()}
</div>
</>;
}
getStatusIcon(): React.ReactNode {
switch (this.status) {
case ActionStatus.IDLE:
return null;
case ActionStatus.PROCESSING:
return <Spinner key="spinner" />;
case ActionStatus.ERROR:
return <Icon key="error" size="16px" material="error" title={this.errorText}></Icon>
}
}
getDisabledToolTip(id: string, action: string): React.ReactNode {
const { cluster } = this.props;
if (cluster.isAdmin) {
return null;
}
return <Tooltip targetId={id} position={TooltipPosition.TOP}>
{action} only allowed by admins
</Tooltip>;
}
getActionButtons(): React.ReactNode[] {
const { cluster } = this.props
const buttons = [];
if (cluster.features[UserModeFeature.id]?.canUpgrade) {
buttons.push(
<Button key="upgrade" id="cluster-feature-user-mode-upgrade" disabled={!cluster.isAdmin} primary onClick={this.runAction("upgradeFeature")}>
Upgrade {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-user-mode-upgrade", "Upgrading")}
</Button>
);
}
if (cluster.features[UserModeFeature.id]?.installed) {
buttons.push(
<Button key="uninstall" id="cluster-feature-user-mode-uninstall" disabled={!cluster.isAdmin} primary onClick={this.runAction("uninstallFeature")}>
Uninstall {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-user-mode-uninstall", "Uninstalling")}
</Button>
);
} else {
buttons.push(
<Button key="install" id="cluster-feature-user-mode-install" disabled={!cluster.isAdmin} primary onClick={this.runAction("installFeature")}>
Install {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-user-mode-install", "Installing")}
</Button>
);
}
return buttons;
}
runAction(action: keyof typeof clusterIpc): () => Promise<void> {
return async () => {
const { cluster } = this.props;
console.log(`running ${action} ${UserModeFeature.id} onto ${cluster.preferences.clusterName}`);
try {
this.status = ActionStatus.PROCESSING
await clusterIpc[action].invokeFromRenderer(cluster.id, UserModeFeature.id);
try {
await cluster.refresh();
} catch (err) {
console.error(err);
}
this.status = ActionStatus.IDLE
} catch (err) {
this.status = ActionStatus.ERROR
this.errorText = err.toString()
}
};
}
}

View File

@ -1,16 +1,12 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../../main/cluster";
import { Button } from "../../button";
import { autobind } from "../../../utils";
import { Spinner } from "../../spinner";
import { Icon } from "../../icon";
import { ConfirmDialog } from "../../confirm-dialog";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { observer } from "mobx-react";
import { clusterIpc } from "../../../../common/cluster-ipc"; import { clusterIpc } from "../../../../common/cluster-ipc";
import { clusterStore } from "../../../../common/cluster-store"; import { clusterStore } from "../../../../common/cluster-store";
import { observable } from "mobx"; import { Cluster } from "../../../../main/cluster";
import { observer } from "mobx-react"; import { autobind } from "../../../utils";
import { RemovalStatus } from "./statuses" import { Button } from "../../button";
import { ConfirmDialog } from "../../confirm-dialog";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -18,46 +14,24 @@ interface Props {
@observer @observer
export class RemoveClusterButton extends React.Component<Props> { export class RemoveClusterButton extends React.Component<Props> {
@observable status = RemovalStatus.PRESENT;
@observable errorText?: string;
render() {
return (
<div className="center">
<Button accent onClick={this.confirmRemoveCluster}>Remove Cluster {this.getStatusIcon()}</Button>
</div>
);
}
getStatusIcon(): React.ReactNode {
switch (this.status) {
case RemovalStatus.PRESENT:
return null;
case RemovalStatus.PROCESSING:
return <Spinner />;
case RemovalStatus.ERROR:
return <Icon size="16px" material="error" title={this.errorText}></Icon>;
}
}
@autobind() @autobind()
confirmRemoveCluster() { confirmRemoveCluster() {
const { cluster } = this.props; const { cluster } = this.props;
ConfirmDialog.open({ ConfirmDialog.open({
message: <p>Are you sure you want to remove <b>{cluster.preferences.clusterName}</b> from Lens?</p>, message: <p>Are you sure you want to remove <b>{cluster.preferences.clusterName}</b> from Lens?</p>,
labelOk: <Trans>Yes</Trans>, labelOk: <Trans>Yes</Trans>,
labelCancel: <Trans>No</Trans>, labelCancel: <Trans>No</Trans>,
ok: async () => { ok: async () => {
try {
this.status = RemovalStatus.PROCESSING;
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
await clusterStore.removeById(cluster.id); await clusterStore.removeById(cluster.id);
} catch (err) {
this.status = RemovalStatus.ERROR;
this.errorText = err.toString();
}
} }
}) })
} }
render() {
return (
<Button accent onClick={this.confirmRemoveCluster} className="button-area">
Remove Cluster
</Button>
);
}
} }

View File

@ -1,24 +0,0 @@
export enum TextInputStatus {
CLEAN = "clean",
DIRTY = "dirty",
UPDATING = "updating",
ERROR = "error",
UPDATED = "updated",
}
export enum GeneralInputStatus {
CLEAN = "clean",
ERROR = "error",
}
export enum ActionStatus {
IDLE = "idle",
PROCESSING = "processing",
ERROR = "error"
}
export enum RemovalStatus {
PRESENT = "present",
PROCESSING = "processing",
ERROR = "error",
}

View File

@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import { Cluster } from "../../../main/cluster"; import { Cluster } from "../../../main/cluster";
import { InstallMetrics } from "./components/install-metrics"; import { InstallFeature } from "./components/install-feature";
import { InstallUserMode } from "./components/install-user-mode"; import { SubTitle } from "../layout/sub-title";
import { MetricsFeature } from "../../../features/metrics";
import { UserModeFeature } from "../../../features/user-mode";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -11,10 +13,30 @@ export class Features extends React.Component<Props> {
render() { render() {
const { cluster } = this.props; const { cluster } = this.props;
return <div> return (
<div>
<h2>Features</h2> <h2>Features</h2>
<InstallMetrics cluster={cluster}/> <InstallFeature cluster={cluster} feature={MetricsFeature.id}>
<InstallUserMode cluster={cluster}/> <>
</div>; <SubTitle title="Metrics"/>
<p>
Enable timeseries data visualization (Prometheus stack) for your cluster.
Install this only if you don't have existing Prometheus stack installed.
You can see preview of manifests{" "}
<a href="https://github.com/lensapp/lens/tree/master/src/features/metrics" target="_blank">here</a>.
</p>
</>
</InstallFeature>
<InstallFeature cluster={cluster} feature={UserModeFeature.id}>
<>
<SubTitle title="User Mode"/>
<p>
User Mode feature enables non-admin users to see namespaces they have access to.{" "}
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
</p>
</>
</InstallFeature>
</div>
);
} }
} }

View File

@ -15,8 +15,6 @@ export class General extends React.Component<Props> {
render() { render() {
return <div> return <div>
<h2>General</h2> <h2>General</h2>
<hr/>
<ClusterNameSetting cluster={this.props.cluster} /> <ClusterNameSetting cluster={this.props.cluster} />
<ClusterWorkspaceSetting cluster={this.props.cluster} /> <ClusterWorkspaceSetting cluster={this.props.cluster} />
<ClusterIconSetting cluster={this.props.cluster} /> <ClusterIconSetting cluster={this.props.cluster} />

View File

@ -10,9 +10,11 @@ export class Removal extends React.Component<Props> {
render() { render() {
const { cluster } = this.props; const { cluster } = this.props;
return <div> return (
<div>
<h2>Removal</h2> <h2>Removal</h2>
<RemoveClusterButton cluster={cluster} /> <RemoveClusterButton cluster={cluster} />
</div>; </div>
);
} }
} }

View File

@ -1,41 +1,40 @@
import React from "react"; import React from "react";
import { Spinner } from "../spinner";
import { Cluster } from "../../../main/cluster"; import { Cluster } from "../../../main/cluster";
import { SubTitle } from "../layout/sub-title";
import { Table, TableCell, TableRow } from "../table";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
} }
export class Status extends React.Component<Props> { export class Status extends React.Component<Props> {
renderStatusRows(): JSX.Element[] { renderStatusRows() {
const { cluster } = this.props; const { cluster } = this.props;
const rows = [
const rows: [string, React.ReactNode][] = [
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"}`], ["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"}`],
["Distribution", cluster.distribution], ["Distribution", cluster.distribution],
["Kerbel Version", cluster.version], ["Kerbel Version", cluster.version],
["API Address", cluster.apiUrl], ["API Address", cluster.apiUrl],
["Nodes Count", cluster.nodes || "0"]
]; ];
return (
if (cluster.nodes > 0) { <Table scrollable={false}>
rows.push(["Nodes Count", cluster.nodes]); {rows.map(([name, value]) => {
} return (
<TableRow key={name}>
return rows <TableCell>{name}</TableCell>
.map(([header, value]) => [ <TableCell className="value">{value}</TableCell>
<h5 key={header+"-header"}>{header}</h5>, </TableRow>
<span key={header + "-value"}>{value}</span> );
]) })}
.flat(); </Table>
);
} }
render() { render() {
const { cluster } = this.props;
return <div> return <div>
<h2>Status</h2> <h2>Status</h2>
<hr/> <SubTitle title="Cluster Status"/>
<h4>Cluster status</h4>
<p> <p>
Cluster status information including: detected distribution, kernel version, and online status. Cluster status information including: detected distribution, kernel version, and online status.
</p> </p>

View File

@ -7,6 +7,12 @@
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
&.interactive {
img {
opacity: .55;
}
}
&.active, &.interactive:hover { &.active, &.interactive:hover {
background-color: #fff; background-color: #fff;
@ -16,7 +22,6 @@
} }
img { img {
opacity: .55;
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
} }

View File

@ -42,12 +42,12 @@ export class ClusterIcon extends React.Component<Props> {
active: isActive, active: isActive,
}); });
return ( return (
<div {...elemProps} className={className} id={clusterIconId}> <div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
{showTooltip && ( {showTooltip && (
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip> <Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
)} )}
{icon && <img src={icon} alt={clusterName}/>} {icon && <img src={icon} alt={clusterName}/>}
{!icon && <Hashicon value={clusterName} options={options}/>} {!icon && <Hashicon value={clusterId} options={options}/>}
{showErrors && isAdmin && eventCount > 0 && ( {showErrors && isAdmin && eventCount > 0 && (
<Badge <Badge
className={cssNames("events-count", errorClass)} className={cssNames("events-count", errorClass)}

View File

@ -33,11 +33,8 @@ export class ClustersMenu extends React.Component<Props> {
@observable showHint = true; @observable showHint = true;
showCluster = (clusterId: ClusterId) => { showCluster = (clusterId: ClusterId) => {
if (clusterStore.activeClusterId === clusterId) { clusterStore.setActive(clusterId);
navigate("/"); // redirect to index navigate("/"); // redirect to index
} else {
clusterStore.activeClusterId = clusterId;
}
} }
addCluster = () => { addCluster = () => {
@ -50,7 +47,10 @@ export class ClustersMenu extends React.Component<Props> {
menu.append(new MenuItem({ menu.append(new MenuItem({
label: _i18n._(t`Settings`), label: _i18n._(t`Settings`),
click: () => navigate(clusterSettingsURL()) click: () => {
clusterStore.setActive(cluster.id);
navigate(clusterSettingsURL())
}
})); }));
if (cluster.online) { if (cluster.online) {
menu.append(new MenuItem({ menu.append(new MenuItem({

View File

@ -4,11 +4,8 @@
} }
label { label {
display: inline-block; display: inline-flex;
border: medium solid;
padding: 10px;
border-radius: 5px;
cursor: pointer; cursor: pointer;
margin: 5px; color: var(--blue);
} }
} }

View File

@ -43,7 +43,7 @@ export enum OverTotalSizeLimitStyle {
export interface BaseProps { export interface BaseProps {
accept?: string; accept?: string;
labelText: string; label: React.ReactNode;
multiple?: boolean; multiple?: boolean;
// limit is the optional maximum number of files to upload // limit is the optional maximum number of files to upload
@ -175,10 +175,10 @@ export class FilePicker extends React.Component<Props> {
} }
render() { render() {
const { accept, labelText, multiple } = this.props; const { accept, label, multiple } = this.props;
return <div className="FilePicker"> return <div className="FilePicker">
<label htmlFor="file-upload">{labelText} {this.getIconRight()}</label> <label className="flex gaps align-center" htmlFor="file-upload">{label} {this.getIconRight()}</label>
<input <input
id="file-upload" id="file-upload"
name="FilePicker" name="FilePicker"

View File

@ -74,7 +74,7 @@
.input-info { .input-info {
.errors { .errors {
color: var(color-error); color: var(--colorError);
font-size: $font-size-small; font-size: $font-size-small;
} }

View File

@ -285,6 +285,7 @@ export class Input extends React.Component<InputProps, State> {
rows: multiLine ? (rows || 1) : null, rows: multiLine ? (rows || 1) : null,
ref: this.bindRef, ref: this.bindRef,
type: "text", type: "text",
spellCheck: "false",
}); });
return ( return (

View File

@ -13,6 +13,11 @@
padding: $spacing; padding: $spacing;
} }
> .head-col {
position: sticky;
border-bottom: 1px solid $grey-800;
}
> .content-col { > .content-col {
margin-right: $spacing; margin-right: $spacing;
background-color: var(--clusters-menu-bgc); background-color: var(--clusters-menu-bgc);
@ -29,6 +34,10 @@
border-left: 1px solid #353a3e; border-left: 1px solid #353a3e;
} }
p {
line-height: 140%;
}
a { a {
color: $colorInfo; color: $colorInfo;
} }

View File

@ -5,6 +5,8 @@ import { cssNames, IClassName } from "../../utils";
interface Props { interface Props {
className?: IClassName; className?: IClassName;
header?: React.ReactNode;
headerClass?: IClassName;
contentClass?: IClassName; contentClass?: IClassName;
infoPanelClass?: IClassName; infoPanelClass?: IClassName;
infoPanel?: React.ReactNode; infoPanel?: React.ReactNode;
@ -13,9 +15,14 @@ interface Props {
@observer @observer
export class WizardLayout extends React.Component<Props> { export class WizardLayout extends React.Component<Props> {
render() { render() {
const { className, contentClass, infoPanelClass, infoPanel, children: content } = this.props; const { className, contentClass, infoPanelClass, infoPanel, header, headerClass, children: content } = this.props;
return ( return (
<div className={cssNames("WizardLayout", className)}> <div className={cssNames("WizardLayout", className)}>
{header && (
<div className={cssNames("head-col flex gaps align-center", headerClass)}>
{header}
</div>
)}
<div className={cssNames("content-col flex column gaps", contentClass)}> <div className={cssNames("content-col flex column gaps", contentClass)}>
{content} {content}
</div> </div>

View File

@ -31,7 +31,7 @@ html {
border-radius: $radius; border-radius: $radius;
background: transparent; background: transparent;
min-height: 0; min-height: 0;
box-shadow: 0 0 0 1px $halfGray; box-shadow: 0 0 0 1px $borderFaintColor;
&--is-focused { &--is-focused {
box-shadow: 0 0 0 2px $primary; box-shadow: 0 0 0 2px $primary;
@ -42,6 +42,10 @@ html {
margin-bottom: 1px; margin-bottom: 1px;
} }
&__single-value {
color: var(--textColorSecondary);
}
&__indicator { &__indicator {
padding: $padding /2; padding: $padding /2;
opacity: .55; opacity: .55;