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

Merge branch 'master' into feature/command-palette

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2021-01-26 07:35:49 +02:00
commit 8a8ce3e692
124 changed files with 758 additions and 485 deletions

View File

@ -58,6 +58,7 @@ jobs:
- script: make test-extensions - script: make test-extensions
displayName: Run In-tree Extension tests displayName: Run In-tree Extension tests
- bash: | - bash: |
set -e
rm -rf extensions/telemetry rm -rf extensions/telemetry
make integration-win make integration-win
git checkout extensions/telemetry git checkout extensions/telemetry
@ -102,6 +103,7 @@ jobs:
- script: make test-extensions - script: make test-extensions
displayName: Run In-tree Extension tests displayName: Run In-tree Extension tests
- bash: | - bash: |
set -e
rm -rf extensions/telemetry rm -rf extensions/telemetry
make integration-mac make integration-mac
git checkout extensions/telemetry git checkout extensions/telemetry
@ -159,6 +161,7 @@ jobs:
sudo chown -R $USER $HOME/.kube $HOME/.minikube sudo chown -R $USER $HOME/.kube $HOME/.minikube
displayName: Install integration test dependencies displayName: Install integration test dependencies
- bash: | - bash: |
set -e
rm -rf extensions/telemetry rm -rf extensions/telemetry
xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux
git checkout extensions/telemetry git checkout extensions/telemetry

View File

@ -46,6 +46,8 @@ module.exports = {
"avoidEscape": true, "avoidEscape": true,
"allowTemplateLiterals": true, "allowTemplateLiterals": true,
}], }],
"linebreak-style": ["error", "unix"],
"eol-last": ["error", "always"],
"semi": ["error", "always"], "semi": ["error", "always"],
"object-shorthand": "error", "object-shorthand": "error",
"prefer-template": "error", "prefer-template": "error",
@ -101,6 +103,8 @@ module.exports = {
}], }],
"semi": "off", "semi": "off",
"@typescript-eslint/semi": ["error"], "@typescript-eslint/semi": ["error"],
"linebreak-style": ["error", "unix"],
"eol-last": ["error", "always"],
"object-shorthand": "error", "object-shorthand": "error",
"prefer-template": "error", "prefer-template": "error",
"template-curly-spacing": "error", "template-curly-spacing": "error",
@ -162,6 +166,8 @@ module.exports = {
}], }],
"semi": "off", "semi": "off",
"@typescript-eslint/semi": ["error"], "@typescript-eslint/semi": ["error"],
"linebreak-style": ["error", "unix"],
"eol-last": ["error", "always"],
"object-shorthand": "error", "object-shorthand": "error",
"prefer-template": "error", "prefer-template": "error",
"template-curly-spacing": "error", "template-curly-spacing": "error",

View File

@ -1 +1 @@
module.exports = {}; module.exports = {};

View File

@ -1 +1 @@
module.exports = {}; module.exports = {};

View File

@ -56,4 +56,4 @@ export function resolveStatusForCronJobs(cronJob: K8sApi.CronJob): K8sApi.KubeOb
text: `${event.message}`, text: `${event.message}`,
timestamp: event.metadata.creationTimestamp timestamp: event.metadata.creationTimestamp
}; };
} }

View File

@ -77,4 +77,4 @@ describe("search store tests", () => {
searchStore.onSearch(logs, "Starting"); searchStore.onSearch(logs, "Starting");
expect(searchStore.totalFinds).toBe(2); expect(searchStore.totalFinds).toBe(2);
}); });
}); });

View File

@ -101,4 +101,4 @@ describe("user store tests", () => {
expect(us.lastSeenAppVersion).toBe("0.0.0"); expect(us.lastSeenAppVersion).toBe("0.0.0");
}); });
}); });
}); });

View File

@ -10,4 +10,4 @@ export class ExecValidationNotFoundError extends Error {
this.name = this.constructor.name; this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor); Error.captureStackTrace(this, this.constructor);
} }
} }

View File

@ -175,4 +175,4 @@ export function validateKubeConfig (config: KubeConfig) {
throw new ExecValidationNotFoundError(execCommand, isAbsolute); throw new ExecValidationNotFoundError(execCommand, isAbsolute);
} }
} }
} }

View File

@ -10,4 +10,4 @@ import { PrometheusProviderRegistry } from "../main/prometheus/provider-registry
PrometheusProviderRegistry.registerProvider(provider.id, provider); PrometheusProviderRegistry.registerProvider(provider.id, provider);
}); });
export const prometheusProviders = PrometheusProviderRegistry.getProviders(); export const prometheusProviders = PrometheusProviderRegistry.getProviders();

View File

@ -7,37 +7,38 @@ export type KubeResource =
"endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets"; "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets";
export interface KubeApiResource { export interface KubeApiResource {
resource: KubeResource; // valid resource name kind: string; // resource type (e.g. "Namespace")
apiName: KubeResource; // valid api resource name (e.g. "namespaces")
group?: string; // api-group group?: string; // api-group
} }
// TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7) // TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7)
export const apiResources: KubeApiResource[] = [ export const apiResources: KubeApiResource[] = [
{ resource: "configmaps" }, { kind: "ConfigMap", apiName: "configmaps" },
{ resource: "cronjobs", group: "batch" }, { kind: "CronJob", apiName: "cronjobs", group: "batch" },
{ resource: "customresourcedefinitions", group: "apiextensions.k8s.io" }, { kind: "CustomResourceDefinition", apiName: "customresourcedefinitions", group: "apiextensions.k8s.io" },
{ resource: "daemonsets", group: "apps" }, { kind: "DaemonSet", apiName: "daemonsets", group: "apps" },
{ resource: "deployments", group: "apps" }, { kind: "Deployment", apiName: "deployments", group: "apps" },
{ resource: "endpoints" }, { kind: "Endpoint", apiName: "endpoints" },
{ resource: "events" }, { kind: "Event", apiName: "events" },
{ resource: "horizontalpodautoscalers" }, { kind: "HorizontalPodAutoscaler", apiName: "horizontalpodautoscalers" },
{ resource: "ingresses", group: "networking.k8s.io" }, { kind: "Ingress", apiName: "ingresses", group: "networking.k8s.io" },
{ resource: "jobs", group: "batch" }, { kind: "Job", apiName: "jobs", group: "batch" },
{ resource: "limitranges" }, { kind: "Namespace", apiName: "namespaces" },
{ resource: "namespaces" }, { kind: "LimitRange", apiName: "limitranges" },
{ resource: "networkpolicies", group: "networking.k8s.io" }, { kind: "NetworkPolicy", apiName: "networkpolicies", group: "networking.k8s.io" },
{ resource: "nodes" }, { kind: "Node", apiName: "nodes" },
{ resource: "persistentvolumes" }, { kind: "PersistentVolume", apiName: "persistentvolumes" },
{ resource: "persistentvolumeclaims" }, { kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" },
{ resource: "pods" }, { kind: "Pod", apiName: "pods" },
{ resource: "poddisruptionbudgets" }, { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets" },
{ resource: "podsecuritypolicies" }, { kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" },
{ resource: "resourcequotas" }, { kind: "ResourceQuota", apiName: "resourcequotas" },
{ resource: "replicasets", group: "apps" }, { kind: "ReplicaSet", apiName: "replicasets", group: "apps" },
{ resource: "secrets" }, { kind: "Secret", apiName: "secrets" },
{ resource: "services" }, { kind: "Service", apiName: "services" },
{ resource: "statefulsets", group: "apps" }, { kind: "StatefulSet", apiName: "statefulsets", group: "apps" },
{ resource: "storageclasses", group: "storage.k8s.io" }, { kind: "StorageClass", apiName: "storageclasses", group: "storage.k8s.io" },
]; ];
export function isAllowedResource(resources: KubeResource | KubeResource[]) { export function isAllowedResource(resources: KubeResource | KubeResource[]) {

View File

@ -133,4 +133,4 @@ export class SearchStore {
} }
} }
export const searchStore = new SearchStore; export const searchStore = new SearchStore;

View File

@ -84,6 +84,15 @@ export class UserStore extends BaseStore<UserStoreModel> {
return semver.gt(getAppVersion(), this.lastSeenAppVersion); return semver.gt(getAppVersion(), this.lastSeenAppVersion);
} }
@action
setHiddenTableColumns(tableId: string, names: Set<string> | string[]) {
this.preferences.hiddenTableColumns[tableId] = Array.from(names);
}
getHiddenTableColumns(tableId: string): Set<string> {
return new Set(this.preferences.hiddenTableColumns[tableId]);
}
@action @action
resetKubeConfigPath() { resetKubeConfigPath() {
this.kubeConfigPath = kubeConfigDefaultPath; this.kubeConfigPath = kubeConfigDefaultPath;

View File

@ -28,4 +28,4 @@ describe("split array on element tests", () => {
test("ten elements, in end array", () => { test("ten elements, in end array", () => {
expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 9)).toStrictEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8], [], true]); expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 9)).toStrictEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8], [], true]);
}); });
}); });

