Migrating Vue components to React and stores refactoring (#585)

Signed-off-by: Roman <ixrock@gmail.com>

Co-authored-by: Sebastian Malton <sebastian@malton.name>
Co-authored-by: Sebastian Malton <smalton@mirantis.com>
Co-authored-by: Lauri Nevala <lauri.nevala@gmail.com>
Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
authored and GitHub committed 2020-08-20 08:53:07 +03:00
1 parent 905bbe9d3f
commit 5670312c47
297 files changed
+8186 -9103

No files matched your search

@@ -0,0 +1,8 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const addClusterRoute: RouteProps = {
path: "/add-cluster"
}
export const addClusterURL = buildURL(addClusterRoute.path)
@@ -0,0 +1,11 @@
.AddCluster {
.Select {
&__control {
box-shadow: 0 0 0 1px $borderFaintColor;
}
}
code {
color: $pink-400;
}
}
@@ -0,0 +1,224 @@
import "./add-cluster.scss"
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { computed, observable } from "mobx";
import { KubeConfig } from "@kubernetes/client-node";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { Select, SelectOption } from "../select";
import { Input } from "../input";
import { AceEditor } from "../ace-editor";
import { Button } from "../button";
import { Icon } from "../icon";
import { WizardLayout } from "../layout/wizard-layout";
import { getKubeConfigLocal, loadConfig, saveConfigToAppFiles, splitConfig, validateConfig } from "../../../common/kube-helpers";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid"
import { navigate } from "../../navigation";
import { userStore } from "../../../common/user-store";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
@observer
export class AddCluster extends React.Component {
readonly custom: any = "custom"
@observable.ref clusterConfig: KubeConfig;
@observable.ref kubeConfig: KubeConfig; // local ~/.kube/config (if available)
@observable.ref error: React.ReactNode;
@observable isWaiting = false
@observable showSettings = false
@observable proxyServer = ""
@observable customConfig = ""
async componentDidMount() {
const kubeConfig: string = await getKubeConfigLocal()
if (kubeConfig) {
this.kubeConfig = loadConfig(kubeConfig)
}
}
componentWillUnmount() {
userStore.markNewContextsAsSeen();
}
@computed get isCustom() {
return this.clusterConfig === this.custom;
}
@computed get clusterOptions() {
const options: SelectOption<KubeConfig>[] = [];
if (this.kubeConfig) {
splitConfig(this.kubeConfig).forEach(kubeConfig => {
const context = kubeConfig.currentContext;
const hasContext = clusterStore.hasContext(context);
if (!hasContext) {
options.push({
value: kubeConfig,
label: context,
});
}
})
}
options.push({
label: <Trans>Custom..</Trans>,
value: this.custom,
});
return options;
}
protected formatClusterContextLabel = ({ value, label }: SelectOption<KubeConfig>) => {
if (value instanceof KubeConfig) {
const context = value.currentContext;
const isNew = userStore.newContexts.has(context);
const className = `${context} kube-context flex gaps align-center`
return (
<div className={className}>
<span>{context}</span>
{isNew && <Icon material="fiber_new"/>}
</div>
)
}
return label;
};
addCluster = async () => {
const { clusterConfig, customConfig, proxyServer } = this;
const clusterId = uuid();
this.isWaiting = true
this.error = ""
try {
const config = this.isCustom ? loadConfig(customConfig) : clusterConfig;
if (!config) {
this.error = <Trans>Please select kubeconfig</Trans>
return;
}
validateConfig(config);
await clusterStore.addCluster({
id: clusterId,
kubeConfigPath: saveConfigToAppFiles(clusterId, config),
workspace: workspaceStore.currentWorkspaceId,
contextName: config.currentContext,
preferences: {
clusterName: config.currentContext,
httpsProxy: proxyServer || undefined,
},
});
navigate(clusterViewURL({ params: { clusterId } }))
} catch (err) {
this.error = String(err);
} finally {
this.isWaiting = false;
}
}
renderInfo() {
return (
<Fragment>
<h2>Clusters associated with Lens</h2>
<p>
Add clusters by clicking the <span className="text-primary">Add Cluster</span> button.
You'll need to obtain a working kubeconfig for the cluster you want to add.
</p>
<p>
Each <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#context" target="_blank">cluster context</a> is added as a separate item in the
left-side cluster menu
to allow you to operate easily on multiple clusters and/or contexts.
</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>.
</p>
<p>
NOTE: Any manually added cluster is not merged into your kubeconfig file.
</p>
<p>
To see your currently enabled config with <code>kubectl</code>, use <code>kubectl config view --minify --raw</code> command in your terminal.
</p>
<p>
When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens
app.
</p>
<a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator" target="_blank">
<h3>OIDC (OpenID Connect)</h3>
</a>
<p>
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>
<p>
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.
One way to achieve this is with <a href="https://github.com/int128/kubelogin" target="_blank">kubelogin</a> tool by removing the tokens
(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.
</p>
<h3>Exec auth plugins</h3>
<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
any binaries
are full paths as Lens app might not be able to call binaries with relative paths. Make also sure that you pass all needed information either as arguments or env variables in the config,
Lens app might not have all login shell env variables set automatically.
</p>
</Fragment>
)
}
render() {
return (
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
<h2><Trans>Add Cluster</Trans></h2>
<p>Choose config:</p>
<Select
placeholder={<Trans>Select kubeconfig</Trans>}
value={this.clusterConfig}
options={this.clusterOptions}
onChange={({ value }: SelectOption) => this.clusterConfig = value}
formatOptionLabel={this.formatClusterContextLabel}
id="kubecontext-select"
/>
<div className="cluster-settings">
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
<Trans>Proxy settings</Trans>
</a>
</div>
{this.showSettings && (
<div className="proxy-settings">
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
<Input
autoFocus
value={this.proxyServer}
onChange={value => this.proxyServer = value}
theme="round-black"
/>
<small className="hint">
{'A HTTP proxy server URL (format: http://<address>:<port>).'}
</small>
</div>
)}
{this.isCustom && (
<div className="custom-kubeconfig flex column gaps box grow">
<p>Kubeconfig:</p>
<AceEditor
autoFocus
showGutter={false}
mode="yaml"
value={this.customConfig}
onChange={value => this.customConfig = value}
/>
</div>
)}
{this.error && (
<div className="error">{this.error}</div>
)}
<div className="actions-panel">
<Button
primary
label={<Trans>Add cluster(s)</Trans>}
onClick={this.addCluster}
waiting={this.isWaiting}
/>
</div>
</WizardLayout>
)
}
}
@@ -0,0 +1,2 @@
export * from "./add-cluster"
export * from "./add-cluster.route"
@@ -1,11 +1,11 @@
import isEqual from "lodash/isEqual";
import { action, observable, when, IReactionDisposer, reaction } from "mobx";
import { action, IReactionDisposer, observable, reaction, when } 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";
import { secretsStore } from "../+config-secrets/secrets.store";
import { getHostedCluster } from "../../../common/cluster-store";
@autobind()
export class ReleaseStore extends ItemStore<HelmRelease> {
@@ -58,8 +58,8 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
this.isLoading = true;
let items;
try {
const { isClusterAdmin, allowedNamespaces } = configStore;
items = await this.loadItems(!isClusterAdmin ? allowedNamespaces : null);
const { isAdmin, allowedNamespaces } = getHostedCluster()
items = await this.loadItems(!isAdmin ? allowedNamespaces : null);
} finally {
if (items) {
items = this.sortItems(items);
@@ -73,8 +73,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
async loadItems(namespaces?: string[]) {
if (!namespaces) {
return helmReleasesApi.list();
}
else {
} else {
return Promise
.all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
.then(items => items.flat());
@@ -0,0 +1,12 @@
import type { IClusterViewRouteParams } from "../cluster-manager/cluster-view.route";
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export interface IClusterSettingsRouteParams extends IClusterViewRouteParams {
}
export const clusterSettingsRoute: RouteProps = {
path: `/cluster/:clusterId/settings`,
}
export const clusterSettingsURL = buildURL<IClusterSettingsRouteParams>(clusterSettingsRoute.path)
@@ -0,0 +1,83 @@
.ClusterSettings {
.WizardLayout {
grid-template-columns: unset;
grid-template-rows: 76px 1fr;
padding: 0;
.head-col {
justify-content: space-between;
:nth-child(2) {
flex: 1 0 0;
}
}
.content-col {
margin: 0;
padding-top: $padding * 3;
background-color: transparent;
.SubTitle {
text-transform: none;
}
> div {
margin-top: $margin * 5;
}
.admin-note {
font-size: small;
opacity: 0.5;
margin-left: $margin;
}
.button-area {
margin-top: $margin * 2;
}
.file-loader {
margin-top: $margin * 2;
}
.hint {
font-size: smaller;
opacity: 0.8;
}
p + p, .hint + p {
padding-top: $padding;
}
}
.status-table {
margin: $margin * 3 0;
.Table {
border: 1px solid var(--drawerSubtitleBackground);
border-radius: $radius;
.TableRow {
&:not(:last-of-type) {
border-bottom: 1px solid var(--drawerSubtitleBackground);
}
.value {
flex-grow: 2;
word-break: break-word;
color: var(--textColorSecondary);
}
}
}
}
.Input, .Select {
margin-top: 10px;
}
.Select {
&__control {
box-shadow: 0 0 0 1px $borderFaintColor;
}
}
}
}
@@ -0,0 +1,43 @@
import "./cluster-settings.scss";
import React from "react";
import { Link } from "react-router-dom";
import { observer } from "mobx-react";
import { Features } from "./features";
import { Removal } from "./removal";
import { Status } from "./status";
import { General } from "./general";
import { WizardLayout } from "../layout/wizard-layout";
import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon";
import { getMatchedCluster } from "../cluster-manager/cluster-view.route";
import { navigate } from "../../navigation";
@observer
export class ClusterSettings extends React.Component {
render() {
const cluster = getMatchedCluster();
if (!cluster) return null;
const header = (
<>
<ClusterIcon
cluster={cluster}
showErrors={false}
showTooltip={false}
/>
<h2>{cluster.preferences.clusterName}</h2>
<Icon material="close" onClick={() => navigate("/")} big/>
</>
);
return (
<div className="ClusterSettings">
<WizardLayout header={header} centered>
<Status cluster={cluster}></Status>
<General cluster={cluster}></General>
<Features cluster={cluster}></Features>
<Removal cluster={cluster}></Removal>
</WizardLayout>
</div>
);
}
}
@@ -0,0 +1,43 @@
import React from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { SubTitle } from "../../layout/sub-title";
interface Props {
cluster: Cluster;
}
@observer
export class ClusterHomeDirSetting extends React.Component<Props> {
@observable directory = this.props.cluster.preferences.terminalCWD || "";
save = () => {
this.props.cluster.preferences.terminalCWD = this.directory;
};
onChange = (value: string) => {
this.directory = value;
}
render() {
return (
<>
<SubTitle title="Working Directory"/>
<p>Terminal working directory.</p>
<Input
theme="round-black"
value={this.directory}
onChange={this.onChange}
onBlur={this.save}
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.
</span>
</>
);
}
}
@@ -0,0 +1,76 @@
import React from "react";
import { Cluster } from "../../../../main/cluster";
import { FilePicker, OverSizeLimitStyle } from "../../file-picker";
import { autobind } from "../../../utils";
import { Button } from "../../button";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { SubTitle } from "../../layout/sub-title";
import { ClusterIcon } from "../../cluster-icon";
enum GeneralInputStatus {
CLEAN = "clean",
ERROR = "error",
}
interface Props {
cluster: Cluster;
}
@observer
export class ClusterIconSetting extends React.Component<Props> {
@observable status = GeneralInputStatus.CLEAN;
@observable errorText?: string;
@autobind()
async onIconPick([file]: File[]) {
const { cluster } = this.props;
try {
if (file) {
const buf = Buffer.from(await file.arrayBuffer());
cluster.preferences.icon = `data:image/jpeg;base64, ${buf.toString('base64')}`;
} else {
// this has to be done as a seperate branch (and not always) because `cluster`
// is observable and triggers an update loop.
cluster.preferences.icon = undefined;
}
} catch (e) {
this.errorText = e.toString()
this.status = GeneralInputStatus.ERROR
}
}
getClearButton() {
if (this.props.cluster.preferences.icon) {
return <Button tooltip="Revert back to default icon" accent onClick={() => this.onIconPick([])}>Clear</Button>
}
}
render() {
const label = (
<>
<ClusterIcon
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
accept="image/*"
label={label}
onOverSizeLimit={OverSizeLimitStyle.FILTER}
handler={this.onIconPick}
/>
{this.getClearButton()}
</div>
</>
);
}
}
@@ -0,0 +1,40 @@
import React from "react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { SubTitle } from "../../layout/sub-title";
import { isRequired } from "../../input/input.validators";
interface Props {
cluster: Cluster;
}
@observer
export class ClusterNameSetting extends React.Component<Props> {
@observable name = this.props.cluster.preferences.clusterName || "";
save = () => {
this.props.cluster.preferences.clusterName = this.name;
};
onChange = (value: string) => {
this.name = value;
}
render() {
return (
<>
<SubTitle title="Cluster Name"/>
<p>Define cluster name.</p>
<Input
theme="round-black"
validators={isRequired}
value={this.name}
onChange={this.onChange}
onBlur={this.save}
/>
</>
);
}
}
@@ -0,0 +1,105 @@
import React from "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";
import { Input } from "../../input";
import { observable, computed } from "mobx";
const options: SelectOption<string>[] = [
{ value: "", label: "Auto detect" },
...prometheusProviders.map(pp => ({value: pp.id, label: pp.name}))
];
interface Props {
cluster: Cluster;
}
@observer
export class ClusterPrometheusSetting extends React.Component<Props> {
@observable path = "";
@observable provider = "";
@computed get canEditPrometheusPath() {
if (this.provider === "" || this.provider === "lens") return false;
return true;
}
componentDidMount() {
const { prometheus, prometheusProvider } = this.props.cluster.preferences;
if (prometheus) {
const prefix = prometheus.prefix || "";
this.path = `${prometheus.namespace}/${prometheus.service}:${prometheus.port}${prefix}`;
}
if (prometheusProvider) {
this.provider = prometheusProvider.type;
}
}
parsePrometheusPath = () => {
if (!this.provider || !this.path) {
return null;
}
const parsed = this.path.split(/\/|:/, 3);
const apiPrefix = this.path.substring(parsed.join("/").length);
if (!parsed[0] || !parsed[1] || !parsed[2]) {
return null;
}
return {
namespace: parsed[0],
service: parsed[1],
port: parseInt(parsed[2]),
prefix: apiPrefix
}
}
onSaveProvider = () => {
this.props.cluster.preferences.prometheusProvider = this.provider ?
{ type: this.provider } :
null;
}
onSavePath = () => {
this.props.cluster.preferences.prometheus = this.parsePrometheusPath();
};
render() {
return (
<>
<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>
<p>Prometheus installation method.</p>
<Select
value={this.provider}
onChange={({value}) => {
this.provider = value;
this.onSaveProvider();
}}
options={options}
/>
<span className="hint">What query format is used to fetch metrics from Prometheus</span>
{this.canEditPrometheusPath && (
<>
<p>Prometheus service address.</p>
<Input
theme="round-black"
value={this.path}
onChange={(value) => this.path = value}
onBlur={this.onSavePath}
placeholder="<namespace>/<service>:<port>"
/>
<span className="hint">
An address to an existing Prometheus installation{" "}
({'<namespace>/<service>:<port>'}). Lens tries to auto-detect address if left empty.
</span>
</>
)}
</>
);
}
}
@@ -0,0 +1,41 @@
import React from "react";
import { observable } from "mobx";
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 {
cluster: Cluster;
}
@observer
export class ClusterProxySetting extends React.Component<Props> {
@observable proxy = this.props.cluster.preferences.httpsProxy || "";
save = () => {
this.props.cluster.preferences.httpsProxy = this.proxy;
};
onChange = (value: string) => {
this.proxy = value;
}
render() {
return (
<>
<SubTitle title="HTTP Proxy"/>
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
<Input
theme="round-black"
value={this.proxy}
onChange={this.onChange}
onBlur={this.save}
placeholder="http://<address>:<port>"
validators={isUrl}
/>
</>
);
}
}
@@ -0,0 +1,36 @@
import React from "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 {
cluster: Cluster;
}
@observer
export class ClusterWorkspaceSetting extends React.Component<Props> {
render() {
return (
<>
<SubTitle title="Cluster Workspace"/>
<p>
Define cluster{" "}
<Link to={workspacesURL()}>
workspace
</Link>.
</p>
<Select
value={this.props.cluster.workspace}
onChange={({value}) => this.props.cluster.workspace = value}
options={workspaceStore.workspacesList.map(w =>
({value: w.id, label: w.name})
)}
/>
</>
);
}
}
@@ -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>
</>
);
}
}
@@ -0,0 +1,37 @@
import React from "react";
import { Trans } from "@lingui/macro";
import { observer } from "mobx-react";
import { clusterIpc } from "../../../../common/cluster-ipc";
import { clusterStore } from "../../../../common/cluster-store";
import { Cluster } from "../../../../main/cluster";
import { autobind } from "../../../utils";
import { Button } from "../../button";
import { ConfirmDialog } from "../../confirm-dialog";
interface Props {
cluster: Cluster;
}
@observer
export class RemoveClusterButton extends React.Component<Props> {
@autobind()
confirmRemoveCluster() {
const { cluster } = this.props;
ConfirmDialog.open({
message: <p>Are you sure you want to remove <b>{cluster.preferences.clusterName}</b> from Lens?</p>,
labelOk: <Trans>Yes</Trans>,
labelCancel: <Trans>No</Trans>,
ok: async () => {
await clusterStore.removeById(cluster.id);
}
})
}
render() {
return (
<Button accent onClick={this.confirmRemoveCluster} className="button-area">
Remove Cluster
</Button>
);
}
}
@@ -0,0 +1,42 @@
import React from "react";
import { Cluster } from "../../../main/cluster";
import { InstallFeature } from "./components/install-feature";
import { SubTitle } from "../layout/sub-title";
import { MetricsFeature } from "../../../features/metrics";
import { UserModeFeature } from "../../../features/user-mode";
interface Props {
cluster: Cluster;
}
export class Features extends React.Component<Props> {
render() {
const { cluster } = this.props;
return (
<div>
<h2>Features</h2>
<InstallFeature cluster={cluster} feature={MetricsFeature.id}>
<>
<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>
);
}
}
@@ -0,0 +1,26 @@
import React from "react";
import { Cluster } from "../../../main/cluster";
import { ClusterNameSetting } from "./components/cluster-name-setting";
import { ClusterWorkspaceSetting } from "./components/cluster-workspace-setting";
import { ClusterIconSetting } from "./components/cluster-icon-setting";
import { ClusterProxySetting } from "./components/cluster-proxy-setting";
import { ClusterPrometheusSetting } from "./components/cluster-prometheus-setting";
import { ClusterHomeDirSetting } from "./components/cluster-home-dir-setting";
interface Props {
cluster: Cluster;
}
export class General extends React.Component<Props> {
render() {
return <div>
<h2>General</h2>
<ClusterNameSetting cluster={this.props.cluster} />
<ClusterWorkspaceSetting cluster={this.props.cluster} />
<ClusterIconSetting cluster={this.props.cluster} />
<ClusterProxySetting cluster={this.props.cluster} />
<ClusterPrometheusSetting cluster={this.props.cluster} />
<ClusterHomeDirSetting cluster={this.props.cluster} />
</div>;
}
}
@@ -0,0 +1,2 @@
export * from "./cluster-settings.route"
export * from "./cluster-settings"
@@ -0,0 +1,20 @@
import React from "react";
import { Cluster } from "../../../main/cluster";
import { RemoveClusterButton } from "./components/remove-cluster-button";
interface Props {
cluster: Cluster;
}
export class Removal extends React.Component<Props> {
render() {
const { cluster } = this.props;
return (
<div>
<h2>Removal</h2>
<RemoveClusterButton cluster={cluster} />
</div>
);
}
}
@@ -0,0 +1,46 @@
import React from "react";
import { Cluster } from "../../../main/cluster";
import { SubTitle } from "../layout/sub-title";
import { Table, TableCell, TableRow } from "../table";
interface Props {
cluster: Cluster;
}
export class Status extends React.Component<Props> {
renderStatusRows() {
const { cluster } = this.props;
const rows = [
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"})`],
["Distribution", cluster.distribution],
["Kerbel Version", cluster.version],
["API Address", cluster.apiUrl],
["Nodes Count", cluster.nodes || "0"]
];
return (
<Table scrollable={false}>
{rows.map(([name, value]) => {
return (
<TableRow key={name}>
<TableCell>{name}</TableCell>
<TableCell className="value">{value}</TableCell>
</TableRow>
);
})}
</Table>
);
}
render() {
return <div>
<h2>Status</h2>
<SubTitle title="Cluster Status"/>
<p>
Cluster status information including: detected distribution, kernel version, and online status.
</p>
<div className="status-table">
{this.renderStatusRows()}
</div>
</div>;
}
}
+7 -9
View File
@@ -13,17 +13,22 @@ 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";
import { isAllowedResource } from "../../../common/rbac";
@observer
export class Cluster extends React.Component {
private dependentStores = [nodesStore, podsStore];
private watchers = [
interval(60, () => clusterStore.getMetrics()),
interval(20, () => eventStore.loadAll())
];
private dependentStores = [nodesStore, podsStore];
@computed get isLoaded() {
return nodesStore.isLoaded && podsStore.isLoaded
}
// todo: refactor
async componentDidMount() {
const { dependentStores } = this;
if (!isAllowedResource("nodes")) {
@@ -46,13 +51,6 @@ export class Cluster extends React.Component {
])
}
@computed get isLoaded() {
return (
nodesStore.isLoaded &&
podsStore.isLoaded
)
}
render() {
const { isLoaded } = this;
return (
+1 -1
View File
@@ -1,2 +1,2 @@
export * from "./cluster.routes"
export * from "./cluster.route"
+1 -1
View File
@@ -10,7 +10,7 @@ import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config
import { configURL } from "./config.route";
import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers";
import { buildURL } from "../../navigation";
import { isAllowedResource } from "../../api/rbac"
import { isAllowedResource } from "../../../common/rbac"
export const certificatesURL = buildURL("/certificates");
export const issuersURL = buildURL("/issuers");
@@ -63,7 +63,7 @@ export class CrdList extends React.Component {
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
// todo: move to global filters
filters: (
<Select
className="group-select"
@@ -11,12 +11,10 @@ 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";
import { Input } from "../input";
// fixme: provide type-safe check for status
interface Props extends KubeObjectDetailsProps<KubeObject & { status: any }> {
interface Props extends KubeObjectDetailsProps {
}
function CrdColumnValue({ value }: { value: any[] | {} | string }) {
+7 -11
View File
@@ -1,13 +1,13 @@
import "./events.scss";
import React from "react";
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { MainLayout } from "../layout/main-layout";
import { eventStore } from "./event.store";
import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object";
import { Trans } from "@lingui/macro";
import { KubeEvent } from "../../api/endpoints/events.api";
import { Tooltip, TooltipContent } from "../tooltip";
import { Tooltip } from "../tooltip";
import { Link } from "react-router-dom";
import { cssNames, IClassName, stopPropagation } from "../../utils";
import { Icon } from "../icon";
@@ -90,18 +90,14 @@ export class Events extends React.Component<Props> {
const detailsUrl = getDetailsUrl(lookupApiLink(involvedObject, event));
return [
{
className: {
warning: isWarning
},
className: { warning: isWarning },
title: (
<>
<Fragment>
<span id={tooltipId}>{message}</span>
<Tooltip htmlFor={tooltipId} following>
<TooltipContent narrow warning={isWarning}>
{message}
</TooltipContent>
<Tooltip targetId={tooltipId} formatters={{ narrow: true, warning: isWarning }}>
{message}
</Tooltip>
</>
</Fragment>
)
},
event.getNs(),
@@ -2,7 +2,6 @@ import "./kube-event-icon.scss";
import React from "react";
import { Icon } from "../icon";
import { TooltipContent } from "../tooltip";
import { KubeObject } from "../../api/kube-object";
import { eventStore } from "./event.store";
import { cssNames } from "../../utils";
@@ -34,15 +33,17 @@ export class KubeEventIcon extends React.Component<Props> {
<Icon
material="warning"
className={cssNames("KubeEventIcon", { warning: event.isWarning() })}
tooltip={(
<TooltipContent className="KubeEventTooltip">
{event.message}
<div className="age">
<Icon material="access_time"/>
{event.getAge(undefined, undefined, true)}
tooltip={{
children: (
<div className="KubeEventTooltip">
<div className="msg">{event.message}</div>
<div className="age">
<Icon material="access_time"/>
{event.getAge(undefined, undefined, true)}
</div>
</div>
</TooltipContent>
)}
)
}}
/>
)
}
@@ -0,0 +1,2 @@
export * from "./landing-page.route"
export * from "./landing-page"
@@ -0,0 +1,8 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const landingRoute: RouteProps = {
path: "/landing"
}
export const landingURL = buildURL(landingRoute.path)
@@ -0,0 +1,8 @@
.LandingPage {
height: 100%;
background: #282b2f url(../../components/icon/crane.svg) no-repeat;
background-position: 0 35%;
background-size: 85%;
background-clip: content-box;
text-align: center;
}
@@ -0,0 +1,28 @@
import "./landing-page.scss"
import React from "react";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
@observer
export class LandingPage extends React.Component {
render() {
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
const noClustersInScope = !clusters.length;
return (
<div className="LandingPage flex">
{noClustersInScope && (
<div className="no-clusters flex column gaps box center">
<h1>
<Trans>Welcome!</Trans>
</h1>
<p>
<Trans>Get started by associating one or more clusters to Lens.</Trans>
</p>
</div>
)}
</div>
)
}
}
@@ -11,7 +11,6 @@ import { namespaceStore } from "./namespace.store";
import { _i18n } from "../../i18n";
import { FilterIcon } from "../item-object-list/filter-icon";
import { FilterType } from "../item-object-list/page-filters.store";
import { isAllowedResource } from "../../api/rbac"
interface Props extends SelectProps {
showIcons?: boolean;
@@ -4,7 +4,7 @@ import { KubeObjectStore } from "../../kube-object.store";
import { Namespace, namespacesApi } from "../../api/endpoints";
import { IQueryParams, navigation, setQueryParams } from "../../navigation";
import { apiManager } from "../../api/api-manager";
import { isAllowedResource } from "../..//api/rbac";
import { isAllowedResource } from "../../../common/rbac";
@autobind()
export class NamespaceStore extends KubeObjectStore<Namespace> {
+1 -1
View File
@@ -12,7 +12,7 @@ import { Ingresses, ingressRoute, ingressURL } from "../+network-ingresses";
import { NetworkPolicies, networkPoliciesRoute, networkPoliciesURL } from "../+network-policies";
import { namespaceStore } from "../+namespaces/namespace.store";
import { networkURL } from "./network.route";
import { isAllowedResource } from "../../api/rbac";
import { isAllowedResource } from "../../../common/rbac";
interface Props extends RouteComponentProps<{}> {
}
+11 -11
View File
@@ -7,7 +7,6 @@ import { disposeOnUnmount, observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { DrawerItem, DrawerItemLabels } from "../drawer";
import { Badge } from "../badge";
import { TooltipContent } from "../tooltip";
import { nodesStore } from "./nodes.store";
import { ResourceMetrics } from "../resource-metrics";
import { podsStore } from "../+workloads-pods/pods.store";
@@ -127,16 +126,17 @@ export class NodeDetails extends React.Component<Props> {
key={type}
label={type}
className={kebabCase(type)}
tooltip={
<TooltipContent tableView>
{Object.entries(condition).map(([key, value]) =>
<div key={key} className="flex gaps align-center">
<div className="name">{upperFirst(key)}</div>
<div className="value">{value}</div>
</div>
)}
</TooltipContent>
}
tooltip={{
formatters: {
tableView: true,
},
children: Object.entries(condition).map(([key, value]) =>
<div key={key} className="flex gaps align-center">
<div className="name">{upperFirst(key)}</div>
<div className="value">{value}</div>
</div>
)
}}
/>
)
})
+21 -15
View File
@@ -1,5 +1,4 @@
import "./nodes.scss";
import React from "react";
import { observer } from "mobx-react";
import { RouteComponentProps } from "react-router";
@@ -15,7 +14,7 @@ import { NodeMenu } from "./node-menu";
import { LineProgress } from "../line-progress";
import { _i18n } from "../../i18n";
import { bytesToUnits } from "../../utils/convertMemory";
import { Tooltip, TooltipContent } from "../tooltip";
import { Tooltip, TooltipPosition } from "../tooltip";
import kebabCase from "lodash/kebabCase";
import upperFirst from "lodash/upperFirst";
import { apiManager } from "../../api/api-manager";
@@ -57,7 +56,10 @@ export class Nodes extends React.Component<Props> {
<LineProgress
max={cores}
value={usage}
tooltip={_i18n._(t`CPU:`) + ` ${Math.ceil(usage * 100) / cores}\%, ` + _i18n._(t`cores:`) + ` ${cores}`}
tooltip={{
position: TooltipPosition.BOTTOM,
children: _i18n._(t`CPU:`) + ` ${Math.ceil(usage * 100) / cores}\%, ` + _i18n._(t`cores:`) + ` ${cores}`
}}
/>
)
}
@@ -71,7 +73,10 @@ export class Nodes extends React.Component<Props> {
<LineProgress
max={capacity}
value={usage}
tooltip={_i18n._(t`Memory:`) + ` ${Math.ceil(usage * 100 / capacity)}%, ${bytesToUnits(usage, 3)}`}
tooltip={{
position: TooltipPosition.BOTTOM,
children: _i18n._(t`Memory:`) + ` ${Math.ceil(usage * 100 / capacity)}%, ${bytesToUnits(usage, 3)}`
}}
/>
)
}
@@ -85,7 +90,10 @@ export class Nodes extends React.Component<Props> {
<LineProgress
max={capacity}
value={usage}
tooltip={_i18n._(t`Disk:`) + ` ${Math.ceil(usage * 100 / capacity)}%, ${bytesToUnits(usage, 3)}`}
tooltip={{
position: TooltipPosition.BOTTOM,
children: _i18n._(t`Disk:`) + ` ${Math.ceil(usage * 100 / capacity)}%, ${bytesToUnits(usage, 3)}`
}}
/>
)
}
@@ -101,15 +109,13 @@ export class Nodes extends React.Component<Props> {
return (
<div key={type} id={tooltipId} className={cssNames("condition", kebabCase(type))}>
{type}
<Tooltip htmlFor={tooltipId} following>
<TooltipContent tableView>
{Object.entries(condition).map(([key, value]) =>
<div key={key} className="flex gaps align-center">
<div className="name">{upperFirst(key)}</div>
<div className="value">{value}</div>
</div>
)}
</TooltipContent>
<Tooltip targetId={tooltipId} formatters={{ tableView: true }}>
{Object.entries(condition).map(([key, value]) =>
<div key={key} className="flex gaps align-center">
<div className="name">{upperFirst(key)}</div>
<div className="value">{value}</div>
</div>
)}
</Tooltip>
</div>)
})
@@ -162,7 +168,7 @@ export class Nodes extends React.Component<Props> {
this.renderDiskUsage(node),
<>
<span id={tooltipId}>{node.getTaints().length}</span>
<Tooltip htmlFor={tooltipId} style={{ whiteSpace: "pre-line" }}>
<Tooltip targetId={tooltipId} style={{ whiteSpace: "pre-line" }}>
{node.getTaints().map(({ key, effect }) => `${key}: ${effect}`).join("\n")}
</Tooltip>
</>,
@@ -0,0 +1,2 @@
export * from "./preferences.route"
export * from "./preferences"
@@ -0,0 +1,8 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const preferencesRoute: RouteProps = {
path: "/preferences"
}
export const preferencesURL = buildURL(preferencesRoute.path)
@@ -0,0 +1,51 @@
.Preferences {
position: fixed!important; // Allows to cover ClustersMenu
z-index: 1;
.WizardLayout {
grid-template-columns: unset;
grid-template-rows: 76px 1fr;
padding: 0;
.content-col {
background-color: transparent;
padding: $padding * 8 0;
h2 {
margin-bottom: $margin * 2;
&:not(:first-child) {
margin-top: $margin * 3;
}
}
.repos {
position: relative;
.Badge {
display: flex;
margin: 0;
margin-bottom: 1px;
padding: $padding $padding * 2;
}
}
.hint {
margin-top: -$margin;
}
}
}
.is-mac & {
.WizardLayout .head-col {
padding-top: 32px;
overflow: hidden;
}
}
.Select {
&__control {
box-shadow: 0 0 0 1px $borderFaintColor;
}
}
}
@@ -0,0 +1,198 @@
import "./preferences.scss"
import React from "react";
import { observer } from "mobx-react";
import { action, computed, observable } from "mobx";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { WizardLayout } from "../layout/wizard-layout";
import { Icon } from "../icon";
import { Select, SelectOption } from "../select";
import { userStore } from "../../../common/user-store";
import { HelmRepo, repoManager } from "../../../main/helm/helm-repo-manager";
import { Input } from "../input";
import { Checkbox } from "../checkbox";
import { Notifications } from "../notifications";
import { Badge } from "../badge";
import { Spinner } from "../spinner";
import { themeStore } from "../../theme.store";
import { history } from "../../navigation";
import { Tooltip } from "../tooltip";
@observer
export class Preferences extends React.Component {
@observable helmLoading = false;
@observable helmRepos: HelmRepo[] = [];
@observable helmAddedRepos = observable.map<string, HelmRepo>();
@observable downloadMirrorOptions: SelectOption<string>[] = [
{ value: "default", label: "Default (Google)" },
{ value: "china", label: "China (Azure)" },
]
@computed get themeOptions(): SelectOption<string>[] {
return themeStore.themes.map(theme => ({
label: theme.name,
value: theme.id,
}))
}
@computed get helmOptions(): SelectOption<HelmRepo>[] {
return this.helmRepos.map(repo => ({
label: repo.name,
value: repo,
}))
}
async componentDidMount() {
await this.loadHelmRepos();
}
@action
async loadHelmRepos() {
this.helmLoading = true;
try {
if (!this.helmRepos.length) {
this.helmRepos = await repoManager.loadAvailableRepos(); // via https://helm.sh
}
const repos = await repoManager.repositories(); // via helm-cli
this.helmAddedRepos.clear();
repos.forEach(repo => this.helmAddedRepos.set(repo.name, repo));
} catch (err) {
Notifications.error(err);
}
this.helmLoading = false;
}
async addRepo(repo: HelmRepo) {
try {
await repoManager.addRepo(repo);
this.helmAddedRepos.set(repo.name, repo);
} catch (err) {
Notifications.error(<Trans>Adding helm branch <b>{repo.name}</b> has failed: {String(err)}</Trans>)
}
}
async removeRepo(repo: HelmRepo) {
try {
await repoManager.removeRepo(repo);
this.helmAddedRepos.delete(repo.name);
} catch (err) {
Notifications.error(
<Trans>Removing helm branch <b>{repo.name}</b> has failed: {String(err)}</Trans>
)
}
}
onRepoSelect = async ({ value: repo }: SelectOption<HelmRepo>) => {
const isAdded = this.helmAddedRepos.has(repo.name);
if (isAdded) {
Notifications.ok(<Trans>Helm branch <b>{repo.name}</b> already in use</Trans>)
return;
}
this.helmLoading = true;
await this.addRepo(repo);
this.helmLoading = false;
}
formatHelmOptionLabel = ({ value: repo }: SelectOption<HelmRepo>) => {
const isAdded = this.helmAddedRepos.has(repo.name);
return (
<div className="flex gaps">
<span>{repo.name}</span>
{isAdded && <Icon small material="check" className="box right"/>}
</div>
)
}
render() {
const { preferences } = userStore;
const header = (
<>
<h2>Preferences</h2>
<Icon material="close" big onClick={history.goBack}/>
</>
);
return (
<div className="Preferences">
<WizardLayout header={header} centered>
<h2><Trans>Color Theme</Trans></h2>
<Select
options={this.themeOptions}
value={preferences.colorTheme}
onChange={({ value }: SelectOption) => preferences.colorTheme = value}
/>
<h2><Trans>Download Mirror</Trans></h2>
<Select
placeholder={<Trans>Download mirror for kubectl</Trans>}
options={this.downloadMirrorOptions}
value={preferences.downloadMirror}
onChange={({ value }: SelectOption) => preferences.downloadMirror = value}
/>
<h2><Trans>Helm</Trans></h2>
<Select
placeholder={<Trans>Repositories</Trans>}
isLoading={this.helmLoading}
isDisabled={this.helmLoading}
options={this.helmOptions}
onChange={this.onRepoSelect}
formatOptionLabel={this.formatHelmOptionLabel}
controlShouldRenderValue={false}
/>
<div className="repos flex gaps column">
{Array.from(this.helmAddedRepos).map(([name, repo]) => {
const tooltipId = `message-${name}`;
return (
<Badge key={name} className="added-repo flex gaps align-center justify-space-between">
<span id={tooltipId} className="repo">{name}</span>
<Icon
material="delete"
onClick={() => this.removeRepo(repo)}
tooltip={<Trans>Remove</Trans>}
/>
<Tooltip targetId={tooltipId} formatters={{ narrow: true }}>
{repo.url}
</Tooltip>
</Badge>
)
})}
</div>
<h2><Trans>HTTP Proxy</Trans></h2>
<Input
theme="round-black"
placeholder={_i18n._(t`Type HTTP proxy url (example: http://proxy.acme.org:8080)`)}
value={preferences.httpsProxy || ""}
onChange={v => preferences.httpsProxy = v}
/>
<small className="hint">
<Trans>Proxy is used only for non-cluster communication.</Trans>
</small>
<h2><Trans>Certificate Trust</Trans></h2>
<Checkbox
label={<Trans>Allow untrusted Certificate Authorities</Trans>}
value={preferences.allowUntrustedCAs}
onChange={v => preferences.allowUntrustedCAs = v}
/>
<small className="hint">
<Trans>This will make Lens to trust ANY certificate authority without any validations.</Trans>{" "}
<Trans>Needed with some corporate proxies that do certificate re-writing.</Trans>{" "}
<Trans>Does not affect cluster communications!</Trans>
</small>
<h2><Trans>Telemetry & Usage Tracking</Trans></h2>
<Checkbox
label={<Trans>Allow telemetry & usage tracking</Trans>}
value={preferences.allowTelemetry}
onChange={v => preferences.allowTelemetry = v}
/>
<small className="hint">
<Trans>Telemetry & usage data is collected to continuously improve the Lens experience.</Trans>
</small>
</WizardLayout>
</div>
);
}
}
+3 -4
View File
@@ -9,9 +9,9 @@ import { MainLayout, TabRoute } from "../layout/main-layout";
import { PersistentVolumes, volumesRoute, volumesURL } from "../+storage-volumes";
import { StorageClasses, storageClassesRoute, storageClassesURL } from "../+storage-classes";
import { PersistentVolumeClaims, volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims";
import { configStore } from "../../config.store";
import { namespaceStore } from "../+namespaces/namespace.store";
import { storageURL } from "./storage.route";
import { isAllowedResource } from "../../../common/rbac";
interface Props extends RouteComponentProps<{}> {
}
@@ -20,7 +20,6 @@ interface Props extends RouteComponentProps<{}> {
export class Storage extends React.Component<Props> {
static get tabRoutes() {
const tabRoutes: TabRoute[] = [];
const { allowedResources } = configStore;
const query = namespaceStore.getContextParams()
tabRoutes.push({
@@ -30,7 +29,7 @@ export class Storage extends React.Component<Props> {
path: volumeClaimsRoute.path,
})
if (allowedResources.includes('persistentvolumes')) {
if (isAllowedResource('persistentvolumes')) {
tabRoutes.push({
title: <Trans>Persistent Volumes</Trans>,
component: PersistentVolumes,
@@ -39,7 +38,7 @@ export class Storage extends React.Component<Props> {
});
}
if (allowedResources.includes('storageclasses')) {
if (isAllowedResource('storageclasses')) {
tabRoutes.push({
title: <Trans>Storage Classes</Trans>,
component: StorageClasses,
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { RouteComponentProps } from "react-router";
import { Icon } from "../icon";
import { IRoleBindingsRouteParams } from "../+user-management/user-management.routes";
import { IRoleBindingsRouteParams } from "../+user-management/user-management.route";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { clusterRoleBindingApi, RoleBinding, roleBindingApi } from "../../api/endpoints";
import { roleBindingsStore } from "./role-bindings.store";
@@ -4,7 +4,7 @@ import React from "react";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { RouteComponentProps } from "react-router";
import { IRolesRouteParams } from "../+user-management/user-management.routes";
import { IRolesRouteParams } from "../+user-management/user-management.route";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { rolesStore } from "./roles.store";
import { clusterRoleApi, Role, roleApi } from "../../api/endpoints";
@@ -1,2 +1,2 @@
export * from "./user-management"
export * from "./user-management.routes"
export * from "./user-management.route"
@@ -1,5 +1,4 @@
import "./user-management.scss"
import React from "react";
import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
@@ -9,10 +8,10 @@ import { MainLayout, TabRoute } from "../layout/main-layout";
import { Roles } from "../+user-management-roles";
import { RoleBindings } from "../+user-management-roles-bindings";
import { ServiceAccounts } from "../+user-management-service-accounts";
import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL, usersManagementURL } from "./user-management.routes";
import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL, usersManagementURL } from "./user-management.route";
import { namespaceStore } from "../+namespaces/namespace.store";
import { configStore } from "../../config.store";
import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies";
import { isAllowedResource } from "../../../common/rbac";
interface Props extends RouteComponentProps<{}> {
}
@@ -21,7 +20,6 @@ interface Props extends RouteComponentProps<{}> {
export class UserManagement extends React.Component<Props> {
static get tabRoutes() {
const tabRoutes: TabRoute[] = [];
const { allowedResources } = configStore;
const query = namespaceStore.getContextParams()
tabRoutes.push(
{
@@ -43,7 +41,7 @@ export class UserManagement extends React.Component<Props> {
path: rolesRoute.path,
},
)
if (allowedResources.includes("podsecuritypolicies")) {
if (isAllowedResource("podsecuritypolicies")) {
tabRoutes.push({
title: <Trans>Pod Security Policies</Trans>,
component: PodSecurityPolicies,
@@ -0,0 +1,2 @@
export * from "./whats-new.route"
export * from "./whats-new"
@@ -0,0 +1,8 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const whatsNewRoute: RouteProps = {
path: "/what-s-new"
}
export const whatsNewURL = buildURL(whatsNewRoute.path)
@@ -0,0 +1,36 @@
.WhatsNew {
$spacing: $padding * 2;
background: $mainBackground url(../../components/icon/crane.svg) no-repeat;
background-position: 0 35%;
background-size: 85%;
background-clip: content-box;
.logo {
width: 200px;
margin-bottom: $spacing;
}
> .content {
@include custom-scrollbar;
margin-top: $spacing;
padding: $spacing * 2;
a {
color: $colorInfo;
text-decoration: underline;
}
ul {
list-style: disc inside;
line-height: 120%;
padding-left: $spacing * 2;
}
}
> .bottom {
text-align: center;
padding: $spacing;
background: $contentColor;
}
}
@@ -0,0 +1,43 @@
import "./whats-new.scss"
import fs from "fs";
import path from "path";
import React from "react";
import { observer } from "mobx-react";
import { userStore } from "../../../common/user-store"
import { navigate } from "../../navigation";
import { Button } from "../button";
import { Trans } from "@lingui/macro";
import marked from "marked"
@observer
export class WhatsNew extends React.Component {
releaseNotes = fs.readFileSync(path.join(__static, "RELEASE_NOTES.md")).toString();
ok = () => {
navigate("/");
userStore.saveLastSeenAppVersion();
}
render() {
const logo = require("../../components/icon/lens-logo.svg");
const releaseNotes = marked(this.releaseNotes);
return (
<div className="WhatsNew flex column">
<div className="content box grow">
<img className="logo" src={logo} alt="Lens"/>
<div
className="release-notes flex column gaps"
dangerouslySetInnerHTML={{ __html: releaseNotes }}
/>
</div>
<div className="bottom">
<Button
primary autoFocus
label={<Trans>Ok, got it!</Trans>}
onClick={this.ok}
/>
</div>
</div>
);
}
}
@@ -98,7 +98,7 @@ export function DeploymentMenu(props: KubeObjectMenuProps<Deployment>) {
return (
<KubeObjectMenu {...props}>
<MenuItem onClick={() => DeploymentScaleDialog.open(object)}>
<Icon material="control_camera" title={_i18n._(t`Scale`)} interactive={toolbar}/>
<Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/>
<span className="title"><Trans>Scale</Trans></span>
</MenuItem>
</KubeObjectMenu>
@@ -15,13 +15,11 @@ import { cronJobStore } from "../+workloads-cronjobs/cronjob.store";
import { namespaceStore } from "../+namespaces/namespace.store";
import { PageFiltersList } from "../item-object-list/page-filters-list";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select";
import { configStore } from "../../config.store";
import { isAllowedResource } from "../../api/rbac";
import { isAllowedResource } from "../../../common/rbac";
@observer
export class OverviewStatuses extends React.Component {
render() {
const { allowedResources } = configStore;
const { contextNs } = namespaceStore;
const pods = isAllowedResource("pods") ? podsStore.getAllByNs(contextNs) : [];
const deployments = isAllowedResource("deployments") ? deploymentStore.getAllByNs(contextNs) : [];
@@ -37,37 +35,37 @@ export class OverviewStatuses extends React.Component {
</div>
<PageFiltersList/>
<div className="workloads">
{ isAllowedResource("pods") &&
{isAllowedResource("pods") &&
<div className="workload">
<div className="title"><Link to={podsURL()}><Trans>Pods</Trans> ({pods.length})</Link></div>
<OverviewWorkloadStatus status={podsStore.getStatuses(pods)}/>
</div>
}
{ isAllowedResource("deployments") &&
{isAllowedResource("deployments") &&
<div className="workload">
<div className="title"><Link to={deploymentsURL()}><Trans>Deployments</Trans> ({deployments.length})</Link></div>
<OverviewWorkloadStatus status={deploymentStore.getStatuses(deployments)}/>
</div>
}
{ isAllowedResource("statefulsets") &&
{isAllowedResource("statefulsets") &&
<div className="workload">
<div className="title"><Link to={statefulSetsURL()}><Trans>StatefulSets</Trans> ({statefulSets.length})</Link></div>
<OverviewWorkloadStatus status={statefulSetStore.getStatuses(statefulSets)}/>
</div>
}
{ isAllowedResource("daemonsets") &&
{isAllowedResource("daemonsets") &&
<div className="workload">
<div className="title"><Link to={daemonSetsURL()}><Trans>DaemonSets</Trans> ({daemonSets.length})</Link></div>
<OverviewWorkloadStatus status={daemonSetStore.getStatuses(daemonSets)}/>
</div>
}
{ isAllowedResource("jobs") &&
{isAllowedResource("jobs") &&
<div className="workload">
<div className="title"><Link to={jobsURL()}><Trans>Jobs</Trans> ({jobs.length})</Link></div>
<OverviewWorkloadStatus status={jobStore.getStatuses(jobs)}/>
</div>
}
{ isAllowedResource("cronjobs") &&
{isAllowedResource("cronjobs") &&
<div className="workload">
<div className="title"><Link to={cronJobsURL()}><Trans>CronJobs</Trans> ({cronJobs.length})</Link></div>
<OverviewWorkloadStatus status={cronJobStore.getStatuses(cronJobs)}/>
@@ -17,7 +17,7 @@ import { cronJobStore } from "../+workloads-cronjobs/cronjob.store";
import { Spinner } from "../spinner";
import { Events } from "../+events";
import { KubeObjectStore } from "../../kube-object.store";
import { isAllowedResource } from "../../api/rbac"
import { isAllowedResource } from "../../../common/rbac"
interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> {
}
@@ -1,5 +1,6 @@
import React, { useContext } from "react";
import { t } from "@lingui/macro";
import { observer } from "mobx-react";
import { IPodMetrics } from "../../api/endpoints";
import { BarChart, cpuOptions, memoryOptions } from "../chart";
import { isMetricsEmpty, normalizeMetrics } from "../../api/endpoints/metrics.api";
@@ -10,7 +11,7 @@ import { themeStore } from "../../theme.store";
type IContext = IResourceMetricsValue<any, { metrics: IPodMetrics }>;
export const ContainerCharts = () => {
export const ContainerCharts = observer(() => {
const { params: { metrics }, tabId } = useContext<IContext>(ResourceMetricsContext);
const { chartCapacityColor } = themeStore.activeTheme.colors;
@@ -100,4 +101,4 @@ export const ContainerCharts = () => {
data={{ datasets: datasets[tabId] }}
/>
);
}
})
@@ -226,7 +226,7 @@ export class PodLogsDialog extends React.Component<Props> {
tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)}
/>
<Icon
material="save_alt"
material="get_app"
onClick={this.downloadLogs}
tooltip={_i18n._(t`Save`)}
/>
@@ -14,7 +14,6 @@ import { Pod, podsApi } from "../../api/endpoints";
import { PodMenu } from "./pod-menu";
import { StatusBrick } from "../status-brick";
import { cssNames, stopPropagation } from "../../utils";
import { TooltipContent } from "../tooltip";
import { KubeEventIcon } from "../+events/kube-event-icon";
import { getDetailsUrl } from "../../navigation";
import toPairs from "lodash/toPairs";
@@ -42,26 +41,29 @@ export class Pods extends React.Component<Props> {
renderContainersStatus(pod: Pod) {
return pod.getContainerStatuses().map(containerStatus => {
const { name, state, ready } = containerStatus;
const tooltip = (
<TooltipContent tableView>
{Object.keys(state).map(status => (
<Fragment key={status}>
<div className="title">
{name} <span className="text-secondary">({status}{ready ? ", ready" : ""})</span>
</div>
{toPairs(state[status]).map(([name, value]) => (
<div key={name} className="flex gaps align-center">
<div className="name">{startCase(name)}</div>
<div className="value">{value}</div>
</div>
))}
</Fragment>
))}
</TooltipContent>
);
return (
<Fragment key={name}>
<StatusBrick className={cssNames(state, { ready })} tooltip={tooltip}/>
<StatusBrick
className={cssNames(state, { ready })}
tooltip={{
formatters: {
tableView: true
},
children: Object.keys(state).map(status => (
<Fragment key={status}>
<div className="title">
{name} <span className="text-secondary">({status}{ready ? ", ready" : ""})</span>
</div>
{toPairs(state[status]).map(([name, value]) => (
<div key={name} className="flex gaps align-center">
<div className="name">{startCase(name)}</div>
<div className="value">{value}</div>
</div>
))}
</Fragment>
))
}}
/>
</Fragment>
)
});
@@ -15,7 +15,7 @@ import { DaemonSets } from "../+workloads-daemonsets";
import { StatefulSets } from "../+workloads-statefulsets";
import { Jobs } from "../+workloads-jobs";
import { CronJobs } from "../+workloads-cronjobs";
import { isAllowedResource } from "../../api/rbac"
import { isAllowedResource } from "../../../common/rbac"
interface Props extends RouteComponentProps {
}
@@ -0,0 +1,2 @@
export * from "./workspaces.route"
export * from "./workspaces"
@@ -0,0 +1,7 @@
.WorkspaceMenu {
border-radius: $radius;
.workspaces-title {
padding: $padding;
}
}
@@ -0,0 +1,51 @@
import "./workspace-menu.scss"
import React from "react";
import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { workspacesURL } from "./workspaces.route";
import { Trans } from "@lingui/macro";
import { Menu, MenuItem, MenuProps } from "../menu";
import { Icon } from "../icon";
import { observable } from "mobx";
import { workspaceStore } from "../../../common/workspace-store";
import { cssNames } from "../../utils";
interface Props extends Partial<MenuProps> {
}
@observer
export class WorkspaceMenu extends React.Component<Props> {
@observable menuVisible = false;
render() {
const { className, ...menuProps } = this.props;
const { workspacesList, currentWorkspace } = workspaceStore;
return (
<Menu
{...menuProps}
usePortal
className={cssNames("WorkspaceMenu", className)}
isOpen={this.menuVisible}
open={() => this.menuVisible = true}
close={() => this.menuVisible = false}
>
<Link className="workspaces-title" to={workspacesURL()}>
<Trans>Workspaces</Trans>
</Link>
{workspacesList.map(({ id: workspaceId, name, description }) => {
return (
<MenuItem
key={workspaceId}
title={description}
active={workspaceId === currentWorkspace.id}
onClick={() => workspaceStore.setActive(workspaceId)}
>
<Icon small material="layers"/>
<span className="workspace">{name}</span>
</MenuItem>
)
})}
</Menu>
)
}
}
@@ -0,0 +1,8 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const workspacesRoute: RouteProps = {
path: "/workspaces"
}
export const workspacesURL = buildURL(workspacesRoute.path)
@@ -0,0 +1,14 @@
.Workspaces {
.workspace {
--flex-gap: #{$padding};
padding: $padding / 2;
&.default {
font-style: italic;
}
> .description {
flex: 1;
}
}
}
@@ -0,0 +1,174 @@
import "./workspaces.scss"
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { computed, observable, toJS } from "mobx";
import { t, Trans } from "@lingui/macro";
import { WizardLayout } from "../layout/wizard-layout";
import { Workspace, WorkspaceId, workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid"
import { _i18n } from "../../i18n";
import { ConfirmDialog } from "../confirm-dialog";
import { Icon } from "../icon";
import { Input } from "../input";
import { cssNames, prevDefault } from "../../utils";
import { Button } from "../button";
@observer
export class Workspaces extends React.Component {
@observable editingWorkspaces = observable.map<WorkspaceId, Workspace>();
@computed get workspaces(): Workspace[] {
const allWorkspaces = new Map([
...workspaceStore.workspaces,
...this.editingWorkspaces,
]);
return Array.from(allWorkspaces.values());
}
renderInfo() {
return (
<Fragment>
<h2><Trans>What is a Workspace?</Trans></h2>
<p className="info">
<Trans>Workspaces are used to organize number of clusters into logical groups.</Trans>
</p>
<p>
<Trans>A single workspaces contains a list of clusters and their full configuration.</Trans>
</p>
</Fragment>
)
}
saveWorkspace = (id: WorkspaceId) => {
const draft = toJS(this.editingWorkspaces.get(id));
if (draft) {
this.clearEditing(id);
workspaceStore.saveWorkspace(draft);
}
}
addWorkspace = () => {
const workspaceId = uuid();
this.editingWorkspaces.set(workspaceId, {
id: workspaceId,
name: "",
description: "",
})
}
editWorkspace = (id: WorkspaceId) => {
const workspace = workspaceStore.getById(id);
this.editingWorkspaces.set(id, toJS(workspace));
}
clearEditing = (id: WorkspaceId) => {
this.editingWorkspaces.delete(id);
}
removeWorkspace = (id: WorkspaceId) => {
const workspace = workspaceStore.getById(id);
ConfirmDialog.open({
okButtonProps: {
label: _i18n._(t`Remove Workspace`),
primary: false,
accent: true,
},
ok: () => {
this.clearEditing(id);
workspaceStore.removeWorkspace(id);
},
message: (
<div className="confirm flex column gaps">
<p>
<Trans>Are you sure you want remove workspace <b>{workspace.name}</b>?</Trans>
</p>
<p className="info">
<Trans>All clusters within workspace will be cleared as well</Trans>
</p>
</div>
),
})
}
render() {
return (
<WizardLayout className="Workspaces" infoPanel={this.renderInfo()}>
<h2>
<Trans>Workspaces</Trans>
</h2>
<div className="items flex column gaps">
{this.workspaces.map(({ id: workspaceId, name, description }) => {
const isActive = workspaceStore.currentWorkspaceId === workspaceId;
const isDefault = workspaceStore.isDefault(workspaceId);
const isEditing = this.editingWorkspaces.has(workspaceId);
const editingWorkspace = this.editingWorkspaces.get(workspaceId);
const className = cssNames("workspace flex gaps align-center", {
active: isActive,
editing: isEditing,
default: isDefault,
});
return (
<div key={workspaceId} className={className}>
{!isEditing && (
<Fragment>
<span className="name flex gaps align-center">
<a href="#" onClick={prevDefault(() => workspaceStore.setActive(workspaceId))}>{name}</a>
{isActive && <span> <Trans>(current)</Trans></span>}
</span>
<span className="description">{description}</span>
{!isDefault && (
<Fragment>
<Icon
material="edit"
tooltip={<Trans>Edit</Trans>}
onClick={() => this.editWorkspace(workspaceId)}
/>
<Icon
material="delete"
tooltip={<Trans>Delete</Trans>}
onClick={() => this.removeWorkspace(workspaceId)}
/>
</Fragment>
)}
</Fragment>
)}
{isEditing && (
<Fragment>
<Input
className="name"
placeholder={_i18n._(t`Name`)}
value={editingWorkspace.name}
onChange={v => editingWorkspace.name = v}
/>
<Input
className="description"
placeholder={_i18n._(t`Description`)}
value={editingWorkspace.description}
onChange={v => editingWorkspace.description = v}
/>
<Icon
material="cancel"
tooltip={<Trans>Cancel</Trans>}
onClick={() => this.clearEditing(workspaceId)}
/>
<Icon
material="save"
tooltip={<Trans>Save</Trans>}
onClick={() => this.saveWorkspace(workspaceId)}
/>
</Fragment>
)}
</div>
)
})}
</div>
<Button
primary
className="box left"
label={<Trans>Add Workspace</Trans>}
onClick={this.addWorkspace}
/>
</WizardLayout>
);
}
}
+1 -6
View File
@@ -9,7 +9,6 @@ export type AnimateName = "opacity" | "slide-right" | "opacity-scale" | string;
export interface AnimateProps {
name?: AnimateName; // predefined names in css
enter?: boolean;
enabled?: boolean;
onEnter?: () => void;
onLeave?: () => void;
}
@@ -21,7 +20,6 @@ export class Animate extends React.Component<AnimateProps> {
static defaultProps: AnimateProps = {
name: "opacity",
enter: true,
enabled: true,
onEnter: noop,
onLeave: noop,
};
@@ -80,10 +78,7 @@ export class Animate extends React.Component<AnimateProps> {
}
render() {
const { name, enabled, children } = this.props;
if (!enabled) {
return children;
}
const { name } = this.props;
const contentElem = this.contentElem;
return React.cloneElement(contentElem, {
className: cssNames("Animate", name, contentElem.props.className, this.statusClassName),
@@ -13,7 +13,6 @@ interface Props {
export class AppInit extends React.Component<Props> {
static async start(rootElem: HTMLElement) {
render(<AppInit/>, rootElem); // show loading indicator asap
await AppInit.readyStateCheck(rootElem); // wait while all good to run
}
+45 -19
View File
@@ -8,8 +8,13 @@
border: 0;
outline: none;
-webkit-font-smoothing: antialiased;
-webkit-user-drag: none;
-webkit-app-region: no-drag;
}
:root {
--mainBackground: #1e2124;
--textColorPrimary: #87909c;
--clusters-menu-bgc: #252729;
--main-layout-header: 40px;
}
::selection {
@@ -26,12 +31,26 @@ html {
html, body {
height: 100%;
overflow: hidden;
}
#app {
height: 100%;
min-height: 100%;
> * {
height: inherit;
}
}
#draggable-top {
@include set-draggable;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: var(--main-layout-header);
z-index: 1000;
pointer-events: none;
}
body {
@@ -60,37 +79,37 @@ hr {
}
h1 {
font-size: 11.2rem;
font-weight: $font-weight-thin;
color: white;
font-size: 28px;
font-weight: normal;
letter-spacing: -.010em;
margin: 0;
}
h2 {
@extend h1;
font-size: 5.6 * $unit;
font-size: 24px;
}
h3 {
@extend h2;
font-size: 4.5 * $unit;
font-size: 20px;
}
h4 {
@extend h2;
font-size: 3.4 * $unit;
@extend h3;
font-size: 18px;
}
h5 {
@extend h2;
font-size: 2.6 * $unit;
@extend h4;
padding: $padding / 2 0;
font-size: 16px;
}
h6 {
@extend h2;
font-size: 2 * $unit;
font-weight: $font-weight-bold;
@extend h5;
font-size: 12px;
}
small {
@@ -98,14 +117,21 @@ small {
}
code {
@include custom-scrollbar;
display: inline-block;
padding: 0.2em;
vertical-align: middle;
border-radius: $radius;
font-family: $font-monospace;
font-size: $font-size * .9;
display: block;
background: #1a1919;
color: #b4b5b4;
padding: $padding * 2;
white-space: pre;
&.block {
@include hidden-scrollbar;
background: #1a1919;
display: block;
white-space: pre;
padding: $padding * 2;
}
}
a {
+38 -38
View File
@@ -1,18 +1,14 @@
import "./app.scss";
import React from "react";
import { render } from "react-dom";
import { Redirect, Route, Router, Switch } from "react-router";
import { observer } from "mobx-react";
import { I18nProvider } from '@lingui/react'
import { _i18n, i18nStore } from "../i18n";
import { browserHistory } from "../navigation";
import { Redirect, Route, Router, Switch } from "react-router";
import { I18nProvider } from "@lingui/react";
import { _i18n } from "../i18n";
import { history } from "../navigation";
import { Notifications } from "./notifications";
import { NotFound } from "./+404";
import { configStore } from "../config.store";
import { UserManagement } from "./+user-management/user-management";
import { ConfirmDialog } from "./confirm-dialog";
import { usersManagementRoute } from "./+user-management/user-management.routes";
import { usersManagementRoute } from "./+user-management/user-management.route";
import { clusterRoute, clusterURL } from "./+cluster";
import { KubeConfigDialog } from "./kubeconfig-dialog/kubeconfig-dialog";
import { Nodes, nodesRoute } from "./+nodes";
@@ -24,7 +20,6 @@ import { Cluster } from "./+cluster/cluster";
import { Config, configRoute } from "./+config";
import { Events } from "./+events/events";
import { eventRoute } from "./+events";
import { ErrorBoundary } from "./error-boundary";
import { Apps, appsRoute } from "./+apps";
import { KubeObjectDetails } from "./kube-object/kube-object-details";
import { AddRoleBindingDialog } from "./+user-management-roles-bindings";
@@ -32,46 +27,54 @@ import { PodLogsDialog } from "./+workloads-pods/pod-logs-dialog";
import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog";
import { CustomResources } from "./+custom-resources/custom-resources";
import { crdRoute } from "./+custom-resources";
import { isAllowedResource } from "../api/rbac";
import { isAllowedResource } from "../../common/rbac";
import { ErrorBoundary } from "./error-boundary";
import { Terminal } from "./dock/terminal";
import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store";
import logger from "../../main/logger";
import { clusterIpc } from "../../common/cluster-ipc";
import { webFrame } from "electron";
@observer
export class App extends React.Component {
static rootElem = document.getElementById('app');
static async init() {
await i18nStore.init();
await configStore.init();
await Terminal.preloadFonts();
render(<App/>, App.rootElem);
const clusterId = getHostedClusterId();
logger.info(`[APP]: Init dashboard, clusterId=${clusterId}`)
await Terminal.preloadFonts()
await clusterIpc.init.invokeFromRenderer(clusterId, webFrame.routingId);
await getHostedCluster().whenInitialized;
}
get startURL() {
if (isAllowedResource(["events", "nodes", "pods"])) {
return clusterURL();
}
return workloadsURL();
}
render() {
const homeUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL();
return (
<I18nProvider i18n={_i18n}>
<Router history={browserHistory}>
<Router history={history}>
<ErrorBoundary>
<Switch>
<Switch>
<Route component={Cluster} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/>
<Route component={Workloads} {...workloadsRoute}/>
<Route component={Config} {...configRoute}/>
<Route component={Network} {...networkRoute}/>
<Route component={Storage} {...storageRoute}/>
<Route component={Namespaces} {...namespacesRoute}/>
<Route component={Events} {...eventRoute}/>
<Route component={CustomResources} {...crdRoute}/>
<Route component={UserManagement} {...usersManagementRoute}/>
<Route component={Apps} {...appsRoute}/>
<Redirect exact from="/" to={homeUrl}/>
<Route path="*" component={NotFound}/>
</Switch>
<Route component={Cluster} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/>
<Route component={Workloads} {...workloadsRoute}/>
<Route component={Config} {...configRoute}/>
<Route component={Network} {...networkRoute}/>
<Route component={Storage} {...storageRoute}/>
<Route component={Namespaces} {...namespacesRoute}/>
<Route component={Events} {...eventRoute}/>
<Route component={CustomResources} {...crdRoute}/>
<Route component={UserManagement} {...usersManagementRoute}/>
<Route component={Apps} {...appsRoute}/>
<Redirect exact from="/" to={this.startURL}/>
<Route component={NotFound}/>
</Switch>
<KubeObjectDetails/>
<Notifications/>
<ConfirmDialog/>
<KubeObjectDetails/>
<KubeConfigDialog/>
<AddRoleBindingDialog/>
<PodLogsDialog/>
@@ -82,6 +85,3 @@ export class App extends React.Component {
)
}
}
// run
App.init();
+1 -1
View File
@@ -5,8 +5,8 @@ import { cssNames } from "../../utils/cssNames";
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
interface Props extends React.HTMLAttributes<any>, TooltipDecoratorProps {
label: React.ReactNode;
small?: boolean;
label?: React.ReactNode;
}
@withTooltip
+123 -125
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useRef } from "react";
import React from "react";
import merge from "lodash/merge";
import moment from "moment";
import Color from "color";
import { observer } from "mobx-react";
import { ChartData, ChartOptions, ChartPoint, Scriptable } from "chart.js";
import { Chart, ChartKind, ChartProps } from "./chart";
import { bytesToUnits, cssNames } from "../../utils";
@@ -19,139 +20,136 @@ const defaultProps: Partial<Props> = {
plugins: [ZebraStripes]
};
BarChart.defaultProps = defaultProps;
@observer
export class BarChart extends React.Component<Props> {
static defaultProps = defaultProps as object;
export function BarChart(props: Props) {
const { name, data, className, timeLabelStep, plugins, options: customOptions, ...settings } = props;
const { textColorPrimary, borderFaintColor, chartStripesColor } = themeStore.activeTheme.colors;
render() {
const { name, data, className, timeLabelStep, plugins, options: customOptions, ...settings } = this.props;
const { textColorPrimary, borderFaintColor, chartStripesColor } = themeStore.activeTheme.colors;
const savedName = useRef<string>();
const getBarColor: Scriptable<string> = ({ dataset }) => {
const color = dataset.borderColor;
return Color(color).alpha(0.2).string();
}
useEffect(() => {
savedName.current = props.name;
});
const getBarColor: Scriptable<string> = ({ dataset }) => {
const color = dataset.borderColor;
return Color(color).alpha(0.2).string();
}
// Remove empty sets and insert default data
const chartData: ChartData = {
...data,
datasets: data.datasets
.filter(set => set.data.length)
.map(item => {
return {
type: ChartKind.BAR,
borderWidth: { top: 3 },
barPercentage: 1,
categoryPercentage: 1,
...item
}
})
};
const formatTimeLabels = (timestamp: string, index: number) => {
const label = moment(parseInt(timestamp)).format("HH:mm");
const offset = " ";
if (index == 0) return offset + label;
if (index == 60) return label + offset;
return index % timeLabelStep == 0 ? label : "";
};
const barOptions: ChartOptions = {
maintainAspectRatio: false,
responsive: true,
scales: {
xAxes: [{
type: "time",
offset: true,
gridLines: {
display: false,
},
stacked: true,
ticks: {
callback: formatTimeLabels,
autoSkip: false,
source: "data",
backdropColor: "white",
fontColor: textColorPrimary,
fontSize: 11,
maxRotation: 0,
minRotation: 0
},
bounds: "data",
time: {
unit: "minute",
displayFormats: {
minute: "x"
},
parser: timestamp => moment.unix(parseInt(timestamp))
}
}],
yAxes: [{
position: "right",
gridLines: {
color: borderFaintColor,
drawBorder: false,
tickMarkLength: 0,
zeroLineWidth: 0
},
ticks: {
maxTicksLimit: 6,
fontColor: textColorPrimary,
fontSize: 11,
padding: 8,
min: 0
}
}]
},
tooltips: {
mode: "index",
position: "cursor",
callbacks: {
title: tooltipItems => {
const now = new Date().getTime()
if (new Date(tooltipItems[0].xLabel).getTime() > now) return "";
return `${tooltipItems[0].xLabel}`
},
labelColor: ({ datasetIndex }) => {
// Remove empty sets and insert default data
const chartData: ChartData = {
...data,
datasets: data.datasets
.filter(set => set.data.length)
.map(item => {
return {
borderColor: "darkgray",
backgroundColor: chartData.datasets[datasetIndex].borderColor as string
type: ChartKind.BAR,
borderWidth: { top: 3 },
barPercentage: 1,
categoryPercentage: 1,
...item
}
})
};
const formatTimeLabels = (timestamp: string, index: number) => {
const label = moment(parseInt(timestamp)).format("HH:mm");
const offset = " ";
if (index == 0) return offset + label;
if (index == 60) return label + offset;
return index % timeLabelStep == 0 ? label : "";
};
const barOptions: ChartOptions = {
maintainAspectRatio: false,
responsive: true,
scales: {
xAxes: [{
type: "time",
offset: true,
gridLines: {
display: false,
},
stacked: true,
ticks: {
callback: formatTimeLabels,
autoSkip: false,
source: "data",
backdropColor: "white",
fontColor: textColorPrimary,
fontSize: 11,
maxRotation: 0,
minRotation: 0
},
bounds: "data",
time: {
unit: "minute",
displayFormats: {
minute: "x"
},
parser: timestamp => moment.unix(parseInt(timestamp))
}
}],
yAxes: [{
position: "right",
gridLines: {
color: borderFaintColor,
drawBorder: false,
tickMarkLength: 0,
zeroLineWidth: 0
},
ticks: {
maxTicksLimit: 6,
fontColor: textColorPrimary,
fontSize: 11,
padding: 8,
min: 0
}
}]
},
tooltips: {
mode: "index",
position: "cursor",
callbacks: {
title: tooltipItems => {
const now = new Date().getTime()
if (new Date(tooltipItems[0].xLabel).getTime() > now) return "";
return `${tooltipItems[0].xLabel}`
},
labelColor: ({ datasetIndex }) => {
return {
borderColor: "darkgray",
backgroundColor: chartData.datasets[datasetIndex].borderColor as string
}
}
}
},
animation: {
duration: 0
},
elements: {
rectangle: {
backgroundColor: getBarColor.bind(null)
}
},
plugins: {
ZebraStripes: {
stripeColor: chartStripesColor
}
}
},
animation: {
duration: 0
},
elements: {
rectangle: {
backgroundColor: getBarColor.bind(null)
}
},
plugins: {
ZebraStripes: {
stripeColor: chartStripesColor
}
};
const options = merge(barOptions, customOptions);
if (chartData.datasets.length == 0) {
return <NoMetrics/>
}
};
const options = merge(barOptions, customOptions);
if (chartData.datasets.length == 0) {
return <NoMetrics/>
return (
<Chart
className={cssNames("BarChart flex box grow column", className)}
type={ChartKind.BAR}
data={chartData}
options={options}
plugins={plugins}
{...settings}
/>
)
}
return (
<Chart
className={cssNames("BarChart flex box grow column", className)}
type={ChartKind.BAR}
data={chartData}
options={options}
plugins={plugins}
{...settings}
/>
)
}
// Default options for all charts containing memory units (network, disk, memory, etc)
@@ -1,5 +1,6 @@
import "./pie-chart.scss";
import React from "react";
import { observer } from "mobx-react";
import ChartJS, { ChartOptions } from "chart.js";
import { Chart, ChartProps } from "./chart";
import { cssNames } from "../../utils";
@@ -8,6 +9,7 @@ import { themeStore } from "../../theme.store";
interface Props extends ChartProps {
}
@observer
export class PieChart extends React.Component<Props> {
render() {
const { data, className, options, ...chartProps } = this.props
@@ -0,0 +1,38 @@
.ClusterIcon {
--size: 37px;
position: relative;
border-radius: $radius;
padding: $radius;
user-select: none;
cursor: pointer;
&.interactive {
img {
opacity: .55;
}
}
&.active, &.interactive:hover {
background-color: #fff;
img {
opacity: 1;
}
}
img {
width: var(--size);
height: var(--size);
}
.Badge {
position: absolute;
right: 0;
bottom: 0;
margin: -$padding * 1.5;
font-size: $font-size-small;
background: $colorError;
color: white;
}
}
@@ -0,0 +1,61 @@
import "./cluster-icon.scss"
import React, { DOMAttributes } from "react";
import { observer } from "mobx-react";
import { Params as HashiconParams } from "@emeraldpay/hashicon";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Cluster } from "../../../main/cluster";
import { cssNames, IClassName } from "../../utils";
import { Badge } from "../badge";
import { Tooltip } from "../tooltip";
interface Props extends DOMAttributes<HTMLElement> {
cluster: Cluster;
className?: IClassName;
errorClass?: IClassName;
showErrors?: boolean;
showTooltip?: boolean;
interactive?: boolean;
isActive?: boolean;
options?: HashiconParams;
}
const defaultProps: Partial<Props> = {
showErrors: true,
showTooltip: true,
};
@observer
export class ClusterIcon extends React.Component<Props> {
static defaultProps = defaultProps as object;
render() {
const {
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
children, ...elemProps
} = this.props;
const { isAdmin, eventCount, preferences, id: clusterId } = cluster;
const { clusterName, icon } = preferences;
const clusterIconId = `cluster-icon-${clusterId}`;
const className = cssNames("ClusterIcon flex inline", this.props.className, {
interactive: interactive !== undefined ? interactive : !!this.props.onClick,
active: isActive,
});
return (
<div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
{showTooltip && (
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
)}
{icon && <img src={icon} alt={clusterName}/>}
{!icon && <Hashicon value={clusterId} options={options}/>}
{showErrors && isAdmin && eventCount > 0 && (
<Badge
className={cssNames("events-count", errorClass)}
label={eventCount >= 1000 ? Math.ceil(eventCount / 1000) + "k+" : eventCount}
/>
)}
{children}
</div>
);
}
}
@@ -0,0 +1 @@
export * from "./cluster-icon"
@@ -0,0 +1,13 @@
.BottomBar {
$spacing: $padding / 2;
font-size: $font-size-small;
background-color: #3d90ce;
padding: $spacing $padding;
color: white;
#current-workspace {
--flex-gap: #{$spacing};
cursor: pointer;
}
}
@@ -0,0 +1,22 @@
import "./bottom-bar.scss"
import React from "react";
import { observer } from "mobx-react";
import { Icon } from "../icon";
import { WorkspaceMenu } from "../+workspaces/workspace-menu";
import { workspaceStore } from "../../../common/workspace-store";
@observer
export class BottomBar extends React.Component {
render() {
const { currentWorkspace } = workspaceStore;
return (
<div className="BottomBar flex gaps">
<div id="current-workspace" className="flex gaps align-center box right">
<Icon small material="layers"/>
<span className="workspace-name">{currentWorkspace.name}</span>
</div>
<WorkspaceMenu htmlFor="current-workspace"/>
</div>
)
}
}
@@ -0,0 +1,35 @@
.ClusterManager {
display: grid;
grid-template-areas: "menu main" "menu main" "bottom-bar bottom-bar";
grid-template-rows: auto 1fr min-content;
grid-template-columns: min-content 1fr;
height: 100%;
main {
grid-area: main;
position: relative;
display: flex;
> * {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: flex;
background-color: $mainBackground;
> * {
flex: 1;
}
}
}
.ClustersMenu {
grid-area: menu;
}
.BottomBar {
grid-area: bottom-bar;
}
}
@@ -0,0 +1,71 @@
import "./cluster-manager.scss"
import React from "react";
import { Redirect, Route, Switch } from "react-router";
import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { ClustersMenu } from "./clusters-menu";
import { BottomBar } from "./bottom-bar";
import { LandingPage, landingRoute, landingURL } from "../+landing-page";
import { Preferences, preferencesRoute } from "../+preferences";
import { Workspaces, workspacesRoute } from "../+workspaces";
import { AddCluster, addClusterRoute } from "../+add-cluster";
import { ClusterView } from "./cluster-view";
import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route";
import { clusterStore } from "../../../common/cluster-store";
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
@observer
export class ClusterManager extends React.Component {
componentDidMount() {
disposeOnUnmount(this, [
reaction(getMatchedClusterId, initView, {
fireImmediately: true
}),
reaction(() => [
hasLoadedView(getMatchedClusterId()), // refresh when cluster's webview loaded
getMatchedCluster()?.available, // refresh on disconnect active-cluster
], refreshViews, {
fireImmediately: true
})
])
}
componentWillUnmount() {
lensViews.clear();
}
get startUrl() {
const { activeClusterId } = clusterStore;
if (activeClusterId) {
return clusterViewURL({
params: {
clusterId: activeClusterId
}
})
}
return landingURL()
}
render() {
return (
<div className="ClusterManager">
<div id="draggable-top"/>
<main>
<div id="lens-views"/>
<Switch>
<Route component={LandingPage} {...landingRoute}/>
<Route component={Preferences} {...preferencesRoute}/>
<Route component={Workspaces} {...workspacesRoute}/>
<Route component={AddCluster} {...addClusterRoute}/>
<Route component={ClusterView} {...clusterViewRoute}/>
<Route component={ClusterSettings} {...clusterSettingsRoute}/>
<Redirect exact to={this.startUrl}/>
</Switch>
</main>
<ClustersMenu/>
<BottomBar/>
</div>
)
}
}
@@ -0,0 +1,21 @@
.ClusterStatus {
--flex-gap: #{$padding * 2};
position: relative;
min-width: 350px;
margin: auto;
text-align: center;
z-index: 1;
pre {
@include hidden-scrollbar;
max-width: 70vw;
max-height: 40vh;
white-space: pre-line;
}
.Icon {
--size: 70px;
margin: auto;
}
}
@@ -0,0 +1,108 @@
import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy";
import "./cluster-status.scss"
import React from "react";
import { observer } from "mobx-react";
import { ipcRenderer } from "electron";
import { computed, observable } from "mobx";
import { clusterIpc } from "../../../common/cluster-ipc";
import { Icon } from "../icon";
import { Button } from "../button";
import { cssNames, IClassName } from "../../utils";
import { Cluster } from "../../../main/cluster";
import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { CubeSpinner } from "../spinner";
interface Props {
className?: IClassName;
clusterId: ClusterId;
}
@observer
export class ClusterStatus extends React.Component<Props> {
@observable authOutput: KubeAuthProxyLog[] = [];
@observable isReconnecting = false;
get cluster(): Cluster {
return clusterStore.getById(this.props.clusterId);
}
@computed get hasErrors(): boolean {
return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason;
}
async componentDidMount() {
ipcRenderer.on(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => {
this.authOutput.push({
data: res.data.trimRight(),
error: res.error,
});
})
if (!this.cluster.initialized || this.cluster.disconnected) {
await this.refreshCluster();
}
}
componentWillUnmount() {
ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`);
}
refreshCluster = async () => {
await clusterIpc.activate.invokeFromRenderer(this.props.clusterId);
}
reconnect = async () => {
this.isReconnecting = true;
await this.refreshCluster();
this.isReconnecting = false;
}
renderContent() {
const { authOutput, cluster, hasErrors } = this;
const failureReason = cluster.failureReason;
if (!hasErrors || this.isReconnecting) {
return (
<>
<CubeSpinner />
<pre className="kube-auth-out">
<p>{this.isReconnecting ? "Reconnecting..." : "Connecting..."}</p>
{authOutput.map(({ data, error }, index) => {
return <p key={index} className={cssNames({ error })}>{data}</p>
})}
</pre>
</>
);
}
return (
<>
<Icon material="cloud_off" className="error" />
<h2>
{cluster.preferences.clusterName}
</h2>
<pre className="kube-auth-out">
{authOutput.map(({ data, error }, index) => {
return <p key={index} className={cssNames({ error })}>{data}</p>
})}
</pre>
{failureReason && (
<div className="failure-reason error">{failureReason}</div>
)}
<Button
primary
label="Reconnect"
className="box center"
onClick={this.reconnect}
waiting={this.isReconnecting}
/>
</>
);
}
render() {
return (
<div className={cssNames("ClusterStatus flex column gaps box center align-center justify-center", this.props.className)}>
{this.renderContent()}
</div>
);
}
}
@@ -0,0 +1,46 @@
import { reaction } from "mobx";
import { ipcRenderer } from "electron";
import { matchPath, RouteProps } from "react-router";
import { buildURL, navigation } from "../../navigation";
import { clusterStore, getHostedClusterId } from "../../../common/cluster-store";
import { clusterSettingsRoute } from "../+cluster-settings/cluster-settings.route";
export interface IClusterViewRouteParams {
clusterId: string;
}
export const clusterViewRoute: RouteProps = {
exact: true,
path: "/cluster/:clusterId",
}
export const clusterViewURL = buildURL<IClusterViewRouteParams>(clusterViewRoute.path)
export function getMatchedClusterId(): string {
const matched = matchPath<IClusterViewRouteParams>(navigation.location.pathname, {
exact: true,
path: [
clusterViewRoute.path,
clusterSettingsRoute.path,
].flat(),
})
if (matched) {
return matched.params.clusterId;
}
}
export function getMatchedCluster() {
return clusterStore.getById(getMatchedClusterId())
}
// Refresh global menu depending on active route's type (common/cluster view)
if (ipcRenderer) {
const isMainView = !getHostedClusterId();
if (isMainView) {
reaction(() => getMatchedClusterId(), clusterId => {
ipcRenderer.send("cluster-view:change", clusterId);
}, {
fireImmediately: true
})
}
}
@@ -0,0 +1,5 @@
.ClusterView {
&:empty {
display: none;
}
}
@@ -0,0 +1,21 @@
import "./cluster-view.scss"
import React from "react";
import { observer } from "mobx-react";
import { getMatchedCluster } from "./cluster-view.route";
import { ClusterStatus } from "./cluster-status";
import { hasLoadedView } from "./lens-views";
@observer
export class ClusterView extends React.Component {
render() {
const cluster = getMatchedCluster();
const showStatus = cluster && (!cluster.available || !hasLoadedView(cluster.id))
return (
<div className="ClusterView">
{showStatus && (
<ClusterStatus key={cluster.id} clusterId={cluster.id} className="box center"/>
)}
</div>
)
}
}
@@ -0,0 +1,86 @@
.ClustersMenu {
@include hidden-scrollbar;
$spacing: $padding * 2;
position: relative;
text-align: center;
padding: $spacing;
background: var(--clusters-menu-bgc);
.is-mac & {
padding-top: $spacing * 2;
}
> .startup-tooltip {
$bgc: $mainBackground;
$arrowSize: 10px;
position: absolute;
top: 20px;
left: 100%;
margin: $padding;
padding: $spacing;
width: 320px;
background: $bgc;
z-index: 100;
color: white;
filter: drop-shadow(0 0px 2px #ffffff33);
pointer-events: none;
&:before {
content: "";
position: absolute;
width: 0;
height: 0;
border-top: $arrowSize solid transparent;
border-bottom: $arrowSize solid transparent;
border-right: $arrowSize solid $bgc;
right: 100%;
}
}
> .add-cluster {
position: relative;
margin-top: $padding;
min-width: 43px;
.Icon {
border-radius: $radius;
padding: $padding / 3;
color: var(--clusters-menu-bgc) !important;
background: white !important;
font-weight: bold;
cursor: pointer;
opacity: 0.4;
&.active {
opacity: 1;
}
&:hover {
box-shadow: none;
opacity: .75;
}
}
.Badge {
$boxSize: 17px;
$offset: -7px;
position: absolute;
font-size: $font-size-small;
right: $offset;
bottom: $offset;
line-height: $boxSize;
min-width: $boxSize;
min-height: $boxSize;
text-align: center;
color: white;
background: $colorSuccess;
font-weight: normal;
border-radius: $radius;
padding: 0;
pointer-events: none;
}
}
}
@@ -0,0 +1,137 @@
import "./clusters-menu.scss"
import { remote } from "electron"
import React from "react";
import { observer } from "mobx-react";
import { observable } from "mobx";
import { _i18n } from "../../i18n";
import { t, Trans } from "@lingui/macro";
import type { Cluster } from "../../../main/cluster";
import { userStore } from "../../../common/user-store";
import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon";
import { cssNames, IClassName } from "../../utils";
import { Badge } from "../badge";
import { navigate, navigation } from "../../navigation";
import { addClusterURL } from "../+add-cluster";
import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page";
import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route";
// fixme: allow to rearrange clusters with drag&drop
interface Props {
className?: IClassName;
}
@observer
export class ClustersMenu extends React.Component<Props> {
@observable showHint = true;
showCluster = (clusterId: ClusterId) => {
clusterStore.setActive(clusterId);
navigate(clusterViewURL({ params: { clusterId } }));
}
addCluster = () => {
navigate(addClusterURL());
}
showContextMenu = (cluster: Cluster) => {
const { Menu, MenuItem } = remote
const menu = new Menu();
menu.append(new MenuItem({
label: _i18n._(t`Settings`),
click: () => {
navigate(clusterSettingsURL({
params: {
clusterId: cluster.id
}
}))
}
}));
if (cluster.online) {
menu.append(new MenuItem({
label: _i18n._(t`Disconnect`),
click: async () => {
if (clusterStore.isActive(cluster.id)) {
navigate(landingURL());
}
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
}
}))
}
menu.append(new MenuItem({
label: _i18n._(t`Remove`),
click: () => {
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
label: _i18n._(t`Remove`),
},
ok: () => {
clusterStore.removeById(cluster.id);
navigate(landingURL());
},
message: <p>Are you sure want to remove cluster <b title={cluster.id}>{cluster.contextName}</b>?</p>,
})
}
}));
menu.popup({
window: remote.getCurrentWindow()
})
}
render() {
const { className } = this.props;
const { newContexts } = userStore;
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
const noClustersInScope = clusters.length === 0;
const isLanding = navigation.getPath() === landingURL();
const showStartupHint = this.showHint && isLanding && noClustersInScope;
return (
<div
className={cssNames("ClustersMenu flex column gaps", className)}
onMouseEnter={() => this.showHint = false}
>
{showStartupHint && (
<div className="startup-tooltip flex column gaps">
<p><Trans>This is the quick launch menu.</Trans></p>
<p>
<Trans>
Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button.
</Trans>
</p>
</div>
)}
{clusters.map(cluster => {
return (
<ClusterIcon
key={cluster.id}
showErrors={true}
cluster={cluster}
isActive={cluster.id === getMatchedClusterId()}
onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)}
/>
)
})}
<div className="add-cluster" onClick={this.addCluster}>
<Tooltip targetId="add-cluster-icon">
<Trans>Add Cluster</Trans>
</Tooltip>
<Icon big material="add" id="add-cluster-icon"/>
{newContexts.size > 0 && (
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>}/>
)}
</div>
</div>
);
}
}
@@ -0,0 +1 @@
export * from "./cluster-manager"
@@ -0,0 +1,43 @@
import { observable } from "mobx";
import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { getMatchedCluster } from "./cluster-view.route"
import logger from "../../../main/logger";
export interface LensView {
isLoaded?: boolean
clusterId: ClusterId;
view: HTMLIFrameElement
}
export const lensViews = observable.map<ClusterId, LensView>();
export function hasLoadedView(clusterId: ClusterId): boolean {
return !!lensViews.get(clusterId)?.isLoaded;
}
export async function initView(clusterId: ClusterId) {
if (!clusterId || lensViews.has(clusterId)) {
return;
}
logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`)
const cluster = clusterStore.getById(clusterId);
await cluster.whenInitialized;
const parentElem = document.getElementById("lens-views");
const iframe = document.createElement("iframe");
iframe.name = cluster.preferences.clusterName;
iframe.setAttribute("src", `//${clusterId}.${location.host}`)
iframe.addEventListener("load", async () => {
logger.info(`[LENS-VIEW]: loaded from ${iframe.src}`)
lensViews.get(clusterId).isLoaded = true;
})
lensViews.set(clusterId, { clusterId, view: iframe });
parentElem.appendChild(iframe);
}
export function refreshViews() {
const cluster = getMatchedCluster();
lensViews.forEach(({ clusterId, view, isLoaded }) => {
const isVisible = cluster && cluster.available && cluster.id === clusterId;
view.style.display = isLoaded && isVisible ? "flex" : "none"
})
}
@@ -5,7 +5,7 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { cssNames, noop, prevDefault } from "../../utils";
import { Button } from "../button";
import { Button, ButtonProps } from "../button";
import { Dialog, DialogProps } from "../dialog";
import { Icon } from "../icon";
@@ -14,7 +14,9 @@ export interface IConfirmDialogParams {
labelOk?: ReactNode;
labelCancel?: ReactNode;
message?: ReactNode;
icon?: string;
icon?: ReactNode;
okButtonProps?: Partial<ButtonProps>
cancelButtonProps?: Partial<ButtonProps>
}
interface Props extends Partial<DialogProps> {
@@ -40,7 +42,7 @@ export class ConfirmDialog extends React.Component<Props> {
ok: noop,
labelOk: <Trans>Ok</Trans>,
labelCancel: <Trans>Cancel</Trans>,
icon: "warning",
icon: <Icon big material="warning"/>,
};
get params(): IConfirmDialogParams {
@@ -67,18 +69,21 @@ export class ConfirmDialog extends React.Component<Props> {
render() {
const { className, ...dialogProps } = this.props;
const { icon, labelOk, labelCancel, message } = this.params;
const {
icon, labelOk, labelCancel, message,
okButtonProps = {},
cancelButtonProps = {},
} = this.params;
return (
<Dialog
{...dialogProps}
className={cssNames("ConfirmDialog", className, icon)}
className={cssNames("ConfirmDialog", className)}
isOpen={ConfirmDialog.isOpen}
onClose={this.onClose}
close={this.close}
>
<div className="confirm-content">
<Icon big material={icon}/>
{message}
{icon} {message}
</div>
<div className="confirm-buttons">
<Button
@@ -86,6 +91,7 @@ export class ConfirmDialog extends React.Component<Props> {
className="cancel"
label={labelCancel}
onClick={prevDefault(this.close)}
{...cancelButtonProps}
/>
<Button
autoFocus primary
@@ -93,6 +99,7 @@ export class ConfirmDialog extends React.Component<Props> {
label={labelOk}
onClick={prevDefault(this.ok)}
waiting={this.isSaving}
{...okButtonProps}
/>
</div>
</Dialog>
+2 -1
View File
@@ -8,6 +8,8 @@ import { Animate } from "../animate";
import { cssNames, noop, stopPropagation } from "../../utils";
import { navigation } from "../../navigation";
// todo: refactor + handle animation-end in props.onClose()?
export interface DialogProps {
className?: string;
isOpen?: boolean;
@@ -24,7 +26,6 @@ interface DialogState {
isOpen: boolean;
}
// fixme: handle animation end props.onClose() (await props.close()?)
@observer
export class Dialog extends React.PureComponent<DialogProps, DialogState> {
private contentElem: HTMLElement;
@@ -10,6 +10,8 @@ import { Button } from "../button";
import { Icon } from "../icon";
import { _i18n } from "../../i18n";
// todo: make as external BrowserWindow (?)
interface Props extends DialogProps {
title: string;
logs: string;
@@ -41,7 +43,7 @@ export class LogsDialog extends React.Component<Props> {
<Dialog {...dialogProps} className="LogsDialog">
<Wizard header={header} done={dialogProps.close}>
<WizardStep scrollable={false} customButtons={customButtons}>
<code ref={e => this.logsElem = e}>
<code className="block" ref={e => this.logsElem = e}>
{logs || <Trans>There are no logs available.</Trans>}
</code>
</WizardStep>
@@ -6,7 +6,6 @@ import { TerminalApi } from "../../api/terminal-api";
import { dockStore, IDockTab, TabId, TabKind } from "./dock.store";
import { WebSocketApiState } from "../../api/websocket-api";
import { _i18n } from "../../i18n";
import { themeStore } from "../../theme.store";
export interface ITerminalTab extends IDockTab {
node?: string; // activate node shell mode
@@ -16,7 +15,6 @@ export function isTerminalTab(tab: IDockTab) {
return tab && tab.kind === TabKind.TERMINAL;
}
export function createTerminalTab(tabParams: Partial<ITerminalTab> = {}) {
return dockStore.createTab({
kind: TabKind.TERMINAL,
@@ -56,7 +54,6 @@ export class TerminalStore {
const api = new TerminalApi({
id: tabId,
node: tab.node,
colorTheme: themeStore.activeTheme.type
});
const terminal = new Terminal(tabId, api);
this.connections.set(tabId, api);
+9 -10
View File
@@ -1,5 +1,5 @@
import debounce from "lodash/debounce";
import { autorun } from "mobx";
import { reaction, toJS } from "mobx";
import { Terminal as XTerm } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { dockStore, TabId } from "./dock.store";
@@ -20,7 +20,7 @@ export class Terminal {
Terminal.spawningPool = pool;
}
static async preloadFonts(){
static async preloadFonts() {
const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires
const fontFace = new FontFace("RobotoMono", `url(${fontPath})`);
await fontFace.load();
@@ -33,7 +33,7 @@ export class Terminal {
public disposers: Function[] = [];
@autobind()
protected setTheme(colors = themeStore.activeTheme.colors) {
protected setTheme(colors: Record<string, string>) {
// Replacing keys stored in styles to format accepted by terminal
// E.g. terminalBrightBlack -> brightBlack
const colorPrefix = "terminal"
@@ -94,19 +94,18 @@ export class Terminal {
this.xterm.attachCustomKeyEventHandler(this.keyHandler);
// bind events
const onResizeDisposer = dockStore.onResize(this.onResize);
const onData = this.xterm.onData(this.onData);
const onThemeChangeDisposer = autorun(() => this.setTheme(themeStore.activeTheme.colors));
const onDataHandler = this.xterm.onData(this.onData);
this.viewport.addEventListener("scroll", this.onScroll);
this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..)
this.api.onData.addListener(this.onApiData);
window.addEventListener("resize", this.onResize);
// add clean-up handlers to be called on destroy
this.disposers.push(
onResizeDisposer,
onThemeChangeDisposer,
() => onData.dispose(),
reaction(() => toJS(themeStore.activeTheme.colors), this.setTheme, {
fireImmediately: true
}),
dockStore.onResize(this.onResize),
() => onDataHandler.dispose(),
() => this.fitAddon.dispose(),
() => this.api.removeAllListeners(),
() => window.removeEventListener("resize", this.onResize),
+4 -4
View File
@@ -5,7 +5,7 @@ import { createPortal } from "react-dom";
import { cssNames, noop } from "../../utils";
import { Icon } from "../icon";
import { Animate, AnimateName } from "../animate";
import { browserHistory } from "../../navigation";
import { history } from "../../navigation";
import { themeStore } from "../../theme.store";
export interface DrawerProps {
@@ -36,7 +36,7 @@ export class Drawer extends React.Component<DrawerProps> {
private scrollElem: HTMLElement
private scrollPos = new Map<string, number>();
private stopListenLocation = browserHistory.listen(() => {
private stopListenLocation = history.listen(() => {
this.restoreScrollPos();
});
@@ -55,13 +55,13 @@ export class Drawer extends React.Component<DrawerProps> {
saveScrollPos = () => {
if (!this.scrollElem) return;
const key = browserHistory.location.key;
const key = history.location.key;
this.scrollPos.set(key, this.scrollElem.scrollTop);
}
restoreScrollPos = () => {
if (!this.scrollElem) return;
const key = browserHistory.location.key;
const key = history.location.key;
this.scrollElem.scrollTop = this.scrollPos.get(key) || 0;
}
@@ -7,7 +7,7 @@ import { t, Trans } from "@lingui/macro";
import { Button } from "../button";
import { navigation } from "../../navigation";
import { _i18n } from "../../i18n";
import { issuesTrackerUrl, slackUrl, buildVersion } from "../../../common/vars";
import { issuesTrackerUrl, slackUrl } from "../../../common/vars";
interface Props {
}
@@ -45,7 +45,6 @@ export class ErrorBoundary extends React.Component<Props, State> {
<div className="ErrorBoundary flex column gaps">
<h5>
<Trans>App crash at <span className="contrast">{pageUrl}</span></Trans>
{buildVersion && <p><Trans>Build version</Trans>: {buildVersion}</p>}
</h5>
<p>
<Trans>
@@ -53,7 +52,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
</Trans>
</p>
<div className="flex gaps">
<code>
<code className="block">
<p className="contrast"><Trans>Component stack</Trans>:</p>
{errorInfo.componentStack}
</code>
@@ -0,0 +1,11 @@
.FilePicker {
input[type="file"] {
display: none;
}
label {
display: inline-flex;
cursor: pointer;
color: var(--blue);
}
}
@@ -0,0 +1,203 @@
import "./file-picker.scss"
import React from "react";
import fse from "fs-extra";
import path from "path";
import { Icon } from "../icon";
import { Spinner } from "../spinner";
import { observable } from "mobx";
import { observer } from "mobx-react";
import _ from "lodash";
export interface FileUploadProps {
uploadDir: string;
rename?: boolean;
handler?(path: string[]): void;
}
export interface MemoryUseProps {
handler?(file: File[]): void;
}
enum FileInputStatus {
CLEAR = "clear",
PROCESSING = "processing",
ERROR = "error",
}
export enum OverLimitStyle {
REJECT = "reject",
CAP = "cap",
}
export enum OverSizeLimitStyle {
REJECT = "reject",
FILTER = "filter",
}
export enum OverTotalSizeLimitStyle {
REJECT = "reject",
FILTER_LAST = "filter-last",
FILTER_LARGEST = "filter-largest",
}
export interface BaseProps {
accept?: string;
label: React.ReactNode;
multiple?: boolean;
// limit is the optional maximum number of files to upload
// the larger number is upper limit, the lower is lower limit
// the lower limit is capped at 0 and the upper limit is capped at Infinity
limit?: [number, number];
// default is "Reject"
onOverLimit?: OverLimitStyle;
// individual files are checked before the total size.
maxSize?: number;
// default is "Reject"
onOverSizeLimit?: OverSizeLimitStyle;
maxTotalSize?: number;
// default is "Reject"
onOverTotalSizeLimit?: OverTotalSizeLimitStyle;
}
export type Props = BaseProps & (MemoryUseProps | FileUploadProps);
const defaultProps: Partial<Props> = {
maxSize: Infinity,
onOverSizeLimit: OverSizeLimitStyle.REJECT,
maxTotalSize: Infinity,
onOverLimit: OverLimitStyle.REJECT,
onOverTotalSizeLimit: OverTotalSizeLimitStyle.REJECT,
};
@observer
export class FilePicker extends React.Component<Props> {
static defaultProps = defaultProps as Object;
@observable status = FileInputStatus.CLEAR;
@observable errorText?: string;
handleFileCount(files: File[]): File[] {
const { limit: [minLimit, maxLimit] = [0, Infinity], onOverLimit } = this.props;
if (files.length > maxLimit) {
switch (onOverLimit) {
case OverLimitStyle.CAP:
files.length = maxLimit;
break;
case OverLimitStyle.REJECT:
throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`;
}
}
if (files.length < minLimit) {
throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`;
}
return files;
}
handleIndiviualFileSizes(files: File[]): File[] {
const { onOverSizeLimit, maxSize } = this.props;
switch (onOverSizeLimit) {
case OverSizeLimitStyle.FILTER:
return files.filter(file => file.size <= maxSize );
case OverSizeLimitStyle.REJECT:
const firstFileToLarge = files.find(file => file.size > maxSize);
if (firstFileToLarge) {
throw `${firstFileToLarge.name} is too large. Maximum size is ${maxSize}. Has size of ${firstFileToLarge.size}`;
}
return files;
}
}
handleTotalFileSizes(files: File[]): File[] {
const { maxTotalSize, onOverTotalSizeLimit } = this.props;
const totalSize = _.sum(files.map(f => f.size));
if (totalSize <= maxTotalSize) {
return files;
}
switch (onOverTotalSizeLimit) {
case OverTotalSizeLimitStyle.FILTER_LARGEST:
files = _.orderBy(files, ["size"])
case OverTotalSizeLimitStyle.FILTER_LAST:
let newTotalSize = totalSize;
for (;files.length > 0;) {
newTotalSize -= files.pop().size;
if (newTotalSize <= maxTotalSize) {
break;
}
}
return files;
case OverTotalSizeLimitStyle.REJECT:
throw `Total file size to upload is too large. Expected at most ${maxTotalSize}. Found ${totalSize}.`;
}
}
async handlePickFiles(selectedFiles: FileList) {
const files: File[] = Array.from(selectedFiles);
try {
const numberLimitedFiles = this.handleFileCount(files);
const sizeLimitedFiles = this.handleIndiviualFileSizes(numberLimitedFiles);
const totalSizeLimitedFiles = this.handleTotalFileSizes(sizeLimitedFiles);
if ("uploadDir" in this.props) {
const { uploadDir } = this.props;
this.status = FileInputStatus.PROCESSING;
const paths: string[] = [];
const promises = totalSizeLimitedFiles.map(async file => {
const destinationPath = path.join(uploadDir, file.name);
paths.push(destinationPath);
return fse.copyFile(file.path, destinationPath);
});
await Promise.all(promises);
this.props.handler(paths);
this.status = FileInputStatus.CLEAR;
} else {
this.props.handler(totalSizeLimitedFiles);
}
} catch (errorText) {
this.status = FileInputStatus.ERROR;
this.errorText = errorText;
return;
}
}
render() {
const { accept, label, multiple } = this.props;
return <div className="FilePicker">
<label className="flex gaps align-center" htmlFor="file-upload">{label} {this.getIconRight()}</label>
<input
id="file-upload"
name="FilePicker"
type="file"
accept={accept}
multiple={multiple}
onChange={(event) => this.handlePickFiles(event.target.files)}
/>
</div>;
}
getIconRight(): React.ReactNode {
switch (this.status) {
case FileInputStatus.CLEAR:
return <Icon className="clean" material="cloud_upload"></Icon>
case FileInputStatus.PROCESSING:
return <Spinner />;
case FileInputStatus.ERROR:
return <Icon material="error" title={this.errorText}></Icon>
}
}
}
@@ -0,0 +1 @@
export * from "./file-picker"
+182
View File
@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1303.8 737.4" style="enable-background:new 0 0 1303.8 737.4;" xml:space="preserve">
<style type="text/css">
.st0{clip-path:url(#SVGID_2_);}
.st1{fill:#393C40;}
.st2{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.54,9.54;}
.st3{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10.35,10.35;}
.st4{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.06,9.06;}
.st5{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:12.67,12.67;}
.st6{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:12.81,12.81;}
.st7{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10.4,10.4;}
.st8{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.25,9.25;}
.st9{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.55,9.55;}
.st10{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.66,9.66;}
.st11{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10.37,10.37;}
.st12{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10,10;}
.st13{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:11.37,11.37;}
.st14{clip-path:url(#SVGID_4_);}
.st15{fill:#2E3136;}
.st16{clip-path:url(#SVGID_6_);}
.st17{clip-path:url(#SVGID_8_);}
</style>
<title>Artboard 1</title>
<g>
<defs>
<rect id="SVGID_1_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_2_">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<g class="st0">
<path class="st1" d="M715.5,222.6c0.2-6.2-4-12.2-9.6-15V190c-2.1,1.9-4.9,3-7.8,3c-1.8,0-3.6-0.5-5.2-1.4v15.9
c-5.6,2.6-9.3,8.2-9.6,14.4c-0.1,2.7,0.6,5.5,1.8,7.9c-0.1-0.8-0.2-1.7-0.2-2.5c0.2-7.8,7.1-14.5,14.7-14.2s14.4,7.4,14.2,14.9
c0,0.4,0,0.8-0.1,1.2C714.8,227.2,715.4,225,715.5,222.6"/>
</g>
</g>
<line class="st2" x1="870.3" y1="376.4" x2="870.3" y2="519.5"/>
<line class="st3" x1="936.8" y1="407.1" x2="936.8" y2="562.4"/>
<line class="st4" x1="1034.7" y1="581.8" x2="1034.7" y2="627.1"/>
<line class="st5" x1="967.1" y1="547" x2="967.1" y2="585"/>
<line class="st6" x1="1104.2" y1="542.4" x2="1104.2" y2="580.8"/>
<line class="st7" x1="1036.7" y1="279.6" x2="1036.7" y2="435.6"/>
<line class="st8" x1="1106.2" y1="240.8" x2="1106.2" y2="361"/>
<line class="st9" x1="1204" y1="436.5" x2="1204" y2="522.4"/>
<line class="st10" x1="1134.5" y1="475.5" x2="1134.5" y2="562.4"/>
<line class="st11" x1="1064.9" y1="436.5" x2="1064.9" y2="467.6"/>
<line class="st12" x1="1006.4" y1="370.6" x2="1006.4" y2="460.6"/>
<line class="st13" x1="965.1" y1="240.8" x2="965.1" y2="297.6"/>
<g>
<defs>
<rect id="SVGID_3_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_4_">
<use xlink:href="#SVGID_3_" style="overflow:visible;"/>
</clipPath>
<g class="st14">
<path class="st15" d="M773.7,590.9c-8.2-4.8-8.2-12.5,0-17.3L823,545c8.2-4.8,21.7-4.8,30,0l49.4,28.6c8.2,4.8,8.2,12.5,0,17.3
L853,619.5c-8.2,4.8-21.7,4.8-30,0L773.7,590.9z"/>
<path class="st15" d="M921.8,676.7c8.2,4.8,21.7,4.8,30,0l49.4-28.6c8.2-4.8,8.2-12.5,0-17.3l-49.4-28.6c-8.2-4.8-21.7-4.8-30,0
l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3L921.8,676.7z"/>
<path class="st15" d="M1020.7,733.8c8.2,4.8,21.7,4.8,30,0l49.3-28.6c8.2-4.8,8.2-12.5,0-17.3l-49.4-28.6c-8.2-4.8-21.7-4.8-30,0
l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3L1020.7,733.8z"/>
<path class="st15" d="M1119.5,676.7c8.2,4.8,21.7,4.8,30,0l49.4-28.6c8.2-4.8,8.2-12.5,0-17.3l-49.4-28.6c-8.2-4.8-21.7-4.8-30,0
l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3L1119.5,676.7z"/>
<path class="st15" d="M1248.3,545.1c-8.2-4.8-21.7-4.8-30,0l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3l49.4,28.6c8.2,4.8,21.7,4.8,30,0
l49.4-28.5c8.2-4.8,8.2-12.5,0-17.3L1248.3,545.1z"/>
<path class="st15" d="M942.8,369.3c-3.5,2.3-5.7,6-5.9,10.2V391c0,3.7-2.7,5.2-6,3.4l-51.6-29.2c-3.5-2.2-5.7-6-6-10.1v-22.7
l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.6,27.3c3.7,1.8,8.1,1.8,11.9,0l49.1-28.3c3.3-1.9,6.7-3.1,7.7-2.8c1.1,0.5,1.8,1.6,1.8,2.8
c0,1.2-2.6,3.7-5.9,5.6L942.8,369.3z M942.8,292.3c-3.7-1.9-8.1-1.9-11.8,0l-58.7,33.9c-3.5,2.2-5.7,6-5.9,10.1v22.9
c0.2,4.1,2.5,7.9,6,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,6-10.1v-22.8c-0.2-4.1-2.4-7.9-5.9-10.1
L942.8,292.3z"/>
<path class="st15" d="M1140.4,437.4c-3.5,2.3-5.7,6-5.9,10.2v11.6c0,3.7-2.7,5.2-5.9,3.4l-51.6-29.2c-3.5-2.2-5.7-6-5.9-10.1
v-22.8l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.5,27.3c3.7,1.9,8.1,1.9,11.9,0l49.2-28.3c3.3-1.9,6.7-3.1,7.7-2.8
c1.1,0.5,1.8,1.6,1.8,2.8c0,1.2-2.6,3.7-5.9,5.6L1140.4,437.4z M1140.4,360.4c-3.7-1.9-8.1-1.9-11.8,0l-58.7,33.9
c-3.5,2.2-5.7,6-5.9,10.1v22.9c0.2,4.1,2.5,7.9,5.9,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,5.9-10.1
v-22.9c-0.2-4.1-2.4-7.9-5.9-10.1L1140.4,360.4z"/>
<path class="st15" d="M1041.6,241c-3.5,2.3-5.7,6-5.9,10.2v11.6c0,3.7-2.7,5.2-5.9,3.4L978.1,237c-3.5-2.2-5.7-6-6-10.1v-22.8
l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.5,27.3c3.7,1.9,8.1,1.9,11.9,0l49.2-28.3c3.3-1.9,6.7-3.1,7.7-2.8c1.1,0.5,1.8,1.6,1.8,2.8
c0,1.2-2.6,3.7-5.9,5.6L1041.6,241z M1041.6,164c-3.7-1.9-8.1-1.9-11.8,0L971,197.9c-3.5,2.2-5.7,6-5.9,10.1v22.8
c0.2,4.1,2.5,7.9,6,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,5.9-10.1v-22.8c-0.2-4.1-2.4-7.9-5.9-10.1
L1041.6,164z"/>
<path class="st15" d="M1041.6,538.7c-3.5,2.3-5.7,6-5.9,10.2v11.6c0,3.7-2.7,5.2-5.9,3.4l-51.6-29.2c-3.5-2.2-5.7-6-6-10.1v-22.8
l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.5,27.3c3.7,1.8,8.1,1.8,11.9,0l49.2-28.3c3.3-1.9,6.7-3.1,7.7-2.8c1.1,0.5,1.8,1.6,1.8,2.8
c0,1.2-2.6,3.7-5.9,5.6L1041.6,538.7z M1041.6,461.7c-3.7-1.9-8.1-1.9-11.8,0L971,495.6c-3.5,2.2-5.7,6-5.9,10.1v22.9
c0.2,4.1,2.5,7.9,6,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,5.9-10.1v-22.9c-0.2-4.1-2.4-7.9-5.9-10.1
L1041.6,461.7z"/>
<path class="st1" d="M582.5,50.3l-0.1-0.1L582.5,50.3 M396.4,166.1c-6.8,6.8-6.1,10.1,2.8,13.7c10.5,4.3,17.5,12,21.7,22.5
c2.9,7.2,6.9,8.1,12.4,2.6c16-15.9,26.1-26,42.2-41.8c3.9-4,8.2-9.3,8.9-16c1.4-10.3,5-17,9-21c19.1-19,27.1-27.7,40.5-41.3
c1.6-1.7,2.5-4.1,2.4-6.5c-1.6-10.5,1.7-18.2,10.5-23.3c8.2-4.7,16.9-4,24.6,2.1c0.3,0.2,1.7,1.7,1.8,1.9c0,0,0.2,0.1,4.4-4.1
l4.8-4.8c-3.1-3.1-22.3-22.2-24.7-24.4c-4.9-4.7-3.9-4.7-4.9-3.7L527,47.2c0,0-3.8,3.8-9.6-1.9C517.8,45,436.3,126.2,396.4,166.1"
/>
<path class="st1" d="M180.6,384c0,0.3,0,0.6,0,0.9C180.6,384.5,180.6,384.2,180.6,384 M362.3,256.2c-10.5-4.3-17.5-12-21.7-22.5
c-2.9-7.2-6.9-8.1-12.4-2.6c-16,15.9-31.8,32-47.9,47.8c-3.2,3.2-4.9,6.3-4,10.7c1.8,8.8-1.6,15.1-8.1,21.2
c-12,11.2-22.9,22.5-34.4,34.3c11.9,4,22.6,15.8,27,29l104.2-104.2C372,263,371.2,259.8,362.3,256.2"/>
<path class="st1" d="M616.5,60.2c-0.2,0.2-0.3,0.4-0.3,0.6C616.2,60.6,616.3,60.4,616.5,60.2 M616.2,60.9c0,0,0,0.1,0,0.1
C616.1,61,616.1,60.9,616.2,60.9 M616.5,61.6c0.1,0.1,0.1,0.1,0.2,0.2C616.6,61.7,616.5,61.7,616.5,61.6 M626.9,61.2
c-0.1-3.3-2.6-6-5.9-6.2c-3.3,0-6,2.6-6.1,5.9c-0.1,3.3,2.5,6,5.8,6.2C624,67,626.7,64.5,626.9,61.2"/>
<path class="st1" d="M575.9,72.3c-0.1-10.5-8.7-18.9-19.2-18.8s-18.9,8.7-18.8,19.2c0,10.5,9,19.4,19.2,19.2
C567.8,91.7,576.1,82.9,575.9,72.3"/>
<path class="st1" d="M188.9,482h-53c-12,0-12-12-12-12v-71c0,82,65,82.8,65,82.8C200.9,482,188.9,482,188.9,482 M223.2,483v-46
c0-6-6-6.3-6-6.3c-11.7-0.9-22.6-6.1-30.7-14.5c-10-10.7-11.2-24.1-10.8-32.4c0.3-6-5.7-6-5.7-6h-47c-6,0-6,6-6,6V483c0,6,6,6,6,6
h94.2C223.2,489,223.2,483,223.2,483"/>
<path class="st1" d="M2.2,363.7c0,0-0.6-1.2-0.8-1.1l-1,0.4c0,0-0.3,0.1-0.3-5.9v-24.4c0,0,0-6,2.4-6s11.8-0.4,16.2-0.4
c11,0,13.1,2.1,13.1,13.2c0,12.6,0,25.2,0,32.2c0,0,0,6,3.8,5.7s4.8,3.7,4.8,9.5c-0.2,27.7-0.2,55.4,0,83.1c0,6.2,0.2,12-4.2,11.8
c0,0-4.4-0.1-4.4,5.9c0,7.9,0.1,21.2,0,34.5c0,6.5-2.9,10.1-8.9,10.4s-16.8,0.4-19.9,0.4c0,0-3.1,0-3.1-6v-24.6c0,0,0-6,0.3-5.9
l1.2,0.3c0,0,0.3,0.1,0.8-1.1s2.5-3.7,2.5-6.1c0.2-40,0.2-79.9,0-119.9C5,367.5,2.9,365,2.2,363.7"/>
<path class="st1" d="M114.9,469.9l-1.5,1.2c0,0-0.3,0.2-0.8-0.5s-2.3-2.1-2.3-3.5c-0.2-23.2-0.2-46.4,0-69.6c0-1.3,2-2.6,2.5-3.2
c0,0,0.5-0.6,0.8-0.5l1.3,0.9 M44.9,432.6c0-15.2,0.1-30.5,0-45.8c0-5.4,2.1-7.9,7.6-7.8c10.5,0.2,37.1,0.1,47.6,0
c5.4-0.1,7.9,2.2,7.9,7.6c-0.1,30.5-0.1,61,0,91.5c0,5.3-2.2,8-7.7,7.9c-10.3-0.1-36.7-0.1-47,0c-5.8,0.1-8.6-2.3-8.5-8.4
C45,462.6,44.8,447.6,44.9,432.6"/>
<path class="st1" d="M218.4,427.1c-24.7-0.2-40.3-21.7-39.5-41.9c1-24.1,19.7-40.8,43.9-39.6c21.8,1.1,38.3,20.9,37,44.4
C258.6,410.9,239.4,428.2,218.4,427.1"/>
<path class="st15" d="M692.1,363.5c4.6,2.3,10,2.2,14.5-0.1l60.3-34.7c4-2.3,8.2-3.8,9.4-3.4c1.1,0.5,1.9,1.5,2.2,2.7
c0,0,0,0.7,0,1.4s-3.2,3.8-7.2,6.1l-63.5,37.1c-4,2.3-7.2,7.9-7.2,12.5v14.2c0,4.5-3.3,6.4-7.3,4.1L630,367.7
c-4-2.3-7.3-7.8-7.3-12.4v-27.9c0,0,0,0,0.3-0.2l1-0.6c0.7-0.4,4.6,1.1,8.6,3.3L692.1,363.5z M693.3,278.3l-72,41.5
c-4.1,2.7-6.7,7.1-7.2,11.9l1.7,0.2c1.2,0.2,2,1.3,1.9,2.5v8c0,0,0,1.1-3.7,2.8v14.9c0,4.5,3.3,10.1,7.3,12.4l71.9,40.7
c4.6,2.3,10,2.3,14.6,0l71.9-40.7c4-2.3,7.3-7.8,7.3-12.4v-12c-0.3-0.1-0.6-0.1-0.8-0.2c-8.1-2.7-8.1-4.6-8.1-4.6v-8
c-0.1-1.2,0.7-2.3,1.9-2.5l7-1c-0.4-4.9-3.1-9.4-7.2-12.1l-72-41.5C703.2,276,697.9,276,693.3,278.3"/>
<path class="st1" d="M487.8,147L487.8,147l35.2-36.8l-5.1-4.9l-23,23.7C494.9,129,488.9,135,487.8,147"/>
<path class="st1" d="M519.9,43.1L519.9,43.1L563.6,0l5.3,5.2l-14,14.4l-1.3-1.3l-27.2,26.1C526.4,44.5,523.8,47.1,519.9,43.1"/>
<path class="st1" d="M631.5,61.3l-7.1,7.1c0,0-4.2,4.2,0,8.5l62.5,62.6c0,0,4.2,4.2,8.5,0l2.8-2.8c0,0,4.2-4.2,0-8.5L631.5,61.3z"
/>
<path class="st1" d="M709.3,166.1L594.9,51.7c0,0-4.2-4.2-8.5,0l-2.8,2.8c0,0-4.2,4.2,0,8.5l24.1,24.1l-11.6,11.6
c0,0-4.2,4.2,0,8.5l86,86c0,0,4.2,4.2,8.5,0l1.9-1.9c-3.5-2.2-5.6-6-5.5-10.1c0.2-6.2,5.7-11.5,11.6-11.2c3.9,0.3,7.4,2.4,9.5,5.7
l1.2-1.2C709.3,174.5,713.5,170.3,709.3,166.1 M690.2,182.8c-0.1-0.1-0.2-0.2-0.3-0.3L690.2,182.8 M690.4,183l-0.1-0.1L690.4,183
M689.4,181c0.1-0.4,0.3-0.8,0.7-1.1C689.7,180.2,689.4,180.6,689.4,181"/>
<path class="st1" d="M708.4,138.2c0,0-4.2-4.2-8.5,0L697,141c0,0-4.2,4.2,0,8.5l14,13.9L708.4,138.2z"/>
<path class="st1" d="M693,228.7c0.1-0.3,0.2-0.6,0.5-0.8C693.2,228.1,693.1,228.4,693,228.7 M693.7,230c-0.1-0.1-0.2-0.2-0.3-0.3
C693.5,229.8,693.6,229.9,693.7,230 M707.3,229.2c-0.3,4.3-3.9,7.7-8.2,7.8c-4.4-0.2-7.8-3.8-7.7-8.2c0.2-4.4,3.7-7.8,8.1-7.8
C703.8,221.3,707.2,224.9,707.3,229.2 M690.5,227.4c-0.3,0.3-0.6,0.7-0.7,1.1C689.9,228,690.2,227.6,690.5,227.4 M724.9,239v-3
c0,0,0-5-5-5h-8.8c0.1-0.5,0.2-1.1,0.2-1.6c0.2-6.2-5.4-12.1-11.7-12.4s-12,5.3-12.2,11.7c0,0.8,0,1.5,0.2,2.3h-8.7c0,0-5,0-5,5v3
c0,0,0,5,5,5h41C719.9,244,724.9,244,724.9,239"/>
<path class="st1" d="M707.7,181.8c-0.1-5-4-9.1-9-9.5c-4.8-0.2-9.2,4.1-9.4,9c-0.1,5.1,3.8,9.3,8.9,9.4
C703.2,190.6,707.4,186.7,707.7,181.8"/>
</g>
</g>
<polygon class="st1" points="570.8,7.1 570.8,7.1 568.9,9 615.4,55.5 617.3,53.6 "/>
<g>
<defs>
<rect id="SVGID_5_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_6_">
<use xlink:href="#SVGID_5_" style="overflow:visible;"/>
</clipPath>
<g class="st16">
<path class="st1" d="M586.1,44.3l-25.1-25.1c-0.4-0.4-1-0.4-1.4,0c0,0-0.1,0.1-0.1,0.1l-1.8,1.8c-0.1,0.1-0.1,0.2-0.2,0.3
L583.9,48l0.1-0.1l0,0c0.1-0.1,0.2-0.1,0.3-0.2l1.7-1.9C586,45.8,586.8,45,586.1,44.3"/>
</g>
</g>
<polygon class="st1" points="540.7,88.9 540.7,88.9 538.8,87 521,105.1 522.9,107 "/>
<g>
<defs>
<rect id="SVGID_7_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_8_">
<use xlink:href="#SVGID_7_" style="overflow:visible;"/>
</clipPath>
<g class="st17">
<path class="st1" d="M1123.8,193.5c0.3,0.3,0.1,0.5-0.2,0.7c-2.5,1.6-5,3.1-7.4,4.8c-2,1.3-3.6,3-4.7,5.1
c-0.8,1.6-0.7,3.6,0.3,5.2c1.3,2,3,3.7,5.1,5c2.2,1.5,4.5,2.9,6.7,4.3c0.3,0.2,0.5,0.3,0.2,0.6c-1.7-0.9-3.4-1.9-5.1-2.7
c-2.1-1.1-4.3-2-6.6-2.6c-1.8-0.6-3.7-0.5-5.4,0.2c-1.3,0.7-2.4,2-2.8,3.4c-0.7,1.8-1.1,3.6-1.2,5.5c-0.3,2.8-0.4,5.7-0.3,8.5
c0,0.1,0,0.2,0,0.2h-0.6c0-0.2,0-0.3,0-0.5c-0.1-2.3-0.1-4.6-0.3-6.9c-0.1-2.5-0.6-4.9-1.5-7.2c-0.6-1.9-2.2-3.3-4.2-3.6
c-1.4-0.2-2.8-0.1-4.1,0.4c-1.8,0.6-3.5,1.2-5.2,2c-2.1,1-4.2,2.1-6.3,3.2l-0.2-0.3c0.1-0.1,0.3-0.2,0.4-0.3
c2.5-1.6,5-3.1,7.4-4.9c1.9-1.3,3.5-3,4.6-5c0.9-1.7,0.7-3.7-0.3-5.2c-1.3-2-3-3.7-5-4.9c-2.2-1.5-4.5-2.9-6.7-4.3
c-0.3-0.2-0.5-0.3-0.2-0.6c1.8,1,3.6,2,5.4,2.9c2.2,1.1,4.4,2,6.8,2.6c1.3,0.3,2.6,0.4,3.9,0.1c1.7-0.5,3-1.7,3.6-3.3
c0.8-1.9,1.3-4,1.4-6.1c0.2-2.3,0.2-4.6,0.4-6.9c0-0.5,0.1-1,0.1-1.5c0-0.1,0.2-0.2,0.3-0.3l0.2,0.1c0,0.4,0,0.9,0,1.3
c0,1.5,0,3,0.1,4.5c0.1,2.9,0.6,5.7,1.5,8.4c0.8,2,2,3.5,4.2,3.9c1.6,0.2,3.2,0.1,4.7-0.4c3.5-1.2,6.8-2.7,10-4.6L1123.8,193.5"/>
<path class="st1" d="M654.9,259c-9,0-14-0.2-22,0c-5,0.1-17,0-17.8,1.1c-4.3,6.1-7.5,14.1-8,21.6c-1.1,17-0.4,34.2-0.4,51.8l8,1.1
v8.6c-15.6,5.2-21.9-3.5-21.7-17.2c0.2-15.7-0.2-31.4,0.2-47.1c0.1-6.4,1.3-12.7,2.1-19.5c-7.4-0.6-5.1-6.6-4.5-12.4h89V259"/>
<path class="st1" d="M731.9,259c9,0,27-0.2,35,0c5,0.1,12.2-0.6,14,2c4.3,6.2,7.5,13.4,8,20.8c1.1,17,0.4,34.2,0.4,51.8l-8,1.1
v8.6c15.6,5.2,21.9-3.5,21.7-17.2c-0.2-15.7,0.2-31.4-0.2-47.1c-0.1-6.4-1.3-12.7-2.1-19.5c7.4-0.6,5.1-6.6,4.5-12.4h-89v12"/>
<path class="st1" d="M275.1,286c-7.5,7.5-44.8,44.5-58.3,57.9c2.7-0.3,5.3-0.4,8-0.3c2.1,0.1,4.1,0.4,6.1,0.8
c3.1-3.2,7-7.3,12.1-12.5c8.1-8.3,15.8-16.3,24.2-24.2c4.6-4.3,7-8.7,5.8-15C272.5,290.3,273.2,287.7,275.1,286 M275.8,285.2
c-0.2,0.2-0.5,0.5-0.7,0.8C276.5,284.6,276.9,284.2,275.8,285.2"/>
<path class="st1" d="M390.6,217c0,5.9-4.8,10.6-10.7,10.6c-5.9,0-10.6-4.8-10.6-10.7c0-5.8,4.7-10.5,10.4-10.6
C385.7,206.3,390.5,211.1,390.6,217 M419,217c0-21.6-17.5-39.1-39.1-39.1s-39.1,17.5-39.1,39.1c0,21.6,17.5,39.1,39.1,39.1
S419,238.6,419,217L419,217"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

+8 -8
View File
@@ -33,18 +33,13 @@
height: var(--big-size);
}
> span {
width: 100%;
height: 100%;
}
// material-icon
&.material {
> span {
> .icon {
font-family: "Material Icons";
font-size: inherit;
font-weight: normal;
font-style: normal;
font-weight: inherit;
font-style: inherit;
display: inline-block;
line-height: 1;
text-transform: none;
@@ -68,6 +63,11 @@
&.svg {
box-sizing: content-box;
> .icon {
width: 100%;
height: 100%;
}
svg {
pointer-events: none;
width: 100%;
Loaded 100 of 297 files, more files were not shown because too many files have changed in this diff. Show more