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

show lens-metrics on cluster settings

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2021-05-06 17:00:39 +03:00
parent 7b1b8e6321
commit 8b0b7fcbb2
18 changed files with 311 additions and 84 deletions

View File

@ -1,55 +1,27 @@
import { LensRendererExtension, Interface, Component, Catalog} from "@k8slens/extensions"; import React from "react";
import { MetricsFeature } from "./src/metrics-feature"; import { LensRendererExtension, Catalog } from "@k8slens/extensions";
import { MetricsSettings } from "./src/metrics-settings";
export default class ClusterMetricsFeatureExtension extends LensRendererExtension { export default class ClusterMetricsFeatureExtension extends LensRendererExtension {
onActivate() { entitySettings = [
const category = Catalog.catalogCategories.getForGroupKind<Catalog.KubernetesClusterCategory>("entity.k8slens.dev", "KubernetesCluster"); {
apiVersions: ["entity.k8slens.dev/v1alpha1"],
kind: "KubernetesCluster",
title: "Lens Metrics",
priority: 5,
components: {
View: (props: {entity: Catalog.KubernetesCluster}) => { // eslint-disable-line
const cluster = props.entity; // eslint-disable-line
if (!category) { return (
return; <section>
} <section>
<MetricsSettings cluster={cluster} />
category.on("contextMenuOpen", this.clusterContextMenuOpen.bind(this)); </section>
} </section>
);
async clusterContextMenuOpen(cluster: Catalog.KubernetesCluster, ctx: Interface.CatalogEntityContextMenuContext) { }
if (!cluster.status.active) {
return;
}
const metricsFeature = new MetricsFeature();
await metricsFeature.updateStatus(cluster);
if (metricsFeature.status.installed) {
if (metricsFeature.status.canUpgrade) {
ctx.menuItems.unshift({
icon: "refresh",
title: "Upgrade Lens Metrics stack",
onClick: async () => {
metricsFeature.upgrade(cluster);
}
});
} }
ctx.menuItems.unshift({
icon: "toggle_off",
title: "Uninstall Lens Metrics stack",
onClick: async () => {
await metricsFeature.uninstall(cluster);
Component.Notifications.info(`Lens Metrics has been removed from ${cluster.metadata.name}`, { timeout: 10_000 });
}
});
} else {
ctx.menuItems.unshift({
icon: "toggle_on",
title: "Install Lens Metrics stack",
onClick: async () => {
metricsFeature.install(cluster);
Component.Notifications.info(`Lens Metrics is now installed to ${cluster.metadata.name}`, { timeout: 10_000 });
}
});
} }
} ];
} }

View File