View File

@ -26,4 +26,4 @@ class Singleton {
} }
export { Singleton }; export { Singleton };
export default Singleton; export default Singleton;

View File

@ -1 +1 @@
export * from "./registrations"; export * from "./registrations";

View File

@ -5,4 +5,4 @@ export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../re
export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry"; export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry";
export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry";
export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry";
export type { StatusBarRegistration } from "../registries/status-bar-registry"; export type { StatusBarRegistration } from "../registries/status-bar-registry";

View File

@ -8,4 +8,4 @@ export enum KubeObjectStatusLevel {
INFO = 1, INFO = 1,
WARNING = 2, WARNING = 2,
CRITICAL = 3 CRITICAL = 3
} }

View File

@ -2,4 +2,4 @@ import { themeStore } from "../../renderer/theme.store";
export function getActiveTheme() { export function getActiveTheme() {
return themeStore.activeTheme; return themeStore.activeTheme;
} }

View File

@ -31,4 +31,4 @@ export class BaseClusterDetector {
}, },
}); });
} }
} }

View File

@ -23,4 +23,4 @@ export class ClusterIdDetector extends BaseClusterDetector {
return response.metadata.uid; return response.metadata.uid;
} }
} }

View File

@ -48,4 +48,4 @@ detectorRegistry.add(ClusterIdDetector);
detectorRegistry.add(LastSeenDetector); detectorRegistry.add(LastSeenDetector);
detectorRegistry.add(VersionDetector); detectorRegistry.add(VersionDetector);
detectorRegistry.add(DistributionDetector); detectorRegistry.add(DistributionDetector);
detectorRegistry.add(NodesCountDetector); detectorRegistry.add(NodesCountDetector);

View File

@ -11,4 +11,4 @@ export class LastSeenDetector extends BaseClusterDetector {
return { value: new Date().toJSON(), accuracy: 100 }; return { value: new Date().toJSON(), accuracy: 100 };
} }
} }

View File

@ -16,4 +16,4 @@ export class NodesCountDetector extends BaseClusterDetector {
return response.items.length; return response.items.length;
} }
} }

View File

@ -16,4 +16,4 @@ export class VersionDetector extends BaseClusterDetector {
return response.gitVersion; return response.gitVersion;
} }
} }

View File

