mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
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:
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>;
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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,2 +1,2 @@
|
||||
export * from "./cluster.routes"
|
||||
export * from "./cluster.route"
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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<{}> {
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
File renamed without changes.
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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
Reference in new issue
Block a user