@ -1,3 +1,4 @@
{{#if prometheus.enabled}}
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
@ -14,3 +15,4 @@ spec:
protocol: TCP protocol: TCP
port: 80 port: 80
targetPort: 9090 targetPort: 9090
{{/if}}

View File

@ -1,3 +1,4 @@
{{#if prometheus.enabled}}
apiVersion: apps/v1 apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
@ -53,7 +54,7 @@ spec:
mountPath: /var/lib/prometheus mountPath: /var/lib/prometheus
containers: containers:
- name: prometheus - name: prometheus
image: quay.io/prometheus/prometheus:v2.19.3 image: quay.io/prometheus/prometheus:v2.20.1
args: args:
- --web.listen-address=0.0.0.0:9090 - --web.listen-address=0.0.0.0:9090
- --config.file=/etc/prometheus/prometheus.yaml - --config.file=/etc/prometheus/prometheus.yaml
@ -114,3 +115,4 @@ spec:
requests: requests:
storage: {{persistence.size}} storage: {{persistence.size}}
{{/if}} {{/if}}
{{/if}}

View File

@ -4,6 +4,9 @@ import * as path from "path";
export interface MetricsConfiguration { export interface MetricsConfiguration {
// Placeholder for Metrics config structure // Placeholder for Metrics config structure
prometheus: {
enabled: boolean;
};
persistence: { persistence: {
enabled: boolean; enabled: boolean;
storageClass: string; storageClass: string;
@ -29,20 +32,23 @@ export class MetricsFeature extends ClusterFeature.Feature {
latestVersion = "v2.19.3-lens1"; latestVersion = "v2.19.3-lens1";
templateContext: MetricsConfiguration = { templateContext: MetricsConfiguration = {
prometheus: {
enabled: false
},
persistence: { persistence: {
enabled: false, enabled: false,
storageClass: null, storageClass: null,
size: "20G", size: "20G",
}, },
nodeExporter: { nodeExporter: {
enabled: true, enabled: false,
}, },
retention: { retention: {
time: "2d", time: "2d",
size: "5GB", size: "5GB",
}, },
kubeStateMetrics: { kubeStateMetrics: {
enabled: true, enabled: false,
}, },
alertManagers: null, alertManagers: null,
replicas: 1, replicas: 1,
@ -59,7 +65,7 @@ export class MetricsFeature extends ClusterFeature.Feature {
sc.metadata?.annotations?.["storageclass.beta.kubernetes.io/is-default-class"] === "true" sc.metadata?.annotations?.["storageclass.beta.kubernetes.io/is-default-class"] === "true"
)); ));
super.applyResources(cluster, path.join(__dirname, "../resources/")); await super.applyResources(cluster, path.join(__dirname, "../resources/"));
} }
async upgrade(cluster: Catalog.KubernetesCluster): Promise<void> { async upgrade(cluster: Catalog.KubernetesCluster): Promise<void> {
@ -75,6 +81,13 @@ export class MetricsFeature extends ClusterFeature.Feature {
this.status.installed = true; this.status.installed = true;
this.status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; this.status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1];
this.status.canUpgrade = semver.lt(this.status.currentVersion, this.latestVersion, true); this.status.canUpgrade = semver.lt(this.status.currentVersion, this.latestVersion, true);
const pvcTemplate = prometheus.spec.volumeClaimTemplates[0];
if (pvcTemplate) {
this.templateContext.persistence.enabled = true;
this.templateContext.persistence.size = pvcTemplate.spec.resources.requests.storage;
}
} else { } else {
this.status.installed = false; this.status.installed = false;
} }

View File

@ -0,0 +1,213 @@
import React from "react";
import { Component, Catalog, K8sApi } from "@k8slens/extensions";
import { observer } from "mobx-react";
import { observable } from "mobx";
import { MetricsFeature } from "./metrics-feature";
interface Props {
cluster: Catalog.KubernetesCluster;
}
@observer
export class MetricsSettings extends React.Component<Props> {
@observable featureStates = {
prometheus: false,
kubeStateMetrics: false,
nodeExporter: false
};
@observable canUpgrade = false;
@observable upgrading = false;
pvcOptions = [
{ value: "", label: "Use EmptyDir (no PVC)" },
{ value: "5GB", label: "5GB" },
{ value: "10GB", label: "10GB" },
{ value: "20GB", label: "20GB" },
{ value: "40GB", label: "40GB" },
{ value: "80GB", label: "80GB" },
];
featureSet: MetricsFeature;
async componentDidMount() {
this.featureSet = new MetricsFeature();
await this.updateFeatureStates();
}
async updateFeatureStates() {
await this.featureSet.updateStatus(this.props.cluster);
this.canUpgrade = this.featureSet.status.canUpgrade;
this.featureStates.prometheus = this.featureSet.status.installed;
const deployment = K8sApi.forCluster(this.props.cluster, K8sApi.Deployment);
try {
await deployment.get({name: "kube-state-metrics", namespace: "lens-metrics"});
this.featureStates.kubeStateMetrics = true;
} catch(e) {
if (e?.error?.code === 404) {
this.featureStates.kubeStateMetrics = false;
} else {
this.featureStates.kubeStateMetrics = undefined;
}
}
const daemonSet = K8sApi.forCluster(this.props.cluster, K8sApi.DaemonSet);
try {
await daemonSet.get({name: "node-exporter", namespace: "lens-metrics"});
this.featureStates.nodeExporter = true;
} catch(e) {
if (e?.error?.code === 404) {
this.featureStates.nodeExporter = false;
} else {
this.featureStates.nodeExporter = undefined;
}
}
}
async save() {
const ctx = this.featureSet.templateContext;
ctx.prometheus.enabled = !!this.featureStates.prometheus;
ctx.kubeStateMetrics.enabled = !!this.featureStates.kubeStateMetrics;
ctx.nodeExporter.enabled = !!this.featureStates.nodeExporter;
if (!ctx.prometheus.enabled && !ctx.kubeStateMetrics.enabled && !ctx.nodeExporter.enabled) {
await this.featureSet.uninstall(this.props.cluster);
} else {
await this.featureSet.install(this.props.cluster);
}
}
async togglePrometheus(enabled: boolean) {
this.featureStates.prometheus = enabled;
this.featureSet.templateContext.prometheus.enabled = this.featureStates.prometheus;
try {
await this.save();
} catch(error) {
this.featureStates.prometheus = !enabled;
Component.Notifications.error(`Failed to ${enabled ? "enable" : "disable"} Prometheus: ${error}`);
}
}
async toggleKubeStateMetrics(enabled: boolean) {
this.featureStates.kubeStateMetrics = enabled;
this.featureSet.templateContext.kubeStateMetrics.enabled = this.featureStates.kubeStateMetrics;
try {
await this.save();
} catch(error) {
this.featureStates.kubeStateMetrics = !enabled;
Component.Notifications.error(`Failed to ${enabled ? "enable" : "disable"} kube-state-metrics: ${error}`);
}
}
async toggleNodeExporter(enabled: boolean) {
this.featureStates.nodeExporter = enabled;
this.featureSet.templateContext.nodeExporter.enabled = this.featureStates.nodeExporter;
try {
await this.save();
} catch(error) {
this.featureStates.nodeExporter = !enabled;
Component.Notifications.error(`Failed to ${enabled ? "enable" : "disable"} node-exporter: ${error}`);
}
}
async updateStack() {
this.upgrading = true;
await this.save();
setTimeout(() => {
Component.Notifications.info("Lens Metrics stack updated!", {timeout: 5_000});
this.upgrading = false;
this.canUpgrade = false;
}, 1000);
}
render() {
return (
<>
{ !this.props.cluster.status.active && (
<section>
<p style={ {color: "var(--colorError)"} }>
Lens Metrics settings requires established connection to the cluster.
</p>
</section>
)}
{ this.canUpgrade && (
<section>
<Component.SubTitle title="Software Update" />
<Component.Button label={this.upgrading ? "Updating ..." : "Update Now"} primary onClick={() => this.updateStack() } waiting={this.upgrading} />
<small className="hint">
An update is available for installed metrics components.
</small>
</section>
)}
<section>
<Component.SubTitle title="Prometheus" />
<Component.FormSwitch
control={
<Component.Switcher
disabled={this.featureStates.kubeStateMetrics === undefined || !this.props.cluster.status.active}
checked={!!this.featureStates.prometheus && this.props.cluster.status.active}
onChange={v => this.togglePrometheus(v.target.checked)}
name="prometheus"
/>
}
label="Install built-in Prometheus metrics stack"
/>
<small className="hint">
Enable timeseries data visualization (Prometheus stack) for your cluster.
</small>
</section>
<section>
<Component.SubTitle title="Kube State Metrics" />
<Component.FormSwitch
control={
<Component.Switcher
disabled={this.featureStates.kubeStateMetrics === undefined || !this.props.cluster.status.active}
checked={!!this.featureStates.kubeStateMetrics && this.props.cluster.status.active}
onChange={v => this.toggleKubeStateMetrics(v.target.checked)}
name="node-exporter"
/>
}
label="Install built-in kube-state-metrics stack"
/>
<small className="hint">
Enable Kubernetes API object metrics for your cluster.
Install this only if you don&apos;t have existing kube-state-metrics stack installed.
</small>
</section>
<section>
<Component.SubTitle title="Node Exporter" />
<Component.FormSwitch
control={
<Component.Switcher
disabled={this.featureStates.nodeExporter === undefined || !this.props.cluster.status.active}
checked={!!this.featureStates.nodeExporter && this.props.cluster.status.active}
onChange={v => this.toggleNodeExporter(v.target.checked)}
name="node-exporter"
/>
}
label="Install built-in node-exporter stack"
/>
<small className="hint">
Enable node level metrics for your cluster.
Install this only if you don&apos;t have existing node-exporter stack installed.
</small>
</section>
</>
);
}
}

View File

@ -16,8 +16,8 @@
"jsx": "react" "jsx": "react"
}, },
"include": [ "include": [
"./*.ts", "./**/*.ts",
"./*.tsx" "./**/*.tsx"
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",

View File

@ -34,7 +34,6 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
async onContextMenuOpen(context: CatalogEntityContextMenuContext) { async onContextMenuOpen(context: CatalogEntityContextMenuContext) {
context.menuItems = [ context.menuItems = [
{ {
icon: "settings",
title: "Settings", title: "Settings",
onlyVisibleForSource: "local", onlyVisibleForSource: "local",
onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`) onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`)
@ -43,7 +42,6 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) { if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) {
context.menuItems.push({ context.menuItems.push({
icon: "delete",
title: "Delete", title: "Delete",
onlyVisibleForSource: "local", onlyVisibleForSource: "local",
onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid), onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid),
@ -54,14 +52,20 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
} }
if (this.status.phase == "connected") { if (this.status.phase == "connected") {
context.menuItems.unshift({ context.menuItems.push({
icon: "link_off",
title: "Disconnect", title: "Disconnect",
onClick: async () => { onClick: async () => {
ClusterStore.getInstance().deactivate(this.metadata.uid); ClusterStore.getInstance().deactivate(this.metadata.uid);
requestMain(clusterDisconnectHandler, this.metadata.uid); requestMain(clusterDisconnectHandler, this.metadata.uid);
} }
}); });
} else {
context.menuItems.push({
title: "Connect",
onClick: async () => {
context.navigate(`/cluster/${this.metadata.uid}`);
}
});
} }
const category = catalogCategoryRegistry.getCategoryForEntity<KubernetesClusterCategory>(this); const category = catalogCategoryRegistry.getCategoryForEntity<KubernetesClusterCategory>(this);

View File

@ -62,7 +62,6 @@ export interface CatalogEntityActionContext {
} }
export interface CatalogEntityContextMenu { export interface CatalogEntityContextMenu {
icon: string;
title: string; title: string;
onlyVisibleForSource?: string; // show only if empty or if matches with entity source onlyVisibleForSource?: string; // show only if empty or if matches with entity source
onClick: () => void | Promise<void>; onClick: () => void | Promise<void>;
@ -71,6 +70,10 @@ export interface CatalogEntityContextMenu {
} }
} }
export interface CatalogEntityAddMenu extends CatalogEntityContextMenu {
icon: string;
}
export interface CatalogEntitySettingsMenu { export interface CatalogEntitySettingsMenu {
group?: string; group?: string;
title: string; title: string;
@ -90,7 +93,7 @@ export interface CatalogEntitySettingsContext {
export interface CatalogEntityAddMenuContext { export interface CatalogEntityAddMenuContext {
navigate: (url: string) => void; navigate: (url: string) => void;
menuItems: CatalogEntityContextMenu[]; menuItems: CatalogEntityAddMenu[];
} }
export type CatalogEntitySpec = Record<string, any>; export type CatalogEntitySpec = Record<string, any>;

View File

@ -46,14 +46,20 @@ if (ipcMain) {
} }
}); });
handleRequest(clusterKubectlApplyAllHandler, (event, clusterId: ClusterId, resources: string[]) => { handleRequest(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[]) => {
appEventBus.emit({name: "cluster", action: "kubectl-apply-all"}); appEventBus.emit({name: "cluster", action: "kubectl-apply-all"});
const cluster = ClusterStore.getInstance().getById(clusterId); const cluster = ClusterStore.getInstance().getById(clusterId);
if (cluster) { if (cluster) {
const applier = new ResourceApplier(cluster); const applier = new ResourceApplier(cluster);
applier.kubectlApplyAll(resources); try {
const stdout = await applier.kubectlApplyAll(resources);
return { stdout };
} catch (error: any) {
return { stderr: error };
}
} else { } else {
throw `${clusterId} is not a valid cluster id`; throw `${clusterId} is not a valid cluster id`;
} }

View File

@ -99,9 +99,9 @@ export abstract class ClusterFeature {
} }
if (app) { if (app) {
await new ResourceApplier(clusterModel).kubectlApplyAll(resources); return await new ResourceApplier(clusterModel).kubectlApplyAll(resources);
} else { } else {
await requestMain(clusterKubectlApplyAllHandler, cluster.metadata.uid, resources); return await requestMain(clusterKubectlApplyAllHandler, cluster.metadata.uid, resources);
} }
} }

View File

@ -13,6 +13,7 @@ export * from "../../renderer/components/checkbox";
export * from "../../renderer/components/radio"; export * from "../../renderer/components/radio";
export * from "../../renderer/components/select"; export * from "../../renderer/components/select";
export * from "../../renderer/components/slider"; export * from "../../renderer/components/slider";
export * from "../../renderer/components/switch";
export * from "../../renderer/components/input/input"; export * from "../../renderer/components/input/input";
// command-overlay // command-overlay

View File

@ -67,16 +67,19 @@ export class ResourceApplier {
const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${tmpDir}"`; const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${tmpDir}"`;
console.log("shooting manifests with:", cmd); console.log("shooting manifests with:", cmd);
exec(cmd, (error, stdout, stderr) => { exec(cmd, (error, stdout) => {
if (error) { if (error) {
reject(`Error applying manifests:${error}`); const splittedError = error.toString().split(`.yaml": `);
}
if (stderr != "") { if (splittedError[1]) {
reject(stderr); reject(`Error applying manifests: ${splittedError[1]}`);
} else {
reject(`Error applying manifests:${error}`);
}
return; return;
} }
resolve(stdout); resolve(stdout);
}); });
}); });