@ -190,7 +190,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
@observable metadata: ClusterMetadata = {}; @observable metadata: ClusterMetadata = {};
/** /**
* List of allowed namespaces * List of allowed namespaces verified via K8S::SelfSubjectAccessReview api
* *
* @observable * @observable
*/ */
@ -203,7 +203,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
@observable allowedResources: string[] = []; @observable allowedResources: string[] = [];
/** /**
* List of accessible namespaces * List of accessible namespaces provided by user in the Cluster Settings
* *
* @observable * @observable
*/ */
@ -224,7 +224,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @computed * @computed
*/ */
@computed get name() { @computed get name() {
return this.preferences.clusterName || this.contextName; return this.preferences.clusterName || this.contextName;
} }
/** /**
@ -279,7 +279,8 @@ export class Cluster implements ClusterModel, ClusterState {
* @param port port where internal auth proxy is listening * @param port port where internal auth proxy is listening
* @internal * @internal
*/ */
@action async init(port: number) { @action
async init(port: number) {
try { try {
this.initializing = true; this.initializing = true;
this.contextHandler = new ContextHandler(this); this.contextHandler = new ContextHandler(this);
@ -334,7 +335,8 @@ export class Cluster implements ClusterModel, ClusterState {
* @param force force activation * @param force force activation
* @internal * @internal
*/ */
@action async activate(force = false) { @action
async activate(force = false) {
if (this.activated && !force) { if (this.activated && !force) {
return this.pushState(); return this.pushState();
} }
@ -373,7 +375,8 @@ export class Cluster implements ClusterModel, ClusterState {
/** /**
* @internal * @internal
*/ */
@action async reconnect() { @action
async reconnect() {
logger.info(`[CLUSTER]: reconnect`, this.getMeta()); logger.info(`[CLUSTER]: reconnect`, this.getMeta());
this.contextHandler?.stopServer(); this.contextHandler?.stopServer();
await this.contextHandler?.ensureServer(); await this.contextHandler?.ensureServer();
@ -400,7 +403,8 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal * @internal
* @param opts refresh options * @param opts refresh options
*/ */
@action async refresh(opts: ClusterRefreshOptions = {}) { @action
async refresh(opts: ClusterRefreshOptions = {}) {
logger.info(`[CLUSTER]: refresh`, this.getMeta()); logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.whenInitialized; await this.whenInitialized;
await this.refreshConnectionStatus(); await this.refreshConnectionStatus();
@ -420,7 +424,8 @@ export class Cluster implements ClusterModel, ClusterState {
/** /**
* @internal * @internal
*/ */
@action async refreshMetadata() { @action
async refreshMetadata() {
logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta());
const metadata = await detectorRegistry.detectForCluster(this); const metadata = await detectorRegistry.detectForCluster(this);
const existingMetadata = this.metadata; const existingMetadata = this.metadata;
@ -431,7 +436,8 @@ export class Cluster implements ClusterModel, ClusterState {
/** /**
* @internal * @internal
*/ */
@action async refreshConnectionStatus() { @action
async refreshConnectionStatus() {
const connectionStatus = await this.getConnectionStatus(); const connectionStatus = await this.getConnectionStatus();
this.online = connectionStatus > ClusterStatus.Offline; this.online = connectionStatus > ClusterStatus.Offline;
@ -441,7 +447,8 @@ export class Cluster implements ClusterModel, ClusterState {
/** /**
* @internal * @internal
*/ */
@action async refreshAllowedResources() { @action
async refreshAllowedResources() {
this.allowedNamespaces = await this.getAllowedNamespaces(); this.allowedNamespaces = await this.getAllowedNamespaces();
this.allowedResources = await this.getAllowedResources(); this.allowedResources = await this.getAllowedResources();
} }
@ -668,7 +675,7 @@ export class Cluster implements ClusterModel, ClusterState {
for (const namespace of this.allowedNamespaces.slice(0, 10)) { for (const namespace of this.allowedNamespaces.slice(0, 10)) {
if (!this.resourceAccessStatuses.get(apiResource)) { if (!this.resourceAccessStatuses.get(apiResource)) {
const result = await this.canI({ const result = await this.canI({
resource: apiResource.resource, resource: apiResource.apiName,
group: apiResource.group, group: apiResource.group,
verb: "list", verb: "list",
namespace namespace
@ -683,9 +690,19 @@ export class Cluster implements ClusterModel, ClusterState {
return apiResources return apiResources
.filter((resource) => this.resourceAccessStatuses.get(resource)) .filter((resource) => this.resourceAccessStatuses.get(resource))
.map(apiResource => apiResource.resource); .map(apiResource => apiResource.apiName);
} catch (error) { } catch (error) {
return []; return [];
} }
} }
isAllowedResource(kind: string): boolean {
const apiResource = apiResources.find(resource => resource.kind === kind || resource.apiName === kind);
if (apiResource) {
return this.allowedResources.includes(apiResource.apiName);
}
return true; // allowed by default for other resources
}
} }

View File

@ -18,4 +18,4 @@ export default {
...version270Beta1, ...version270Beta1,
...version360Beta1, ...version360Beta1,
...snap ...snap
}; };

View File

@ -79,4 +79,4 @@ describe("KubeApi", () => {
expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiPrefix).toEqual("/apis");
expect(kubeApi.apiGroup).toEqual("extensions"); expect(kubeApi.apiGroup).toEqual("extensions");
}); });
}); });

View File

@ -11,7 +11,7 @@ import { apiPrefix, isDevelopment } from "../../common/vars";
import { getHostedCluster } from "../../common/cluster-store"; import { getHostedCluster } from "../../common/cluster-store";
export interface IKubeWatchEvent<T = any> { export interface IKubeWatchEvent<T = any> {
type: "ADDED" | "MODIFIED" | "DELETED"; type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR";
object?: T; object?: T;
} }
@ -62,27 +62,41 @@ export class KubeWatchApi {
}); });
} }
protected getQuery(): Partial<IKubeWatchRouteQuery> { // FIXME: use POST to send apis for subscribing (list could be huge)
const { isAdmin, allowedNamespaces } = getHostedCluster(); // TODO: try to use normal fetch res.body stream to consume watch-api updates
// https://github.com/lensapp/lens/issues/1898
protected async getQuery() {
const { namespaceStore } = await import("../components/+namespaces/namespace.store");
await namespaceStore.whenReady;
const { isAdmin } = getHostedCluster();
return { return {
api: this.activeApis.map(api => { api: this.activeApis.map(api => {
if (isAdmin) return api.getWatchUrl(); if (isAdmin && !api.isNamespaced) {
return api.getWatchUrl();
}
return allowedNamespaces.map(namespace => api.getWatchUrl(namespace)); if (api.isNamespaced) {
return namespaceStore.getContextNamespaces().map(namespace => api.getWatchUrl(namespace));
}
return [];
}).flat() }).flat()
}; };
} }
// todo: maybe switch to websocket to avoid often reconnects // todo: maybe switch to websocket to avoid often reconnects
@autobind() @autobind()
protected connect() { protected async connect() {
if (this.evtSource) this.disconnect(); // close previous connection if (this.evtSource) this.disconnect(); // close previous connection
if (!this.activeApis.length) { const query = await this.getQuery();
if (!this.activeApis.length || !query.api.length) {
return; return;
} }
const query = this.getQuery();
const apiUrl = `${apiPrefix}/watch?${stringify(query)}`; const apiUrl = `${apiPrefix}/watch?${stringify(query)}`;
this.evtSource = new EventSource(apiUrl); this.evtSource = new EventSource(apiUrl);
@ -158,6 +172,10 @@ export class KubeWatchApi {
addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) { addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) {
const listener = (evt: IKubeWatchEvent<KubeJsonApiData>) => { const listener = (evt: IKubeWatchEvent<KubeJsonApiData>) => {
if (evt.type === "ERROR") {
return; // e.g. evt.object.message == "too old resource version"
}
const { namespace, resourceVersion } = evt.object.metadata; const { namespace, resourceVersion } = evt.object.metadata;
const api = apiManager.getApiByKind(evt.object.kind, evt.object.apiVersion); const api = apiManager.getApiByKind(evt.object.kind, evt.object.apiVersion);

View File

@ -1,7 +1,7 @@
import get from "lodash/get"; import get from "lodash/get";
import { KubeObject } from "./kube-object"; import { KubeObject } from "./kube-object";
interface IToleration { export interface IToleration {
key?: string; key?: string;
operator?: string; operator?: string;
effect?: string; effect?: string;
@ -82,4 +82,4 @@ export class WorkloadKubeObject extends KubeObject {
return Object.keys(affinity).length; return Object.keys(affinity).length;
} }
} }

View File

@ -11,4 +11,4 @@ export interface IHelmChartsRouteParams {
repo?: string; repo?: string;
} }
export const helmChartsURL = buildURL<IHelmChartsRouteParams>(helmChartsRoute.path); export const helmChartsURL = buildURL<IHelmChartsRouteParams>(helmChartsRoute.path);

View File

@ -1,2 +1,2 @@
export * from "./helm-charts"; export * from "./helm-charts";
export * from "./helm-charts.route"; export * from "./helm-charts.route";

View File

@ -1,2 +1,2 @@
export * from "./releases"; export * from "./releases";
export * from "./release.route"; export * from "./release.route";

View File

@ -5,7 +5,7 @@ import { HelmRelease, helmReleasesApi, IReleaseCreatePayload, IReleaseUpdatePayl
import { ItemStore } from "../../item.store"; import { ItemStore } from "../../item.store";
import { Secret } from "../../api/endpoints"; import { Secret } from "../../api/endpoints";
import { secretsStore } from "../+config-secrets/secrets.store"; import { secretsStore } from "../+config-secrets/secrets.store";
import { getHostedCluster } from "../../../common/cluster-store"; import { namespaceStore } from "../+namespaces/namespace.store";
@autobind() @autobind()
export class ReleaseStore extends ItemStore<HelmRelease> { export class ReleaseStore extends ItemStore<HelmRelease> {
@ -60,30 +60,23 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
@action @action
async loadAll() { async loadAll() {
this.isLoading = true; this.isLoading = true;
let items;
try { try {
const { isAdmin, allowedNamespaces } = getHostedCluster(); const items = await this.loadItems(namespaceStore.getContextNamespaces());
items = await this.loadItems(!isAdmin ? allowedNamespaces : null); this.items.replace(this.sortItems(items));
} finally {
if (items) {
items = this.sortItems(items);
this.items.replace(items);
}
this.isLoaded = true; this.isLoaded = true;
} catch (error) {
console.error(`Loading Helm Chart releases has failed: ${error}`);
} finally {
this.isLoading = false; this.isLoading = false;
} }
} }
async loadItems(namespaces?: string[]) { async loadItems(namespaces: string[]) {
if (!namespaces) { return Promise
return helmReleasesApi.list(); .all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
} else { .then(items => items.flat());
return Promise
.all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
.then(items => items.flat());
}
} }
async create(payload: IReleaseCreatePayload) { async create(payload: IReleaseCreatePayload) {

View File

@ -48,4 +48,4 @@ export class ClusterHomeDirSetting extends React.Component<Props> {
</> </>
); );
} }
} }

View File

@ -45,4 +45,4 @@ export class ClusterNameSetting extends React.Component<Props> {
</> </>
); );
} }
} }

View File

@ -45,4 +45,4 @@ export class ClusterProxySetting extends React.Component<Props> {
</> </>
); );
} }
} }

View File

@ -25,4 +25,4 @@ export class General extends React.Component<Props> {
<ClusterAccessibleNamespaces cluster={this.props.cluster} /> <ClusterAccessibleNamespaces cluster={this.props.cluster} />
</div>; </div>;
} }
} }

View File

@ -17,4 +17,4 @@ export class Removal extends React.Component<Props> {
</div> </div>
); );
} }
} }

View File

@ -58,4 +58,4 @@ export class Status extends React.Component<Props> {
</div> </div>
</div>; </div>;
} }
} }

View File

@ -95,4 +95,4 @@ export const ClusterMetrics = observer(() => {
{renderMetrics()} {renderMetrics()}
</div> </div>
); );
}); });

View File

@ -1,2 +1,2 @@
export * from "./landing-page.route"; export * from "./landing-page.route";
export * from "./landing-page"; export * from "./landing-page";

View File

@ -1,53 +1,120 @@
import { action, comparer, observable, reaction } from "mobx"; import { action, comparer, IReactionDisposer, IReactionOptions, observable, reaction, toJS, when } from "mobx";
import { autobind, createStorage } from "../../utils"; import { autobind, createStorage } from "../../utils";
import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store";
import { Namespace, namespacesApi } from "../../api/endpoints"; import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api";
import { createPageParam } from "../../navigation"; import { createPageParam } from "../../navigation";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
import { isAllowedResource } from "../../../common/rbac"; import { clusterStore, getHostedCluster } from "../../../common/cluster-store";
import { getHostedCluster } from "../../../common/cluster-store";
const storage = createStorage<string[]>("context_namespaces", []); const storage = createStorage<string[]>("context_namespaces");
export const namespaceUrlParam = createPageParam<string[]>({ export const namespaceUrlParam = createPageParam<string[]>({
name: "namespaces", name: "namespaces",
isSystem: true, isSystem: true,
multiValues: true, multiValues: true,
get defaultValue() { get defaultValue() {
return storage.get(); // initial namespaces coming from URL or local-storage (default) return storage.get() ?? []; // initial namespaces coming from URL or local-storage (default)
} }
}); });
export function getDummyNamespace(name: string) {
return new Namespace({
kind: Namespace.kind,
apiVersion: "v1",
metadata: {
name,
uid: "",
resourceVersion: "",
selfLink: `/api/v1/namespaces/${name}`
}
});
}
@autobind() @autobind()
export class NamespaceStore extends KubeObjectStore<Namespace> { export class NamespaceStore extends KubeObjectStore<Namespace> {
api = namespacesApi; api = namespacesApi;
contextNs = observable.array<string>();
@observable contextNs = observable.array<string>();
@observable isReady = false;
whenReady = when(() => this.isReady);
constructor() { constructor() {
super(); super();
this.init(); this.init();
} }
private init() { private async init() {
this.setContext(this.initNamespaces); await clusterStore.whenLoaded;
if (!getHostedCluster()) return;
await getHostedCluster().whenReady; // wait for cluster-state from main
return reaction(() => this.contextNs.toJS(), namespaces => { this.setContext(this.initialNamespaces);
this.autoLoadAllowedNamespaces();
this.autoUpdateUrlAndLocalStorage();
this.isReady = true;
}
public onContextChange(callback: (contextNamespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer {
return reaction(() => this.contextNs.toJS(), callback, {
equals: comparer.shallow,
...opts,
});
}
private autoUpdateUrlAndLocalStorage(): IReactionDisposer {
return this.onContextChange(namespaces => {
storage.set(namespaces); // save to local-storage storage.set(namespaces); // save to local-storage
namespaceUrlParam.set(namespaces, { replaceHistory: true }); // update url namespaceUrlParam.set(namespaces, { replaceHistory: true }); // update url
}, { }, {
fireImmediately: true, fireImmediately: true,
equals: comparer.identity,
}); });
} }
get initNamespaces() { private autoLoadAllowedNamespaces(): IReactionDisposer {
return namespaceUrlParam.get(); return reaction(() => this.allowedNamespaces, () => this.loadAll(), {
fireImmediately: true,
equals: comparer.shallow,
});
} }
getContextParams() { get allowedNamespaces(): string[] {
return { return toJS(getHostedCluster().allowedNamespaces);
namespaces: this.contextNs.toJS(), }
};
private get initialNamespaces(): string[] {
const allowed = new Set(this.allowedNamespaces);
const prevSelected = storage.get();
if (Array.isArray(prevSelected)) {
return prevSelected.filter(namespace => allowed.has(namespace));
}
// otherwise select "default" or first allowed namespace
if (allowed.has("default")) {
return ["default"];
} else if (allowed.size) {
return [Array.from(allowed)[0]];
}
return [];
}
getContextNamespaces(): string[] {
const namespaces = this.contextNs.toJS();
// show all namespaces when nothing selected
if (!namespaces.length) {
if (this.isLoaded) {
// return actual namespaces list since "allowedNamespaces" updating every 30s in cluster and thus might be stale
return this.items.map(namespace => namespace.getName());
}
return this.allowedNamespaces;
}
return namespaces;
} }
subscribe(apis = [this.api]) { subscribe(apis = [this.api]) {
@ -61,31 +128,18 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
return super.subscribe(apis); return super.subscribe(apis);
} }
protected async loadItems(namespaces?: string[]) { protected async loadItems(params: KubeObjectStoreLoadingParams) {
if (!isAllowedResource("namespaces")) { const { allowedNamespaces } = this;
if (namespaces) return namespaces.map(this.getDummyNamespace);
return []; let namespaces = await super.loadItems(params);
namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName()));
if (!namespaces.length && allowedNamespaces.length > 0) {
return allowedNamespaces.map(getDummyNamespace);
} }
if (namespaces) { return namespaces;
return Promise.all(namespaces.map(name => this.api.get({ name })));
} else {
return super.loadItems();
}
}
protected getDummyNamespace(name: string) {
return new Namespace({
kind: "Namespace",
apiVersion: "v1",
metadata: {
name,
uid: "",
resourceVersion: "",
selfLink: `/api/v1/namespaces/${name}`
}
});
} }
@action @action
@ -105,12 +159,6 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
else this.contextNs.push(namespace); else this.contextNs.push(namespace);
} }
@action
reset() {
super.reset();
this.contextNs.clear();
}
@action @action
async remove(item: Namespace) { async remove(item: Namespace) {
await super.remove(item); await super.remove(item);

View File

@ -51,6 +51,10 @@ export class Nodes extends React.Component<Props> {
if (!metrics || !metrics[1]) return <LineProgress value={0}/>; if (!metrics || !metrics[1]) return <LineProgress value={0}/>;
const usage = metrics[0]; const usage = metrics[0];
const cores = metrics[1]; const cores = metrics[1];
const cpuUsagePercent = Math.ceil(usage * 100) / cores;
const cpuUsagePercentLabel: String = cpuUsagePercent % 1 === 0
? cpuUsagePercent.toString()
: cpuUsagePercent.toFixed(2);
return ( return (
<LineProgress <LineProgress
@ -58,7 +62,7 @@ export class Nodes extends React.Component<Props> {
value={usage} value={usage}
tooltip={{ tooltip={{
preferredPositions: TooltipPosition.BOTTOM, preferredPositions: TooltipPosition.BOTTOM,
children: `CPU: ${Math.ceil(usage * 100) / cores}\%, cores: ${cores}` children: `CPU: ${cpuUsagePercentLabel}\%, cores: ${cores}`
}} }}
/> />
); );

View File

@ -1,3 +1,3 @@
export * from "./pod-security-policies.route"; export * from "./pod-security-policies.route";
export * from "./pod-security-policies"; export * from "./pod-security-policies";
export * from "./pod-security-policy-details"; export * from "./pod-security-policy-details";

View File

@ -1,7 +1,7 @@
import difference from "lodash/difference"; import difference from "lodash/difference";
import uniqBy from "lodash/uniqBy"; import uniqBy from "lodash/uniqBy";
import { clusterRoleBindingApi, IRoleBindingSubject, RoleBinding, roleBindingApi } from "../../api/endpoints"; import { clusterRoleBindingApi, IRoleBindingSubject, RoleBinding, roleBindingApi } from "../../api/endpoints";
import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
@ -26,15 +26,13 @@ export class RoleBindingsStore extends KubeObjectStore<RoleBinding> {
return clusterRoleBindingApi.get(params); return clusterRoleBindingApi.get(params);
} }
protected loadItems(namespaces?: string[]) { protected async loadItems(params: KubeObjectStoreLoadingParams): Promise<RoleBinding[]> {
if (namespaces) { const items = await Promise.all([
return Promise.all( super.loadItems({ ...params, api: clusterRoleBindingApi }),
namespaces.map(namespace => roleBindingApi.list({ namespace })) super.loadItems({ ...params, api: roleBindingApi }),
).then(items => items.flat()); ]);
} else {
return Promise.all([clusterRoleBindingApi.list(), roleBindingApi.list()]) return items.flat();
.then(items => items.flat());
}
} }
protected async createItem(params: { name: string; namespace?: string }, data?: Partial<RoleBinding>) { protected async createItem(params: { name: string; namespace?: string }, data?: Partial<RoleBinding>) {

View File

@ -1,6 +1,6 @@
import { clusterRoleApi, Role, roleApi } from "../../api/endpoints"; import { clusterRoleApi, Role, roleApi } from "../../api/endpoints";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
@autobind() @autobind()
@ -24,15 +24,13 @@ export class RolesStore extends KubeObjectStore<Role> {
return clusterRoleApi.get(params); return clusterRoleApi.get(params);
} }
protected loadItems(namespaces?: string[]): Promise<Role[]> { protected async loadItems(params: KubeObjectStoreLoadingParams): Promise<Role[]> {
if (namespaces) { const items = await Promise.all([
return Promise.all( super.loadItems({ ...params, api: clusterRoleApi }),
namespaces.map(namespace => roleApi.list({ namespace })) super.loadItems({ ...params, api: roleApi }),
).then(items => items.flat()); ]);
} else {
return Promise.all([clusterRoleApi.list(), roleApi.list()]) return items.flat();
.then(items => items.flat());
}
} }
protected async createItem(params: { name: string; namespace?: string }, data?: Partial<Role>) { protected async createItem(params: { name: string; namespace?: string }, data?: Partial<Role>) {
@ -49,4 +47,4 @@ export const rolesStore = new RolesStore();
apiManager.registerStore(rolesStore, [ apiManager.registerStore(rolesStore, [
roleApi, roleApi,
clusterRoleApi, clusterRoleApi,
]); ]);

View File

@ -1,3 +1,3 @@
export * from "./service-accounts"; export * from "./service-accounts";
export * from "./service-accounts-details"; export * from "./service-accounts-details";
export * from "./create-service-account-dialog"; export * from "./create-service-account-dialog";

View File

@ -1,2 +1,2 @@
export * from "./user-management"; export * from "./user-management";
export * from "./user-management.route"; export * from "./user-management.route";

View File

@ -27,7 +27,7 @@ export class OverviewStatuses extends React.Component {
@autobind() @autobind()
renderWorkload(resource: KubeResource): React.ReactElement { renderWorkload(resource: KubeResource): React.ReactElement {
const store = workloadStores[resource]; const store = workloadStores[resource];
const items = store.getAllByNs(namespaceStore.contextNs); const items = store.getAllByNs(namespaceStore.getContextNamespaces());
return ( return (
<div className="workload" key={resource}> <div className="workload" key={resource}>

View File

@ -17,81 +17,65 @@ import { cronJobStore } from "../+workloads-cronjobs/cronjob.store";
import { Events } from "../+events"; import { Events } from "../+events";
import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectStore } from "../../kube-object.store";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
import { namespaceStore } from "../+namespaces/namespace.store";
interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> { interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> {
} }
@observer @observer
export class WorkloadsOverview extends React.Component<Props> { export class WorkloadsOverview extends React.Component<Props> {
@observable isLoading = false;
@observable isUnmounting = false; @observable isUnmounting = false;
async componentDidMount() { async componentDidMount() {
const stores: KubeObjectStore[] = []; const stores: KubeObjectStore[] = [
isAllowedResource("pods") && podsStore,
isAllowedResource("deployments") && deploymentStore,
isAllowedResource("daemonsets") && daemonSetStore,
isAllowedResource("statefulsets") && statefulSetStore,
isAllowedResource("replicasets") && replicaSetStore,
isAllowedResource("jobs") && jobStore,
isAllowedResource("cronjobs") && cronJobStore,
isAllowedResource("events") && eventStore,
].filter(Boolean);
if (isAllowedResource("pods")) { const unsubscribeMap = new Map<KubeObjectStore, () => void>();
stores.push(podsStore);
}
if (isAllowedResource("deployments")) { const loadStores = async () => {
stores.push(deploymentStore); this.isLoading = true;
}
if (isAllowedResource("daemonsets")) { for (const store of stores) {
stores.push(daemonSetStore); if (this.isUnmounting) break;
}
if (isAllowedResource("statefulsets")) { try {
stores.push(statefulSetStore); await store.loadAll();
} unsubscribeMap.get(store)?.(); // unsubscribe previous watcher
unsubscribeMap.set(store, store.subscribe());
} catch (error) {
console.error("loading store error", error);
}
}
this.isLoading = false;
};
if (isAllowedResource("replicasets")) { namespaceStore.onContextChange(loadStores, {
stores.push(replicaSetStore); fireImmediately: true,
} });
if (isAllowedResource("jobs")) { await when(() => this.isUnmounting && !this.isLoading);
stores.push(jobStore); unsubscribeMap.forEach(dispose => dispose());
} unsubscribeMap.clear();
if (isAllowedResource("cronjobs")) {
stores.push(cronJobStore);
}
if (isAllowedResource("events")) {
stores.push(eventStore);
}
const unsubscribeList: Array<() => void> = [];
for (const store of stores) {
await store.loadAll();
unsubscribeList.push(store.subscribe());
}
await when(() => this.isUnmounting);
unsubscribeList.forEach(dispose => dispose());
} }
componentWillUnmount() { componentWillUnmount() {
this.isUnmounting = true; this.isUnmounting = true;
} }
get contents() {
return (
<>
<OverviewStatuses/>
{ isAllowedResource("events") && <Events
compact
hideFilters
className="box grow"
/> }
</>
);
}
render() { render() {
return ( return (
<div className="WorkloadsOverview flex column gaps"> <div className="WorkloadsOverview flex column gaps">
{this.contents} <OverviewStatuses/>
{isAllowedResource("events") && <Events compact hideFilters className="box grow"/>}
</div> </div>
); );
} }

View File

@ -0,0 +1,59 @@
/**
* @jest-environment jsdom
*/
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import { fireEvent, render } from "@testing-library/react";
import { IToleration } from "../../../api/workload-kube-object";
import { PodTolerations } from "../pod-tolerations";
const tolerations: IToleration[] =[
{
key: "CriticalAddonsOnly",
operator: "Exist",
effect: "NoExecute",
tolerationSeconds: 3600
},
{
key: "node.kubernetes.io/not-ready",
operator: "NoExist",
effect: "NoSchedule",
tolerationSeconds: 7200
},
];
describe("<PodTolerations />", () => {
it("renders w/o errors", () => {
const { container } = render(<PodTolerations tolerations={tolerations} />);
expect(container).toBeInstanceOf(HTMLElement);
});
it("shows all tolerations", () => {
const { container } = render(<PodTolerations tolerations={tolerations} />);
const rows = container.querySelectorAll(".TableRow");
expect(rows[0].querySelector(".key").textContent).toBe("CriticalAddonsOnly");
expect(rows[0].querySelector(".operator").textContent).toBe("Exist");
expect(rows[0].querySelector(".effect").textContent).toBe("NoExecute");
expect(rows[0].querySelector(".seconds").textContent).toBe("3600");
expect(rows[1].querySelector(".key").textContent).toBe("node.kubernetes.io/not-ready");
expect(rows[1].querySelector(".operator").textContent).toBe("NoExist");
expect(rows[1].querySelector(".effect").textContent).toBe("NoSchedule");
expect(rows[1].querySelector(".seconds").textContent).toBe("7200");
});
it("sorts table properly", () => {
const { container, getByText } = render(<PodTolerations tolerations={tolerations} />);
const headCell = getByText("Key");
fireEvent.click(headCell);
fireEvent.click(headCell);
const rows = container.querySelectorAll(".TableRow");
expect(rows[0].querySelector(".key").textContent).toBe("node.kubernetes.io/not-ready");
});
});

View File

@ -1,2 +1,2 @@
export * from "./pods"; export * from "./pods";
export * from "./pod-details"; export * from "./pod-details";

View File

@ -27,4 +27,4 @@ export class PodDetailsStatuses extends React.Component<Props> {
</div> </div>
); );
} }
} }

View File

@ -1,5 +1,23 @@
.PodDetailsTolerations { .PodDetailsTolerations {
.toleration { grid-template-columns: auto;
margin-bottom: $margin;
.PodTolerations {
margin-top: var(--margin);
}
// Expanding value cell to cover 2 columns (whole Drawer width)
> .name {
grid-row-start: 1;
grid-column-start: 1;
}
> .value {
grid-row-start: 1;
grid-column-start: 1;
}
.DrawerParamToggler > .params {
margin-left: var(--drawer-item-title-width);
} }
} }

View File

@ -1,10 +1,11 @@
import "./pod-details-tolerations.scss"; import "./pod-details-tolerations.scss";
import React from "react"; import React from "react";
import { Pod, Deployment, DaemonSet, StatefulSet, ReplicaSet, Job } from "../../api/endpoints";
import { DrawerParamToggler, DrawerItem } from "../drawer"; import { DrawerParamToggler, DrawerItem } from "../drawer";
import { WorkloadKubeObject } from "../../api/workload-kube-object";
import { PodTolerations } from "./pod-tolerations";
interface Props { interface Props {
workload: Pod | Deployment | DaemonSet | StatefulSet | ReplicaSet | Job; workload: WorkloadKubeObject;
} }
export class PodDetailsTolerations extends React.Component<Props> { export class PodDetailsTolerations extends React.Component<Props> {
@ -17,20 +18,7 @@ export class PodDetailsTolerations extends React.Component<Props> {
return ( return (
<DrawerItem name="Tolerations" className="PodDetailsTolerations"> <DrawerItem name="Tolerations" className="PodDetailsTolerations">
<DrawerParamToggler label={tolerations.length}> <DrawerParamToggler label={tolerations.length}>
{ <PodTolerations tolerations={tolerations} />
tolerations.map((toleration, index) => {
const { key, operator, effect, tolerationSeconds } = toleration;
return (
<div className="toleration" key={index}>
<DrawerItem name="Key">{key}</DrawerItem>
{operator && <DrawerItem name="Operator">{operator}</DrawerItem>}
{effect && <DrawerItem name="Effect">{effect}</DrawerItem>}
{!!tolerationSeconds && <DrawerItem name="Effect">{tolerationSeconds}</DrawerItem>}
</div>
);
})
}
</DrawerParamToggler> </DrawerParamToggler>
</DrawerItem> </DrawerItem>
); );

View File

@ -0,0 +1,14 @@
.PodTolerations {
.TableHead {
background-color: var(--drawerSubtitleBackground);
}
.TableCell {
white-space: normal;
word-break: normal;
&.key {
flex-grow: 3;
}
}
}

View File

@ -0,0 +1,63 @@
import "./pod-tolerations.scss";
import React from "react";
import uniqueId from "lodash/uniqueId";
import { IToleration } from "../../api/workload-kube-object";
import { Table, TableCell, TableHead, TableRow } from "../table";
interface Props {
tolerations: IToleration[];
}
enum sortBy {
Key = "key",
Operator = "operator",
Effect = "effect",
Seconds = "seconds",
}
const sortingCallbacks = {
[sortBy.Key]: (toleration: IToleration) => toleration.key,
[sortBy.Operator]: (toleration: IToleration) => toleration.operator,
[sortBy.Effect]: (toleration: IToleration) => toleration.effect,
[sortBy.Seconds]: (toleration: IToleration) => toleration.tolerationSeconds,
};
const getTableRow = (toleration: IToleration) => {
const { key, operator, effect, tolerationSeconds } = toleration;
return (
<TableRow
key={uniqueId("toleration-")}
sortItem={toleration}
nowrap
>
<TableCell className="key">{key}</TableCell>
<TableCell className="operator">{operator}</TableCell>
<TableCell className="effect">{effect}</TableCell>
<TableCell className="seconds">{tolerationSeconds}</TableCell>
</TableRow>
);
};
export function PodTolerations({ tolerations }: Props) {
return (
<Table
selectable
scrollable={false}
sortable={sortingCallbacks}
sortSyncWithUrl={false}
className="PodTolerations"
>
<TableHead sticky={false}>
<TableCell className="key" sortBy={sortBy.Key}>Key</TableCell>
<TableCell className="operator" sortBy={sortBy.Operator}>Operator</TableCell>
<TableCell className="effect" sortBy={sortBy.Effect}>Effect</TableCell>
<TableCell className="seconds" sortBy={sortBy.Seconds}>Seconds</TableCell>
</TableHead>
{
tolerations.map(getTableRow)
}
</Table>
);
}

View File

@ -19,8 +19,7 @@ import { lookupApiLink } from "../../api/kube-api";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { Badge } from "../badge"; import { Badge } from "../badge";
enum columnId {
enum sortBy {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
containers = "containers", containers = "containers",
@ -77,15 +76,15 @@ export class Pods extends React.Component<Props> {
tableId = "workloads_pods" tableId = "workloads_pods"
isConfigurable isConfigurable
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (pod: Pod) => pod.getName(), [columnId.name]: (pod: Pod) => pod.getName(),
[sortBy.namespace]: (pod: Pod) => pod.getNs(), [columnId.namespace]: (pod: Pod) => pod.getNs(),
[sortBy.containers]: (pod: Pod) => pod.getContainers().length, [columnId.containers]: (pod: Pod) => pod.getContainers().length,
[sortBy.restarts]: (pod: Pod) => pod.getRestartsCount(), [columnId.restarts]: (pod: Pod) => pod.getRestartsCount(),
[sortBy.owners]: (pod: Pod) => pod.getOwnerRefs().map(ref => ref.kind), [columnId.owners]: (pod: Pod) => pod.getOwnerRefs().map(ref => ref.kind),
[sortBy.qos]: (pod: Pod) => pod.getQosClass(), [columnId.qos]: (pod: Pod) => pod.getQosClass(),
[sortBy.node]: (pod: Pod) => pod.getNodeName(), [columnId.node]: (pod: Pod) => pod.getNodeName(),
[sortBy.age]: (pod: Pod) => pod.metadata.creationTimestamp, [columnId.age]: (pod: Pod) => pod.metadata.creationTimestamp,
[sortBy.status]: (pod: Pod) => pod.getStatusMessage(), [columnId.status]: (pod: Pod) => pod.getStatusMessage(),
}} }}
searchFilters={[ searchFilters={[
(pod: Pod) => pod.getSearchFields(), (pod: Pod) => pod.getSearchFields(),
@ -95,16 +94,16 @@ export class Pods extends React.Component<Props> {
]} ]}
renderHeaderTitle="Pods" renderHeaderTitle="Pods"
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning", showWithColumn: "name" }, { className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Containers", className: "containers", sortBy: sortBy.containers }, { title: "Containers", className: "containers", sortBy: columnId.containers, id: columnId.containers },
{ title: "Restarts", className: "restarts", sortBy: sortBy.restarts }, { title: "Restarts", className: "restarts", sortBy: columnId.restarts, id: columnId.restarts },
{ title: "Controlled By", className: "owners", sortBy: sortBy.owners }, { title: "Controlled By", className: "owners", sortBy: columnId.owners, id: columnId.owners },
{ title: "Node", className: "node", sortBy: sortBy.node }, { title: "Node", className: "node", sortBy: columnId.node, id: columnId.node },
{ title: "QoS", className: "qos", sortBy: sortBy.qos }, { title: "QoS", className: "qos", sortBy: columnId.qos, id: columnId.qos },
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
{ title: "Status", className: "status", sortBy: sortBy.status }, { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status },
]} ]}
renderTableContents={(pod: Pod) => [ renderTableContents={(pod: Pod) => [
<Badge flat key="name" label={pod.getName()} tooltip={pod.getName()} />, <Badge flat key="name" label={pod.getName()} tooltip={pod.getName()} />,

View File

@ -1,2 +1,2 @@
export * from "./statefulsets"; export * from "./statefulsets";
export * from "./statefulset-details"; export * from "./statefulset-details";

View File

@ -154,4 +154,4 @@ export class AceEditor extends React.Component<Props, State> {
</div> </div>
); );
} }
} }

View File

@ -1 +1 @@
export * from "./ace-editor"; export * from "./ace-editor";

View File

@ -1 +1 @@
export * from "./add-remove-buttons"; export * from "./add-remove-buttons";

View File

@ -1 +1 @@
export * from "./animate"; export * from "./animate";

View File

@ -39,4 +39,4 @@ export const BackgroundBlock = {
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
} }
}; };

View File

@ -220,4 +220,4 @@ export const cpuOptions: ChartOptions = {
} }
} }
} }
}; };