View File

@ -9,6 +9,7 @@ export {
CatalogEntityKindData, CatalogEntityKindData,
CatalogEntityActionContext, CatalogEntityActionContext,
CatalogEntityAddMenuContext, CatalogEntityAddMenuContext,
CatalogEntityAddMenu,
CatalogEntityContextMenu, CatalogEntityContextMenu,
CatalogEntityContextMenuContext CatalogEntityContextMenuContext
} from "../../common/catalog"; } from "../../common/catalog";

View File

@ -5,7 +5,7 @@ import { Icon } from "../icon";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { observable, reaction } from "mobx"; import { observable, reaction } from "mobx";
import { autobind } from "../../../common/utils"; import { autobind } from "../../../common/utils";
import { CatalogCategory, CatalogEntityAddMenuContext, CatalogEntityContextMenu } from "../../api/catalog-entity"; import { CatalogCategory, CatalogEntityAddMenuContext, CatalogEntityAddMenu } from "../../api/catalog-entity";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
@ -16,7 +16,7 @@ export type CatalogAddButtonProps = {
@observer @observer
export class CatalogAddButton extends React.Component<CatalogAddButtonProps> { export class CatalogAddButton extends React.Component<CatalogAddButtonProps> {
@observable protected isOpen = false; @observable protected isOpen = false;
protected menuItems = observable.array<CatalogEntityContextMenu>([]); protected menuItems = observable.array<CatalogEntityAddMenu>([]);
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [

View File

@ -8,7 +8,6 @@ import { navigate } from "../../navigation";
import { kebabCase } from "lodash"; import { kebabCase } from "lodash";
import { PageLayout } from "../layout/page-layout"; import { PageLayout } from "../layout/page-layout";
import { MenuItem, MenuActions } from "../menu"; import { MenuItem, MenuActions } from "../menu";
import { Icon } from "../icon";
import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { HotbarStore } from "../../../common/hotbar-store"; import { HotbarStore } from "../../../common/hotbar-store";
@ -115,16 +114,16 @@ export class Catalog extends React.Component {
return ( return (
<MenuActions onOpen={() => item.onContextMenuOpen(this.contextMenu)}> <MenuActions onOpen={() => item.onContextMenuOpen(this.contextMenu)}>
<MenuItem key="add-to-hotbar" onClick={() => this.addToHotbar(item) }>
<Icon material="add" small interactive={true} title="Add to hotbar"/> Add to Hotbar
</MenuItem>
{ {
menuItems.map((menuItem, index) => ( menuItems.map((menuItem, index) => (
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}> <MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>
<Icon material={menuItem.icon} small interactive={true} title={menuItem.title} /> {menuItem.title} {menuItem.title}
</MenuItem> </MenuItem>
)) ))
} }
<MenuItem key="add-to-hotbar" onClick={() => this.addToHotbar(item) }>
Add to Hotbar
</MenuItem>
</MenuActions> </MenuActions>
); );
} }

View File

@ -36,9 +36,15 @@ export class EntitySettings extends React.Component<Props> {
async componentDidMount() { async componentDidMount() {
const { hash } = navigation.location; const { hash } = navigation.location;
this.ensureActiveTab(); if (hash) {
const item = this.menuItems.find((item) => item.title === hash.slice(1));
document.getElementById(hash.slice(1))?.scrollIntoView(); if (item) {
this.activeTab = item.id;
}
}
this.ensureActiveTab();
} }
onTabChange = (tabId: string) => { onTabChange = (tabId: string) => {

View File

@ -159,16 +159,16 @@ export class HotbarIcon extends React.Component<Props> {
position={{right: true, bottom: true }} // FIXME: position does not work position={{right: true, bottom: true }} // FIXME: position does not work
open={() => onOpen()} open={() => onOpen()}
close={() => this.toggleMenu()}> close={() => this.toggleMenu()}>
<MenuItem key="remove-from-hotbar" onClick={() => this.remove(entity) }>
<Icon material="clear" small interactive={true} title="Remove from hotbar"/> Remove from Hotbar
</MenuItem>
{ this.contextMenu && menuItems.map((menuItem) => { { this.contextMenu && menuItems.map((menuItem) => {
return ( return (
<MenuItem key={menuItem.title} onClick={() => this.onMenuItemClick(menuItem) }> <MenuItem key={menuItem.title} onClick={() => this.onMenuItemClick(menuItem) }>
<Icon material={menuItem.icon} small interactive={true} title={menuItem.title}/> {menuItem.title} {menuItem.title}
</MenuItem> </MenuItem>
); );
})} })}
<MenuItem key="remove-from-hotbar" onClick={() => this.remove(entity) }>
Remove from Hotbar
</MenuItem>
</Menu> </Menu>
{children} {children}
</div> </div>

View File

@ -1,5 +1,5 @@
.Menu { .Menu {
--bgc: #{$contentColor}; --bgc: #{$layoutBackground};
position: absolute; position: absolute;
display: flex; display: flex;
@ -8,6 +8,8 @@
list-style: none; list-style: none;
border: 1px solid $borderColor; border: 1px solid $borderColor;
z-index: 101; z-index: 101;
box-shadow: rgba(0,0,0,0.24) 0px 8px 16px 0px;
border-radius: 4px;
&.portal { &.portal {
left: -1000px; left: -1000px;