View File

@ -213,4 +213,4 @@ export class Chart extends React.Component<ChartProps> {
</> </>
); );
} }
} }

View File

@ -1,3 +1,3 @@
export * from "./chart"; export * from "./chart";
export * from "./pie-chart"; export * from "./pie-chart";
export * from "./bar-chart"; export * from "./bar-chart";

View File

@ -64,4 +64,4 @@ export class PieChart extends React.Component<Props> {
ChartJS.Tooltip.positioners.cursor = function (elements: any, position: { x: number; y: number }) { ChartJS.Tooltip.positioners.cursor = function (elements: any, position: { x: number; y: number }) {
return position; return position;
}; };

View File

@ -42,4 +42,4 @@ export function useRealTimeMetrics(metrics: IMetricValues, chartData: IChartData
} }
return data; return data;
} }

View File

@ -95,4 +95,4 @@ export const ZebraStripes = {
cover.style.backgroundPositionX = `${-step * minutes}px`; cover.style.backgroundPositionX = `${-step * minutes}px`;
} }
} }
}; };

View File

@ -50,4 +50,4 @@ export class Checkbox extends React.PureComponent<CheckboxProps> {
</label> </label>
); );
} }
} }

View File

@ -1 +1 @@
export * from "./checkbox"; export * from "./checkbox";

View File

@ -1 +1 @@
export * from "./cluster-icon"; export * from "./cluster-icon";

View File

@ -1 +1 @@
export * from "./confirm-dialog"; export * from "./confirm-dialog";

View File

@ -48,4 +48,4 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: Props) =
{tabs.map(tab => <Fragment key={tab.id}>{renderTab(tab)}</Fragment>)} {tabs.map(tab => <Fragment key={tab.id}>{renderTab(tab)}</Fragment>)}
</Tabs> </Tabs>
); );
}; };

View File

@ -1,12 +1,16 @@
.InfoPanel { .InfoPanel {
@include hidden-scrollbar; @include hidden-scrollbar;
background: $dockInfoBackground; background: var(--dockInfoBackground);
padding: $padding $padding * 2; padding: var(--padding) calc(var(--padding) * 2);
flex-shrink: 0; flex-shrink: 0;
.Spinner { .Spinner {
margin-right: $padding; margin-right: var(--padding);
}
.Badge {
background-color: var(--dockBadgeBackground);
} }
> .controls { > .controls {
@ -15,8 +19,8 @@
&:not(:empty) + .info { &:not(:empty) + .info {
min-height: 25px; min-height: 25px;
padding-left: $padding; padding-left: var(--padding);
padding-right: $padding; padding-right: var(--padding);
} }
} }
} }

View File

@ -41,7 +41,12 @@ export const LogControls = observer((props: Props) => {
return ( return (
<div className={cssNames("LogControls flex gaps align-center justify-space-between wrap")}> <div className={cssNames("LogControls flex gaps align-center justify-space-between wrap")}>
<div className="time-range"> <div className="time-range">
{since && `Logs from ${new Date(since[0]).toLocaleString()}`} {since && (
<span>
Logs from{" "}
<b>{new Date(since[0]).toLocaleString()}</b>
</span>
)}
</div> </div>
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<Checkbox <Checkbox

View File

@ -1,6 +1,8 @@
.DrawerItem { .DrawerItem {
--drawer-item-title-width: 30%;
display: grid; display: grid;
grid-template-columns: minmax(30%, min-content) auto; grid-template-columns: minmax(var(--drawer-item-title-width), min-content) auto;
border-bottom: 1px solid $borderFaintColor; border-bottom: 1px solid $borderFaintColor;
padding: $padding 0; padding: $padding 0;

View File

@ -25,7 +25,7 @@ export class DrawerParamToggler extends React.Component<DrawerParamTogglerProps,
return ( return (
<div className="DrawerParamToggler"> <div className="DrawerParamToggler">
<div className="flex gaps align-center"> <div className="flex gaps align-center params">
<div className="param-label">{label}</div> <div className="param-label">{label}</div>
<div className="param-link" onClick={this.toggle}> <div className="param-link" onClick={this.toggle}>
<span className="param-link-text">{link}</span> <span className="param-link-text">{link}</span>

View File

@ -69,7 +69,6 @@
padding: var(--spacing); padding: var(--spacing);
.Table .TableHead { .Table .TableHead {
background-color: $contentColor;
border-bottom: 1px solid $borderFaintColor; border-bottom: 1px solid $borderFaintColor;
} }
} }

View File

@ -1 +1 @@
export * from "./editable-list"; export * from "./editable-list";

View File

@ -1 +1 @@
export * from "./error-boundary"; export * from "./error-boundary";

View File

@ -209,4 +209,4 @@ export class FilePicker extends React.Component<Props> {
return <Icon material="error" title={this.errorText}></Icon>; return <Icon material="error" title={this.errorText}></Icon>;
} }
} }
} }

View File

@ -1 +1 @@
export * from "./file-picker"; export * from "./file-picker";

View File

@ -1 +1 @@
export * from "./icon"; export * from "./icon";

View File

@ -54,4 +54,4 @@ export class SearchInputUrl extends React.Component<Props> {
/> />
); );
} }
} }

View File

@ -1 +1 @@
export * from "./item-list-layout"; export * from "./item-list-layout";

View File

@ -36,3 +36,14 @@
} }
} }
.ItemListLayoutVisibilityMenu {
.MenuItem {
padding: 0;
}
.Checkbox {
width: 100%;
padding: var(--spacing);
cursor: pointer;
}
}

View File

@ -1,12 +1,11 @@
import "./item-list-layout.scss"; import "./item-list-layout.scss";
import "./table-menu.scss";
import groupBy from "lodash/groupBy"; import groupBy from "lodash/groupBy";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { computed, observable, reaction, toJS, when } from "mobx"; import { computed, IReactionDisposer, observable, reaction, toJS } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog"; import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog";
import { TableSortCallback, Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps } from "../table"; import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallback } from "../table";
import { autobind, createStorage, cssNames, IClassName, isReactNode, noop, prevDefault, stopPropagation } from "../../utils"; import { autobind, createStorage, cssNames, IClassName, isReactNode, noop, prevDefault, stopPropagation } from "../../utils";
import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons"; import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons";
import { NoItems } from "../no-items"; import { NoItems } from "../no-items";
@ -19,11 +18,10 @@ import { PageFiltersList } from "./page-filters-list";
import { PageFiltersSelect } from "./page-filters-select"; import { PageFiltersSelect } from "./page-filters-select";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select";
import { themeStore } from "../../theme.store"; import { themeStore } from "../../theme.store";
import { MenuActions} from "../menu/menu-actions"; import { MenuActions } from "../menu/menu-actions";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { Checkbox } from "../checkbox"; import { Checkbox } from "../checkbox";
import { userStore } from "../../../common/user-store"; import { userStore } from "../../../common/user-store";
import logger from "../../../main/logger";
// todo: refactor, split to small re-usable components // todo: refactor, split to small re-usable components
@ -98,10 +96,11 @@ interface ItemListLayoutUserSettings {
@observer @observer
export class ItemListLayout extends React.Component<ItemListLayoutProps> { export class ItemListLayout extends React.Component<ItemListLayoutProps> {
static defaultProps = defaultProps as object; static defaultProps = defaultProps as object;
@observable hiddenColumnNames = new Set<string>();
private watchDisposers: IReactionDisposer[] = [];
@observable isUnmounting = false; @observable isUnmounting = false;
// default user settings (ui show-hide tweaks mostly)
@observable userSettings: ItemListLayoutUserSettings = { @observable userSettings: ItemListLayoutUserSettings = {
showAppliedFilters: false, showAppliedFilters: false,
}; };
@ -120,31 +119,54 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
} }
async componentDidMount() { async componentDidMount() {
const { store, dependentStores, isClusterScoped, tableId } = this.props; const { isClusterScoped, isConfigurable, tableId } = this.props;
if (this.canBeConfigured) this.hiddenColumnNames = new Set(userStore.preferences?.hiddenTableColumns?.[tableId]); if (isConfigurable && !tableId) {
throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified");
}
const stores = [store, ...dependentStores]; this.loadStores();
if (!isClusterScoped) stores.push(namespaceStore); if (!isClusterScoped) {
disposeOnUnmount(this, [
try { namespaceStore.onContextChange(() => this.loadStores())
stores.map(store => store.reset()); ]);
await Promise.all(stores.map(store => store.loadAll()));
const subscriptions = stores.map(store => store.subscribe());
await when(() => this.isUnmounting);
subscriptions.forEach(dispose => dispose()); // unsubscribe all
} catch (error) {
console.log("catched", error);
} }
} }
componentWillUnmount() { async componentWillUnmount() {
this.isUnmounting = true; this.isUnmounting = true;
const { store, isSelectable } = this.props; this.unsubscribeStores();
}
if (isSelectable) store.resetSelection(); @computed get stores() {
const { store, dependentStores } = this.props;
return new Set([store, ...dependentStores]);
}
async loadStores() {
this.unsubscribeStores(); // reset first
// load
for (const store of this.stores) {
if (this.isUnmounting) {
this.unsubscribeStores();
break;
}
try {
await store.loadAll();
this.watchDisposers.push(store.subscribe());
} catch (error) {
console.error("loading store error", error);
}
}
}
unsubscribeStores() {
this.watchDisposers.forEach(dispose => dispose());
this.watchDisposers.length = 0;
} }
private filterCallbacks: { [type: string]: ItemsFilter } = { private filterCallbacks: { [type: string]: ItemsFilter } = {
@ -180,9 +202,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
}; };
@computed get isReady() { @computed get isReady() {
const { isReady, store } = this.props; return this.props.isReady ?? this.props.store.isLoaded;
return typeof isReady == "boolean" ? isReady : store.isLoaded;
} }
@computed get filters() { @computed get filters() {
@ -228,42 +248,6 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return this.applyFilters(filterItems, allItems); return this.applyFilters(filterItems, allItems);
} }
updateColumnFilter(checkboxValue: boolean, columnName: string) {
if (checkboxValue){
this.hiddenColumnNames.delete(columnName);
} else {
this.hiddenColumnNames.add(columnName);
}
if (this.canBeConfigured) {
userStore.preferences.hiddenTableColumns[this.props.tableId] = Array.from(this.hiddenColumnNames);
}
}
columnIsVisible(index: number): boolean {
const {renderTableHeader} = this.props;
if (!this.canBeConfigured) return true;
return !this.hiddenColumnNames.has(renderTableHeader[index].showWithColumn ?? renderTableHeader[index].className);
}
get canBeConfigured(): boolean {
const { isConfigurable, tableId, renderTableHeader } = this.props;
if (!isConfigurable || !tableId) {
return false;
}
if (!renderTableHeader?.every(({ className }) => className)) {
logger.warning("[ItemObjectList]: cannot configure an object list without all headers being identifiable");
return false;
}
return true;
}
@autobind() @autobind()
getRow(uid: string) { getRow(uid: string) {
const { const {
@ -295,20 +279,18 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
/> />
)} )}
{ {
renderTableContents(item) renderTableContents(item).map((content, index) => {
.map((content, index) => { const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content;
const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; const headCell = renderTableHeader?.[index];
if (copyClassNameFromHeadCells && renderTableHeader) { if (copyClassNameFromHeadCells && headCell) {
const headCell = renderTableHeader[index]; cellProps.className = cssNames(cellProps.className, headCell.className);
}
if (headCell) { if (!headCell || !this.isHiddenColumn(headCell)) {
cellProps.className = cssNames(cellProps.className, headCell.className); return <TableCell key={index} {...cellProps} />;
} }
} })
return this.columnIsVisible(index) ? <TableCell key={index} {...cellProps} /> : null;
})
} }
{renderItemMenu && ( {renderItemMenu && (
<TableCell className="menu" onClick={stopPropagation}> <TableCell className="menu" onClick={stopPropagation}>
@ -347,16 +329,11 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return; return;
} }
return <PageFiltersList filters={filters} />; return <PageFiltersList filters={filters}/>;
} }
renderNoItems() { renderNoItems() {
const { allItems, items, filters } = this; if (this.filters.length > 0) {
const allItemsCount = allItems.length;
const itemsCount = items.length;
const isFiltered = filters.length > 0 && allItemsCount > itemsCount;
if (isFiltered) {
return ( return (
<NoItems> <NoItems>
No items found. No items found.
@ -369,7 +346,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
); );
} }
return <NoItems />; return <NoItems/>;
} }
renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode {
@ -413,12 +390,12 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
title: <h5 className="title">{title}</h5>, title: <h5 className="title">{title}</h5>,
info: this.renderInfo(), info: this.renderInfo(),
filters: <> filters: <>
{!isClusterScoped && <NamespaceSelectFilter />} {!isClusterScoped && <NamespaceSelectFilter/>}
<PageFiltersSelect allowEmpty disableFilters={{ <PageFiltersSelect allowEmpty disableFilters={{
[FilterType.NAMESPACE]: true, // namespace-select used instead [FilterType.NAMESPACE]: true, // namespace-select used instead
}} /> }}/>
</>, </>,
search: <SearchInputUrl />, search: <SearchInputUrl/>,
}; };
let header = this.renderHeaderContent(placeholders); let header = this.renderHeaderContent(placeholders);
@ -442,10 +419,40 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
); );
} }
renderTableHeader() {
const { renderTableHeader, isSelectable, isConfigurable, store } = this.props;
if (!renderTableHeader) {
return;
}
return (
<TableHead showTopLine nowrap>
{isSelectable && (
<TableCell
checkbox
isChecked={store.isSelectedAll(this.items)}
onClick={prevDefault(() => store.toggleSelectionAll(this.items))}
/>
)}
{renderTableHeader.map((cellProps, index) => {
if (!this.isHiddenColumn(cellProps)) {
return <TableCell key={cellProps.id ?? index} {...cellProps} />;
}
})}
{isConfigurable && (
<TableCell className="menu">
{this.renderColumnVisibilityMenu()}
</TableCell>
)}
</TableHead>
);
}
renderList() { renderList() {
const { const {
isSelectable, tableProps = {}, renderTableHeader, renderItemMenu, store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, detailsItem,
store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, detailsItem tableProps = {},
} = this.props; } = this.props;
const { isReady, removeItemsDialog, items } = this; const { isReady, removeItemsDialog, items } = this;
const { selectedItems } = store; const { selectedItems } = store;
@ -454,7 +461,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return ( return (
<div className="items box grow flex column"> <div className="items box grow flex column">
{!isReady && ( {!isReady && (
<Spinner center /> <Spinner center/>
)} )}
{isReady && ( {isReady && (
<Table <Table
@ -470,23 +477,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
className: cssNames("box grow", tableProps.className, themeStore.activeTheme.type), className: cssNames("box grow", tableProps.className, themeStore.activeTheme.type),
})} })}
> >
{renderTableHeader && ( {this.renderTableHeader()}
<TableHead showTopLine nowrap>
{isSelectable && (
<TableCell
checkbox
isChecked={store.isSelectedAll(items)}
onClick={prevDefault(() => store.toggleSelectionAll(items))}
/>
)}
{renderTableHeader.map((cellProps, index) => this.columnIsVisible(index) ? <TableCell key={index} {...cellProps} /> : null)}
{ renderItemMenu &&
<TableCell className="menu" >
{this.canBeConfigured && this.renderColumnMenu()}
</TableCell>
}
</TableHead>
)}
{ {
!virtual && items.map(item => this.getRow(item.getId())) !virtual && items.map(item => this.getRow(item.getId()))
} }
@ -502,24 +493,47 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
); );
} }
renderColumnMenu() { @computed get hiddenColumns() {
const { renderTableHeader} = this.props; return userStore.getHiddenTableColumns(this.props.tableId);
}
isHiddenColumn({ id: columnId, showWithColumn }: TableCellProps): boolean {
if (!this.props.isConfigurable) {
return false;
}
return this.hiddenColumns.has(columnId) || (
showWithColumn && this.hiddenColumns.has(showWithColumn)
);
}
updateColumnVisibility({ id: columnId }: TableCellProps, isVisible: boolean) {
const hiddenColumns = new Set(this.hiddenColumns);
if (!isVisible) {
hiddenColumns.add(columnId);
} else {
hiddenColumns.delete(columnId);
}
userStore.setHiddenTableColumns(this.props.tableId, hiddenColumns);
}
renderColumnVisibilityMenu() {
const { renderTableHeader } = this.props;
return ( return (
<MenuActions <MenuActions className="ItemListLayoutVisibilityMenu" toolbar={false} autoCloseOnSelect={false}>
toolbar = {false}
autoCloseOnSelect = {false}
className={cssNames("KubeObjectMenu")}
>
{renderTableHeader.map((cellProps, index) => ( {renderTableHeader.map((cellProps, index) => (
!cellProps.showWithColumn && !cellProps.showWithColumn && (
<MenuItem key={index} className="input"> <MenuItem key={index} className="input">
<Checkbox label = {cellProps.title ?? `<${cellProps.className}>`} <Checkbox
className = "MenuCheckbox" label={cellProps.title ?? `<${cellProps.className}>`}
value ={!this.hiddenColumnNames.has(cellProps.className)} value={!this.isHiddenColumn(cellProps)}
onChange = {(v) => this.updateColumnFilter(v, cellProps.className)} onChange={isVisible => this.updateColumnVisibility(cellProps, isVisible)}
/> />
</MenuItem> </MenuItem>
)
))} ))}
</MenuActions> </MenuActions>
); );

View File

@ -34,14 +34,14 @@ export class PageFiltersStore {
namespaceStore.setContext(filteredNs); namespaceStore.setContext(filteredNs);
} }
}), }),
reaction(() => namespaceStore.contextNs.toJS(), contextNs => { namespaceStore.onContextChange(namespaces => {
const filteredNs = this.getValues(FilterType.NAMESPACE); const filteredNs = this.getValues(FilterType.NAMESPACE);
const isChanged = contextNs.length !== filteredNs.length; const isChanged = namespaces.length !== filteredNs.length;
if (isChanged) { if (isChanged) {
this.filters.replace([ this.filters.replace([
...this.filters.filter(({ type }) => type !== FilterType.NAMESPACE), ...this.filters.filter(({ type }) => type !== FilterType.NAMESPACE),
...contextNs.map(ns => ({ type: FilterType.NAMESPACE, value: ns })), ...namespaces.map(ns => ({ type: FilterType.NAMESPACE, value: ns })),
]); ]);
} }
}, { }, {

View File

@ -1,4 +0,0 @@
.MenuCheckbox {
width: 100%;
height: 100%;
}

View File

@ -1 +1 @@
export * from "./kube-object-status-icon"; export * from "./kube-object-status-icon";

View File

@ -1 +1 @@
export * from "./kubeconfig-dialog"; export * from "./kubeconfig-dialog";

View File

@ -46,4 +46,4 @@ describe("<MainLayoutHeader />", () => {
expect(getByText("minikube")).toBeInTheDocument(); expect(getByText("minikube")).toBeInTheDocument();
}); });
}); });

View File

@ -34,4 +34,4 @@ export class LoginLayout extends React.Component<Props> {
</section> </section>
); );
} }
} }

View File

@ -4,4 +4,4 @@ export const SidebarContext = React.createContext<SidebarContextValue>({ pinned:
export type SidebarContextValue = { export type SidebarContextValue = {
pinned: boolean; pinned: boolean;
}; };

View File

@ -1 +1 @@
export * from "./line-progress"; export * from "./line-progress";

View File

@ -1 +1 @@
export * from "./markdown-viewer"; export * from "./markdown-viewer";

View File

@ -34,4 +34,4 @@ export class MarkdownViewer extends Component<Props> {
/> />
); );
} }
} }

Some files were not shown because too many files have changed in this diff Show More