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

Merge branch 'master' into documentation-typedocs

# Conflicts:
#	mkdocs.yml
#	package.json
#	yarn.lock
This commit is contained in:
Mario Sarcher 2020-11-09 17:02:01 +01:00
commit 57c00e7298
103 changed files with 4042 additions and 2178 deletions

View File

@ -37,6 +37,10 @@ jobs:
displayName: Cache Yarn packages
- script: make install-deps
displayName: Install dependencies
- script: make build-npm
displayName: Generate npm package
- script: make build-extensions
displayName: Build bundled extensions
- script: make integration-win
displayName: Run integration tests
- script: make test-extensions
@ -76,6 +80,10 @@ jobs:
condition: eq(variables.CACHE_RESTORED, 'true')
- script: make install-deps
displayName: Install dependencies
- script: make build-npm
displayName: Generate npm package
- script: make build-extensions
displayName: Build bundled extensions
- script: make test
displayName: Run tests
- script: make integration-mac
@ -127,6 +135,10 @@ jobs:
displayName: Run In-tree Extension tests
- script: make lint
displayName: Lint
- script: make build-npm
displayName: Generate npm package
- script: make build-extensions
displayName: Build bundled extensions
- script: make test
displayName: Run tests
- bash: |

View File

@ -17,11 +17,11 @@ There are many sites where you can vote, recommend, favorite and star us.
Here are some nice blog posts and videos about our project for you to get some inspiration:
[Onboard AWS EKS Cluster on Lens(Kubernetes IDE)](https://dev.to/himwad05/onboard-aws-eks-cluster-on-lens-kubernetes-ide-492l)
[Using Lens to Manage All Your Kubernetes Cluster](https://medium.com/@magicmagnate/using-lens-to-manage-all-your-kubernetes-cluster-c1ef88fdb476)
[Kontena Lens - Beautiful Kubernetes UI](https://www.youtube.com/watch?v=YGgaiGdYfdI)
[Gerenciando Kubernetes com Lens e Octant](https://www.youtube.com/watch?v=h9ZqDelJLQQ)
[Walkthrough of Kubernetes IDE - Lens](https://www.youtube.com/watch?v=602aHZSdEfY)
[LENS - Interfaz Gráfica para Kubernetes en 1 PASO.](https://www.youtube.com/watch?v=DFMKcR4BqwM)
* [Onboard AWS EKS Cluster on Lens(Kubernetes IDE)](https://dev.to/himwad05/onboard-aws-eks-cluster-on-lens-kubernetes-ide-492l)
* [Using Lens to Manage All Your Kubernetes Cluster](https://medium.com/@magicmagnate/using-lens-to-manage-all-your-kubernetes-cluster-c1ef88fdb476)
* [Kontena Lens - Beautiful Kubernetes UI](https://www.youtube.com/watch?v=YGgaiGdYfdI)
* [Gerenciando Kubernetes com Lens e Octant](https://www.youtube.com/watch?v=h9ZqDelJLQQ)
* [Walkthrough of Kubernetes IDE - Lens](https://www.youtube.com/watch?v=602aHZSdEfY)
* [LENS - Interfaz Gráfica para Kubernetes en 1 PASO.](https://www.youtube.com/watch?v=DFMKcR4BqwM)
Psst... If you have created some content around Lens, let us know!

View File

@ -0,0 +1,20 @@
# Extension Development Overview
This is a general overview to how the development of an extension will procede. For building extensions there will be a few things that you should have installed, and some that might help.
### Required:
- [Node.js](https://www.nodejs.org/en/)
- [Git](https://www.git-scm.com/)
- Some sort of text editor, we recommend [VSCode](https://code.visualstudio.com/)
- We use [Webpack](https://www.webpack.js.org/) for compilation. All extension need to be at least compatable with a webpack system.
### Recommened:
-
All *Lens* extensions are javascript packages. We recommend that you program in [Typescript](https://www.typescriptlang.org/) because it catches quite a few easily to make errors around passing data around in javascript.
*Lens* is a standard [Electron](https://www.electronjs.org/) application which both main and renderer processes. An extension is made up of two parts, one for each of *Lens*'s core processes. When an extension is loaded each part is loaded and then notified that it has been loaded. From there the extension can start doing is work.
*Lens* uses [React](https://www.reactjs.org/) as it UI framework and even provides some of our own components for reuse by extensions. An extension is resonsible for the lifetime of any resources it spins up. If an extension's main part starts new processes they all must be stopped and cleaned up when the extension is deactivated or unloaded.
See [Your First Extension](your-first-extension.md) to get started.

View File

@ -1,6 +1,6 @@
# Your First Extension
In this topic, we'll teach you the fundamental concepts for building extensions. Make sure you have [Node.js](https://nodejs.org/en/) and [Git](https://git-scm.com/) installed....
In this topic, we'll teach you the fundamental concepts for building extensions.
## Installing and Building the extension
@ -11,7 +11,7 @@ Simple Lens extension that adds "Hello World" page to a cluster menu.
First you will need to clone the [Lens Extension samples](https://github.com/lensapp/lens-extension-samples) repository to your local machine:
```sh
git clone https://github.com/lensapp/lens-extension-samples.git
git clone https://github.com/lensapp/lens-extension-samples.git
```
Next you need to create a symlink from the directory that Lens will monitor for user installed extensions to the sample extension, in this case **helloworld-sample**:

View File

@ -410,6 +410,40 @@ describe("Lens integration tests", () => {
})
})
describe("viewing pod logs", () => {
beforeEach(appStartAddCluster, 40000)
afterEach(async () => {
if (app && app.isRunning()) {
return util.tearDown(app)
}
})
it(`shows a logs for a pod`, async () => {
expect(clusterAdded).toBe(true)
// Go to Pods page
await app.client.click(".sidebar-nav #workloads span.link-text")
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods")
await app.client.click('a[href^="/pods"]')
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver")
// Open logs tab in dock
await app.client.click(".list .TableRow:first-child")
await app.client.waitForVisible(".Drawer")
await app.client.click(".drawer-title .Menu li:nth-child(2)")
// Check if controls are available
await app.client.waitForVisible(".PodLogs .VirtualList")
await app.client.waitForVisible(".PodLogControls")
await app.client.waitForVisible(".PodLogControls .SearchInput")
await app.client.waitForVisible(".PodLogControls .SearchInput input")
// Search for semicolon
await app.client.keys(":")
await app.client.waitForVisible(".PodLogs .list span.active")
// Click through controls
await app.client.click(".PodLogControls .timestamps-icon")
await app.client.click(".PodLogControls .undo-icon")
})
})
describe("cluster operations", () => {
beforeEach(appStartAddCluster, 40000)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,13 @@ nav:
- Publishing Extensions: extensions/testing-and-publishing/publishing.md
- Bundling Extensions: extensions/testing-and-publishing/bundling.md
- API Reference: extensions/api/modules/_src_extensions_extension_api_.md
- Contributing: contributing/README.md
- Contributing:
- Overview: contributing/README.md
- Development: contributing/development.md
- Documentation: contributing/documentation.md
- Maintainers: contributing/maintainers.md
- Promotion: contributing/promotion.md
- FAQ: faq/README.md
theme:
name: 'material'

View File

@ -15,7 +15,7 @@
"dev-build": "concurrently yarn:compile:*",
"dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"",
"dev:main": "yarn compile:main --watch",
"dev:renderer": "yarn compile:renderer --watch",
"dev:renderer": "yarn webpack-dev-server --config webpack.renderer.ts",
"dev:extension-types": "yarn compile:extension-types --watch",
"compile": "env NODE_ENV=production concurrently yarn:compile:*",
"compile:main": "webpack --config webpack.main.ts",
@ -269,6 +269,7 @@
"@lingui/macro": "^3.0.0-13",
"@lingui/react": "^3.0.0-13",
"@material-ui/core": "^4.10.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
"@rollup/plugin-json": "^4.1.0",
"@testing-library/jest-dom": "^5.11.5",
"@testing-library/react": "^11.1.0",
@ -280,6 +281,7 @@
"@types/electron-window-state": "^2.0.34",
"@types/fs-extra": "^9.0.1",
"@types/hapi": "^18.0.3",
"@types/hard-source-webpack-plugin": "^1.0.1",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/html-webpack-plugin": "^3.2.3",
"@types/http-proxy": "^1.17.4",
@ -316,6 +318,7 @@
"@types/uuid": "^8.0.0",
"@types/webdriverio": "^4.13.0",
"@types/webpack": "^4.41.17",
"@types/webpack-dev-server": "^3.11.1",
"@types/webpack-env": "^1.15.2",
"@types/webpack-node-externals": "^1.7.1",
"@typescript-eslint/eslint-plugin": "^4.0.0",
@ -340,6 +343,7 @@
"file-loader": "^6.0.0",
"flex.box": "^3.4.4",
"fork-ts-checker-webpack-plugin": "^5.0.0",
"hard-source-webpack-plugin": "^0.13.1",
"hoist-non-react-statics": "^3.3.2",
"html-webpack-plugin": "^4.3.0",
"identity-obj-proxy": "^3.0.0",
@ -361,6 +365,7 @@
"react": "^16.14.0",
"react-beautiful-dnd": "^13.0.0",
"react-dom": "^16.13.1",
"react-refresh": "^0.9.0",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-select": "^3.1.0",
@ -379,11 +384,13 @@
"ts-node": "^8.10.2",
"typedoc": "^0.19.2",
"typedoc-plugin-markdown": "^3.0.11",
"type-fest": "^0.18.0",
"typeface-roboto": "^0.0.75",
"typescript": "^4.0.2",
"url-loader": "^4.1.0",
"webpack": "^4.43.0",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.11.0",
"webpack-node-externals": "^1.7.2",
"xterm": "^4.6.0",
"xterm-addon-fit": "^0.4.0"

View File

@ -0,0 +1,80 @@
/**
* @jest-environment jsdom
*/
import { SearchStore } from "../search-store"
let searchStore: SearchStore = null;
const logs = [
"1:M 30 Oct 2020 16:17:41.553 # Connection with replica 172.17.0.12:6379 lost",
"1:M 30 Oct 2020 16:17:41.623 * Replica 172.17.0.12:6379 asks for synchronization",
"1:M 30 Oct 2020 16:17:41.623 * Starting Partial resynchronization request from 172.17.0.12:6379 accepted. Sending 0 bytes of backlog starting from offset 14407."
]
describe("search store tests", () => {
beforeEach(async () => {
searchStore = new SearchStore();
})
it("does nothing with empty search query", () => {
searchStore.onSearch([], "");
expect(searchStore.occurrences).toEqual([]);
})
it("doesn't break if no text provided", () => {
searchStore.onSearch(null, "replica");
expect(searchStore.occurrences).toEqual([]);
searchStore.onSearch([], "replica");
expect(searchStore.occurrences).toEqual([]);
})
it("find 3 occurences across 3 lines", () => {
searchStore.onSearch(logs, "172");
expect(searchStore.occurrences).toEqual([0, 1, 2]);
})
it("find occurences within 1 line (case-insensitive)", () => {
searchStore.onSearch(logs, "Starting");
expect(searchStore.occurrences).toEqual([2, 2]);
})
it("sets overlay index equal to first occurence", () => {
searchStore.onSearch(logs, "Replica");
expect(searchStore.activeOverlayIndex).toBe(0);
})
it("set overlay index to next occurence", () => {
searchStore.onSearch(logs, "172");
searchStore.setNextOverlayActive();
expect(searchStore.activeOverlayIndex).toBe(1);
})
it("sets overlay to last occurence", () => {
searchStore.onSearch(logs, "172");
searchStore.setPrevOverlayActive();
expect(searchStore.activeOverlayIndex).toBe(2);
})
it("gets line index where overlay is located", () => {
searchStore.onSearch(logs, "synchronization");
expect(searchStore.activeOverlayLine).toBe(1);
})
it("escapes string for using in regex", () => {
const regex = searchStore.escapeRegex("some.interesting-query\\#?()[]");
expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]");
})
it("gets active find number", () => {
searchStore.onSearch(logs, "172");
searchStore.setNextOverlayActive();
expect(searchStore.activeFind).toBe(2);
})
it("gets total finds number", () => {
searchStore.onSearch(logs, "Starting");
expect(searchStore.totalFinds).toBe(2);
})
})

View File

@ -39,6 +39,7 @@ export interface ClusterModel {
preferences?: ClusterPreferences;
metadata?: ClusterMetadata;
ownerRef?: string;
accessibleNamespaces?: string[];
/** @deprecated */
kubeConfig?: string; // yaml
@ -179,8 +180,8 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
@action
addCluster(model: ClusterModel | Cluster ): Cluster {
appEventBus.emit({name: "cluster", action: "add"})
addCluster(model: ClusterModel | Cluster): Cluster {
appEventBus.emit({ name: "cluster", action: "add" })
let cluster = model as Cluster;
if (!(model instanceof Cluster)) {
cluster = new Cluster(model)
@ -195,7 +196,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@action
async removeById(clusterId: ClusterId) {
appEventBus.emit({name: "cluster", action: "remove"})
appEventBus.emit({ name: "cluster", action: "remove" })
const cluster = this.getById(clusterId);
if (cluster) {
this.clusters.delete(clusterId);

126
src/common/search-store.ts Normal file
View File

@ -0,0 +1,126 @@
import { action, computed, observable } from "mobx";
import { autobind } from "../renderer/utils";
export class SearchStore {
@observable searchQuery = ""; // Text in the search input
@observable occurrences: number[] = []; // Array with line numbers, eg [0, 0, 10, 21, 21, 40...]
@observable activeOverlayIndex = -1; // Index withing the occurences array. Showing where is activeOverlay currently located
/**
* Sets default activeOverlayIndex
* @param text An array of any textual data (logs, for example)
* @param query Search query from input
*/
@action
onSearch(text: string[], query = this.searchQuery) {
this.searchQuery = query;
if (!query) {
this.reset();
return;
}
this.occurrences = this.findOccurences(text, query);
if (!this.occurrences.length) return;
// If new highlighted keyword in exact same place as previous one, then no changing in active overlay
if (this.occurrences[this.activeOverlayIndex] !== undefined) return;
this.activeOverlayIndex = this.getNextOverlay(true);
}
/**
* Does searching within text array, create a list of search keyword occurences.
* Each keyword "occurency" is saved as index of the the line where keyword founded
* @param text An array of any textual data (logs, for example)
* @param query Search query from input
* @returns {Array} Array of line indexes [0, 0, 14, 17, 17, 17, 20...]
*/
findOccurences(text: string[], query: string) {
if (!text) return [];
const occurences: number[] = [];
text.forEach((line, index) => {
const regex = new RegExp(this.escapeRegex(query), "gi");
const matches = [...line.matchAll(regex)];
matches.forEach(() => occurences.push(index));
});
return occurences;
}
/**
* Getting next overlay index within the occurences array
* @param loopOver Allows to jump from last element to first
* @returns {number} next overlay index
*/
getNextOverlay(loopOver = false) {
const next = this.activeOverlayIndex + 1;
if (next > this.occurrences.length - 1) {
return loopOver ? 0 : this.activeOverlayIndex;
}
return next;
}
/**
* Getting previous overlay index within the occurences array of occurences
* @param loopOver Allows to jump from first element to last one
* @returns {number} prev overlay index
*/
getPrevOverlay(loopOver = false) {
const prev = this.activeOverlayIndex - 1;
if (prev < 0) {
return loopOver ? this.occurrences.length - 1 : this.activeOverlayIndex;
}
return prev;
}
@autobind()
setNextOverlayActive() {
this.activeOverlayIndex = this.getNextOverlay(true);
}
@autobind()
setPrevOverlayActive() {
this.activeOverlayIndex = this.getPrevOverlay(true);
}
/**
* Gets line index of where active overlay is located
* @returns {number} A line index within the text/logs array
*/
@computed get activeOverlayLine(): number {
return this.occurrences[this.activeOverlayIndex];
}
@computed get activeFind(): number {
return this.activeOverlayIndex + 1;
}
@computed get totalFinds(): number {
return this.occurrences.length;
}
/**
* Checks if overlay is active (to highlight it with orange background usually)
* @param line Index of the line where overlay is located
* @param occurence Number of the overlay within one line
*/
@autobind()
isActiveOverlay(line: number, occurence: number) {
const firstLineIndex = this.occurrences.findIndex(item => item === line);
return firstLineIndex + occurence === this.activeOverlayIndex;
}
/**
* An utility methods escaping user string to safely pass it into new Regex(variable)
* @param value Unescaped string
*/
escapeRegex(value: string) {
return value.replace( /[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&" );
}
@action
reset() {
this.searchQuery = "";
this.activeOverlayIndex = -1;
this.occurrences = [];
}
}
export const searchStore = new SearchStore;

View File

@ -22,6 +22,7 @@ export const mainDir = path.join(contextDir, "src/main");
export const rendererDir = path.join(contextDir, "src/renderer");
export const htmlTemplate = path.resolve(rendererDir, "template.html");
export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
export const webpackDevServerPort = 9009
// Special runtime paths
defineGlobal("__static", {

View File

@ -1,23 +1,38 @@
// TODO: add more common re-usable UI components + refactor interfaces (Props -> ComponentProps)
// Common UI components
export * from "../../renderer/components/icon"
export * from "../../renderer/components/checkbox"
export * from "../../renderer/components/tooltip"
export * from "../../renderer/components/button"
export * from "../../renderer/components/tabs"
export * from "../../renderer/components/badge"
// layouts
export * from "../../renderer/components/layout/page-layout"
export * from "../../renderer/components/layout/wizard-layout"
export * from "../../renderer/components/layout/tab-layout"
// form-controls
export * from "../../renderer/components/button"
export * from "../../renderer/components/checkbox"
export * from "../../renderer/components/radio"
export * from "../../renderer/components/select"
export * from "../../renderer/components/slider"
export * from "../../renderer/components/input/input"
// other components
export * from "../../renderer/components/icon"
export * from "../../renderer/components/tooltip"
export * from "../../renderer/components/tabs"
export * from "../../renderer/components/table"
export * from "../../renderer/components/badge"
export * from "../../renderer/components/drawer"
export * from "../../renderer/components/dialog"
export * from "../../renderer/components/confirm-dialog";
export * from "../../renderer/components/line-progress"
export * from "../../renderer/components/menu"
export * from "../../renderer/components/notifications"
export * from "../../renderer/components/spinner"
export * from "../../renderer/components/stepper"
// kube helpers
export { KubeObjectDetailsProps, KubeObjectMenuProps } from "../../renderer/components/kube-object"
export { KubeObjectMeta } from "../../renderer/components/kube-object/kube-object-meta"
export { KubeObjectListLayout, KubeObjectListLayoutProps } from "../../renderer/components/kube-object/kube-object-list-layout";
export { KubeEventDetails } from "../../renderer/components/+events/kube-event-details"
export * from "../../renderer/components/kube-object"
export * from "../../renderer/components/+events/kube-event-details"
// specific exports
export { ConfirmDialog } from "../../renderer/components/confirm-dialog";
export { MenuItem, SubMenu } from "../../renderer/components/menu";
export { StatusBrick } from "../../renderer/components/status-brick";
export * from "../../renderer/components/status-brick";
export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store";
export { createPodLogsTab } from "../../renderer/components/dock/pod-logs.store";

View File

@ -80,13 +80,14 @@ export class Cluster implements ClusterModel, ClusterState {
@observable metadata: ClusterMetadata = {};
@observable allowedNamespaces: string[] = [];
@observable allowedResources: string[] = [];
@observable accessibleNamespaces: string[] = [];
@computed get available() {
return this.accessible && !this.disconnected;
}
get version(): string {
return String(this.metadata?.version) || ""
return String(this.metadata?.version) || ""
}
constructor(model: ClusterModel) {
@ -149,7 +150,7 @@ export class Cluster implements ClusterModel, ClusterState {
}
@action
async activate(force = false ) {
async activate(force = false) {
if (this.activated && !force) {
return this.pushState();
}
@ -340,7 +341,7 @@ export class Cluster implements ClusterModel, ClusterState {
for (const w of warnings) {
if (w.involvedObject.kind === 'Pod') {
try {
const pod = (await client.readNamespacedPod(w.involvedObject.name, w.involvedObject.namespace)).body;
const { body: pod } = await client.readNamespacedPod(w.involvedObject.name, w.involvedObject.namespace);
logger.debug(`checking pod ${w.involvedObject.namespace}/${w.involvedObject.name}`)
if (podHasIssues(pod)) {
uniqEventSources.add(w.involvedObject.uid);
@ -351,11 +352,10 @@ export class Cluster implements ClusterModel, ClusterState {
uniqEventSources.add(w.involvedObject.uid);
}
}
let nodeNotificationCount = 0;
const nodes = (await client.listNode()).body.items;
nodes.map(n => {
nodeNotificationCount = nodeNotificationCount + getNodeWarningConditions(n).length
});
const nodeNotificationCount = nodes
.map(getNodeWarningConditions)
.reduce((sum, conditions) => sum + conditions.length, 0);
return uniqEventSources.size + nodeNotificationCount;
} catch (error) {
logger.error("Failed to fetch event count: " + JSON.stringify(error))
@ -371,7 +371,8 @@ export class Cluster implements ClusterModel, ClusterState {
workspace: this.workspace,
preferences: this.preferences,
metadata: this.metadata,
ownerRef: this.ownerRef
ownerRef: this.ownerRef,
accessibleNamespaces: this.accessibleNamespaces,
};
return toJS(model, {
recurseEverything: true
@ -426,6 +427,10 @@ export class Cluster implements ClusterModel, ClusterState {
}
protected async getAllowedNamespaces() {
if (this.accessibleNamespaces.length) {
return this.accessibleNamespaces
}
const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api)
try {
const namespaceList = await api.listNamespace()
@ -442,7 +447,7 @@ export class Cluster implements ClusterModel, ClusterState {
} catch (error) {
const ctx = this.getProxyKubeconfig().getContextObject(this.contextName)
if (ctx.namespace) return [ctx.namespace]
return []
return [];
}
}

View File

@ -4,7 +4,7 @@ import http from "http"
import path from "path"
import { readFile } from "fs-extra"
import { Cluster } from "./cluster"
import { apiPrefix, appName, publicPath } from "../common/vars";
import { apiPrefix, appName, publicPath, isDevelopment, webpackDevServerPort } from "../common/vars";
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes";
export interface RouterRequestOpts {
@ -94,23 +94,35 @@ export class Router {
return mimeTypes[path.extname(filename).slice(1)] || "text/plain"
}
async handleStaticFile(filePath: string, res: http.ServerResponse) {
async handleStaticFile(filePath: string, res: http.ServerResponse, req: http.IncomingMessage) {
const asset = path.join(__static, filePath);
try {
const filename = path.basename(req.url);
// redirect requests to [appName].js, [appName].html /sockjs-node/ to webpack-dev-server (for hot-reload support)
const toWebpackDevServer = filename.includes(appName) || filename.includes('hot-update') || req.url.includes('sockjs-node');
if (isDevelopment && toWebpackDevServer) {
const redirectLocation = `http://localhost:${webpackDevServerPort}` + req.url;
res.statusCode = 307;
res.setHeader('Location', redirectLocation);
res.end();
return;
}
const data = await readFile(asset);
res.setHeader("Content-Type", this.getMimeType(asset));
res.write(data)
res.end()
res.write(data);
res.end();
} catch (err) {
this.handleStaticFile(`${publicPath}/${appName}.html`, res);
this.handleStaticFile(`${publicPath}/${appName}.html`, res, req);
}
}
protected addRoutes() {
// Static assets
this.router.add({ method: 'get', path: '/{path*}' }, ({ params, response }: LensApiRequest) => {
this.handleStaticFile(params.path, response);
});
this.router.add(
{ method: 'get', path: '/{path*}' },
({ params, response, path, raw: { req }}: LensApiRequest) => {
this.handleStaticFile(params.path, response, req);
});
this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute))

View File

@ -1,22 +1,13 @@
import type { KubeObjectStore } from "../kube-object.store";
import type { KubeObjectDetailsProps, KubeObjectListLayoutProps, KubeObjectMenuProps } from "../components/kube-object";
import type React from "react";
import { observable } from "mobx";
import { action, observable } from "mobx";
import { autobind } from "../utils";
import { KubeApi } from "./kube-api";
export interface ApiComponents {
List?: React.ComponentType<KubeObjectListLayoutProps>;
Menu?: React.ComponentType<KubeObjectMenuProps>;
Details?: React.ComponentType<KubeObjectDetailsProps>;
}
@autobind()
export class ApiManager {
private apis = observable.map<string, KubeApi>();
private stores = observable.map<KubeApi, KubeObjectStore>();
private views = observable.map<KubeApi, ApiComponents>();
getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) {
if (typeof pathOrCallback === "string") {
@ -46,8 +37,11 @@ export class ApiManager {
}
}
registerStore(api: KubeApi, store: KubeObjectStore) {
this.stores.set(api, store);
@action
registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) {
apis.forEach(api => {
this.stores.set(api, store);
})
}
getStore(api: string | KubeApi): KubeObjectStore {

View File

@ -9,7 +9,6 @@ import { kubeWatchApi } from "./kube-watch-api";
import { apiManager } from "./api-manager";
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
import { apiKubePrefix, isDevelopment } from "../../common/vars";
import * as URL from "url"
export interface IKubeApiOptions<T extends KubeObject> {
apiBase?: string; // base api-path for listing all resources, e.g. "/api/v1/pods"

View File

@ -11,7 +11,7 @@ import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { SearchInput } from "../input";
import { SearchInputUrl } from "../input";
enum sortBy {
name = "name",
@ -72,7 +72,7 @@ export class HelmCharts extends Component<Props> {
(items: HelmChart[]) => items.filter(item => !item.deprecated)
]}
customizeHeader={() => (
<SearchInput placeholder={_i18n._(t`Search Helm Charts`)} />
<SearchInputUrl placeholder={_i18n._(t`Search Helm Charts`)} />
)}
renderTableHeader={[
{ className: "icon" },

View File

@ -0,0 +1,38 @@
import React from "react";
import { observer } from "mobx-react";
import { Cluster } from "../../../../main/cluster";
import { SubTitle } from "../../layout/sub-title";
import { EditableList } from "../../editable-list";
import { observable } from "mobx";
import { _i18n } from "../../../i18n";
import { Trans } from "@lingui/macro";
interface Props {
cluster: Cluster;
}
@observer
export class ClusterAccessibleNamespaces extends React.Component<Props> {
@observable namespaces = new Set(this.props.cluster.accessibleNamespaces);
render() {
return (
<>
<SubTitle title="Accessible Namespaces" />
<p><Trans>This setting is useful for manually specifying which namespaces you have access to. This is useful when you don't have permissions to list namespaces.</Trans></p>
<EditableList
placeholder={_i18n._("Add new namespace...")}
add={(newNamespace) => {
this.namespaces.add(newNamespace);
this.props.cluster.accessibleNamespaces = Array.from(this.namespaces);
}}
items={Array.from(this.namespaces)}
remove={({ oldItem: oldNamesapce }) => {
this.namespaces.delete(oldNamesapce);
this.props.cluster.accessibleNamespaces = Array.from(this.namespaces);
}}
/>
</>
);
}
}

View File

@ -6,6 +6,7 @@ import { ClusterIconSetting } from "./components/cluster-icon-setting";
import { ClusterProxySetting } from "./components/cluster-proxy-setting";
import { ClusterPrometheusSetting } from "./components/cluster-prometheus-setting";
import { ClusterHomeDirSetting } from "./components/cluster-home-dir-setting";
import { ClusterAccessibleNamespaces } from "./components/cluster-accessible-namespaces";
interface Props {
cluster: Cluster;
@ -21,6 +22,7 @@ export class General extends React.Component<Props> {
<ClusterProxySetting cluster={this.props.cluster} />
<ClusterPrometheusSetting cluster={this.props.cluster} />
<ClusterHomeDirSetting cluster={this.props.cluster} />
<ClusterAccessibleNamespaces cluster={this.props.cluster} />
</div>;
}
}

View File

@ -107,4 +107,4 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
}
export const clusterStore = new ClusterStore();
apiManager.registerStore(clusterApi, clusterStore);
apiManager.registerStore(clusterStore);

View File

@ -9,4 +9,4 @@ export class HPAStore extends KubeObjectStore<HorizontalPodAutoscaler> {
}
export const hpaStore = new HPAStore();
apiManager.registerStore(hpaApi, hpaStore);
apiManager.registerStore(hpaStore);

View File

@ -9,4 +9,4 @@ export class ConfigMapsStore extends KubeObjectStore<ConfigMap> {
}
export const configMapsStore = new ConfigMapsStore();
apiManager.registerStore(configMapApi, configMapsStore);
apiManager.registerStore(configMapsStore);

View File

@ -9,4 +9,4 @@ export class PodDisruptionBudgetsStore extends KubeObjectStore<PodDisruptionBudg
}
export const podDisruptionBudgetsStore = new PodDisruptionBudgetsStore();
apiManager.registerStore(pdbApi, podDisruptionBudgetsStore);
apiManager.registerStore(podDisruptionBudgetsStore);

View File

@ -9,4 +9,4 @@ export class ResourceQuotasStore extends KubeObjectStore<ResourceQuota> {
}
export const resourceQuotaStore = new ResourceQuotasStore();
apiManager.registerStore(resourceQuotaApi, resourceQuotaStore);
apiManager.registerStore(resourceQuotaStore);

View File

@ -9,4 +9,4 @@ export class SecretsStore extends KubeObjectStore<Secret> {
}
export const secretsStore = new SecretsStore();
apiManager.registerStore(secretsApi, secretsStore);
apiManager.registerStore(secretsStore);

View File

@ -10,7 +10,7 @@ import { KubeObject } from "../../api/kube-object";
import { ICRDRouteParams } from "./crd.route";
import { autorun, computed } from "mobx";
import { crdStore } from "./crd.store";
import { SortingCallback } from "../table";
import { TableSortCallback } from "../table";
import { apiManager } from "../../api/api-manager";
interface Props extends RouteComponentProps<ICRDRouteParams> {
@ -50,7 +50,7 @@ export class CrdResources extends React.Component<Props> {
if (!crd) return null;
const isNamespaced = crd.isNamespaced();
const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details
const sortingCallbacks: { [sortBy: string]: SortingCallback } = {
const sortingCallbacks: { [sortBy: string]: TableSortCallback } = {
[sortBy.name]: (item: KubeObject) => item.getName(),
[sortBy.namespace]: (item: KubeObject) => item.getNs(),
[sortBy.age]: (item: KubeObject) => item.metadata.creationTimestamp,

View File

@ -14,7 +14,7 @@ function initStore(crd: CustomResourceDefinition) {
const api = apiManager.getApi(apiBase) || new KubeApi({ apiBase, kind, isNamespaced });
if (!apiManager.getStore(api)) {
apiManager.registerStore(api, new CRDResourceStore(api));
apiManager.registerStore(new CRDResourceStore(api));
}
}
@ -64,4 +64,4 @@ export class CRDStore extends KubeObjectStore<CustomResourceDefinition> {
export const crdStore = new CRDStore();
apiManager.registerStore(crdApi, crdStore);
apiManager.registerStore(crdStore);

View File

@ -49,4 +49,4 @@ export class EventStore extends KubeObjectStore<KubeEvent> {
}
export const eventStore = new EventStore();
apiManager.registerStore(eventApi, eventStore);
apiManager.registerStore(eventStore);

View File

@ -6,15 +6,14 @@ import { Trans } from "@lingui/macro";
import { KubeObject } from "../../api/kube-object";
import { DrawerItem, DrawerTitle } from "../drawer";
import { cssNames } from "../../utils";
import { Icon } from "../icon";
import { eventStore } from "./event.store";
interface Props {
export interface KubeEventDetailsProps {
object: KubeObject;
}
@observer
export class KubeEventDetails extends React.Component<Props> {
export class KubeEventDetails extends React.Component<KubeEventDetailsProps> {
async componentDidMount() {
eventStore.loadAll();
}

View File

@ -95,4 +95,4 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
}
export const namespaceStore = new NamespaceStore();
apiManager.registerStore(namespacesApi, namespaceStore);
apiManager.registerStore(namespaceStore);

View File

@ -9,4 +9,4 @@ export class EndpointStore extends KubeObjectStore<Endpoint> {
}
export const endpointStore = new EndpointStore();
apiManager.registerStore(endpointApi, endpointStore);
apiManager.registerStore(endpointStore);

View File

@ -19,4 +19,4 @@ export class IngressStore extends KubeObjectStore<Ingress> {
}
export const ingressStore = new IngressStore();
apiManager.registerStore(ingressApi, ingressStore);
apiManager.registerStore(ingressStore);

View File

@ -9,4 +9,4 @@ export class NetworkPolicyStore extends KubeObjectStore<NetworkPolicy> {
}
export const networkPolicyStore = new NetworkPolicyStore();
apiManager.registerStore(networkPolicyApi, networkPolicyStore);
apiManager.registerStore(networkPolicyStore);

View File

@ -9,4 +9,4 @@ export class ServiceStore extends KubeObjectStore<Service> {
}
export const serviceStore = new ServiceStore();
apiManager.registerStore(serviceApi, serviceStore);
apiManager.registerStore(serviceStore);

View File

@ -69,4 +69,4 @@ export class NodesStore extends KubeObjectStore<Node> {
}
export const nodesStore = new NodesStore()
apiManager.registerStore(nodesApi, nodesStore);
apiManager.registerStore(nodesStore);

View File

@ -9,4 +9,4 @@ export class PodSecurityPoliciesStore extends KubeObjectStore<PodSecurityPolicy>
}
export const podSecurityPoliciesStore = new PodSecurityPoliciesStore()
apiManager.registerStore(pspApi, podSecurityPoliciesStore);
apiManager.registerStore(podSecurityPoliciesStore);

View File

@ -9,4 +9,4 @@ export class StorageClassStore extends KubeObjectStore<StorageClass> {
}
export const storageClassStore = new StorageClassStore();
apiManager.registerStore(storageClassApi, storageClassStore);
apiManager.registerStore(storageClassStore);

View File

@ -20,4 +20,4 @@ export class VolumeClaimStore extends KubeObjectStore<PersistentVolumeClaim> {
}
export const volumeClaimStore = new VolumeClaimStore();
apiManager.registerStore(pvcApi, volumeClaimStore);
apiManager.registerStore(volumeClaimStore);

View File

@ -9,4 +9,4 @@ export class PersistentVolumesStore extends KubeObjectStore<PersistentVolume> {
}
export const volumesStore = new PersistentVolumesStore();
apiManager.registerStore(persistentVolumeApi, volumesStore);
apiManager.registerStore(volumesStore);

View File

@ -30,8 +30,7 @@ export class RoleBindingsStore extends KubeObjectStore<RoleBinding> {
return Promise.all(
namespaces.map(namespace => roleBindingApi.list({ namespace }))
).then(items => items.flat())
}
else {
} else {
return Promise.all([clusterRoleBindingApi.list(), roleBindingApi.list()])
.then(items => items.flat())
}
@ -40,8 +39,7 @@ export class RoleBindingsStore extends KubeObjectStore<RoleBinding> {
protected async createItem(params: { name: string; namespace?: string }, data?: Partial<RoleBinding>) {
if (params.namespace) {
return roleBindingApi.create(params, data)
}
else {
} else {
return clusterRoleBindingApi.create(params, data)
}
}
@ -58,8 +56,7 @@ export class RoleBindingsStore extends KubeObjectStore<RoleBinding> {
newSubjects = uniqBy(currentSubjects.concat(addSubjects), ({ kind, name, namespace }) => {
return [kind, name, namespace].join("-");
})
}
else if (removeSubjects) {
} else if (removeSubjects) {
newSubjects = difference(currentSubjects, removeSubjects);
}
return this.update(roleBinding, {
@ -71,5 +68,7 @@ export class RoleBindingsStore extends KubeObjectStore<RoleBinding> {
export const roleBindingsStore = new RoleBindingsStore();
apiManager.registerStore(roleBindingApi, roleBindingsStore);
apiManager.registerStore(clusterRoleBindingApi, roleBindingsStore);
apiManager.registerStore(roleBindingsStore, [
roleBindingApi,
clusterRoleBindingApi,
]);

View File

@ -28,8 +28,7 @@ export class RolesStore extends KubeObjectStore<Role> {
return Promise.all(
namespaces.map(namespace => roleApi.list({ namespace }))
).then(items => items.flat())
}
else {
} else {
return Promise.all([clusterRoleApi.list(), roleApi.list()])
.then(items => items.flat())
}
@ -38,8 +37,7 @@ export class RolesStore extends KubeObjectStore<Role> {
protected async createItem(params: { name: string; namespace?: string }, data?: Partial<Role>) {
if (params.namespace) {
return roleApi.create(params, data)
}
else {
} else {
return clusterRoleApi.create(params, data)
}
}
@ -47,5 +45,7 @@ export class RolesStore extends KubeObjectStore<Role> {
export const rolesStore = new RolesStore();
apiManager.registerStore(roleApi, rolesStore);
apiManager.registerStore(clusterRoleApi, rolesStore);
apiManager.registerStore(rolesStore, [
roleApi,
clusterRoleApi,
]);

View File

@ -14,4 +14,4 @@ export class ServiceAccountsStore extends KubeObjectStore<ServiceAccount> {
}
export const serviceAccountsStore = new ServiceAccountsStore();
apiManager.registerStore(serviceAccountsApi, serviceAccountsStore);
apiManager.registerStore(serviceAccountsStore);

View File

@ -30,4 +30,4 @@ export class CronJobStore extends KubeObjectStore<CronJob> {
}
export const cronJobStore = new CronJobStore();
apiManager.registerStore(cronJobApi, cronJobStore);
apiManager.registerStore(cronJobStore);

View File

@ -43,4 +43,4 @@ export class DaemonSetStore extends KubeObjectStore<DaemonSet> {
}
export const daemonSetStore = new DaemonSetStore();
apiManager.registerStore(daemonSetApi, daemonSetStore);
apiManager.registerStore(daemonSetStore);

View File

@ -50,4 +50,4 @@ export class DeploymentStore extends KubeObjectStore<Deployment> {
}
export const deploymentStore = new DeploymentStore();
apiManager.registerStore(deploymentApi, deploymentStore);
apiManager.registerStore(deploymentStore);

View File

@ -42,4 +42,4 @@ export class JobStore extends KubeObjectStore<Job> {
}
export const jobStore = new JobStore();
apiManager.registerStore(jobApi, jobStore);
apiManager.registerStore(jobStore);

View File

@ -76,4 +76,4 @@ export class PodsStore extends KubeObjectStore<Pod> {
}
export const podsStore = new PodsStore();
apiManager.registerStore(podsApi, podsStore);
apiManager.registerStore(podsStore);

View File

@ -31,4 +31,4 @@ export class ReplicaSetStore extends KubeObjectStore<ReplicaSet> {
}
export const replicaSetStore = new ReplicaSetStore();
apiManager.registerStore(replicaSetApi, replicaSetStore);
apiManager.registerStore(replicaSetStore);

View File

@ -42,4 +42,4 @@ export class StatefulSetStore extends KubeObjectStore<StatefulSet> {
}
export const statefulSetStore = new StatefulSetStore();
apiManager.registerStore(statefulSetApi, statefulSetStore);
apiManager.registerStore(statefulSetStore);

View File

@ -12,7 +12,7 @@ import { Icon } from "../icon";
import { Input } from "../input";
import { cssNames, prevDefault } from "../../utils";
import { Button } from "../button";
import { isRequired, Validator } from "../input/input_validators";
import { isRequired, InputValidator } from "../input/input_validators";
@observer
export class Workspaces extends React.Component {
@ -122,7 +122,7 @@ export class Workspaces extends React.Component {
editing: isEditing,
default: isDefault,
});
const existenceValidator: Validator = {
const existenceValidator: InputValidator = {
message: () => `Workspace '${name}' already exists`,
validate: value => !workspaceStore.getByName(value.trim())
}

View File

@ -4,13 +4,13 @@ import React from "react";
import { cssNames } from "../../utils/cssNames";
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
interface Props extends React.HTMLAttributes<any>, TooltipDecoratorProps {
export interface BadgeProps extends React.HTMLAttributes<any>, TooltipDecoratorProps {
small?: boolean;
label?: React.ReactNode;
}
@withTooltip
export class Badge extends React.Component<Props> {
export class Badge extends React.Component<BadgeProps> {
render() {
const { className, label, small, children, ...elemProps } = this.props;
return <>

View File

@ -2,7 +2,7 @@ import './checkbox.scss'
import React from 'react'
import { autobind, cssNames } from "../../utils";
interface Props<T = boolean> {
export interface CheckboxProps<T = boolean> {
theme?: "dark" | "light";
className?: string;
label?: React.ReactNode;
@ -12,7 +12,7 @@ interface Props<T = boolean> {
onChange?(value: T, evt: React.ChangeEvent<HTMLInputElement>): void;
}
export class Checkbox extends React.PureComponent<Props> {
export class Checkbox extends React.PureComponent<CheckboxProps> {
private input: HTMLInputElement;
@autobind()

View File

@ -9,7 +9,10 @@ import { Button, ButtonProps } from "../button";
import { Dialog, DialogProps } from "../dialog";
import { Icon } from "../icon";
export interface IConfirmDialogParams {
export interface ConfirmDialogProps extends Partial<DialogProps> {
}
export interface ConfirmDialogParams {
ok?: () => void;
labelOk?: ReactNode;
labelCancel?: ReactNode;
@ -19,17 +22,14 @@ export interface IConfirmDialogParams {
cancelButtonProps?: Partial<ButtonProps>
}
interface Props extends Partial<DialogProps> {
}
@observer
export class ConfirmDialog extends React.Component<Props> {
export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
@observable static isOpen = false;
@observable.ref static params: IConfirmDialogParams;
@observable.ref static params: ConfirmDialogParams;
@observable isSaving = false;
static open(params: IConfirmDialogParams) {
static open(params: ConfirmDialogParams) {
ConfirmDialog.isOpen = true;
ConfirmDialog.params = params;
}
@ -38,14 +38,14 @@ export class ConfirmDialog extends React.Component<Props> {
ConfirmDialog.isOpen = false;
}
public defaultParams: IConfirmDialogParams = {
public defaultParams: ConfirmDialogParams = {
ok: noop,
labelOk: <Trans>Ok</Trans>,
labelCancel: <Trans>Cancel</Trans>,
icon: <Icon big material="warning"/>,
};
get params(): IConfirmDialogParams {
get params(): ConfirmDialogParams {
return Object.assign({}, this.defaultParams, ConfirmDialog.params);
}

View File

@ -8,22 +8,26 @@ import { Icon } from "../icon";
import { _i18n } from "../../i18n";
import { cssNames, downloadFile } from "../../utils";
import { Pod } from "../../api/endpoints";
import { PodLogSearch, PodLogSearchProps } from "./pod-log-search";
interface Props {
interface Props extends PodLogSearchProps {
ready: boolean
tabId: string
tabData: IPodLogsData
logs: string[][]
logs: string[]
save: (data: Partial<IPodLogsData>) => void
reload: () => void
onSearch: (query: string) => void
}
export const PodLogControls = observer((props: Props) => {
if (!props.ready) return null;
const { tabData, tabId, save, reload, logs } = props;
const { tabData, save, reload, tabId, logs } = props;
const { selectedContainer, showTimestamps, previous } = tabData;
const since = podLogsStore.getTimestamps(podLogsStore.logs.get(tabId)[0]);
const rawLogs = podLogsStore.logs.get(tabId);
const since = rawLogs.length ? podLogsStore.getTimestamps(rawLogs[0]) : null;
const pod = new Pod(tabData.pod);
const toggleTimestamps = () => {
save({ showTimestamps: !showTimestamps });
}
@ -35,8 +39,7 @@ export const PodLogControls = observer((props: Props) => {
const downloadLogs = () => {
const fileName = selectedContainer ? selectedContainer.name : pod.getName();
const [oldLogs, newLogs] = logs;
downloadFile(fileName + ".log", [...oldLogs, ...newLogs].join("\n"), "text/plain");
downloadFile(fileName + ".log", logs.join("\n"), "text/plain");
}
const onContainerChange = (option: SelectOption) => {
@ -92,7 +95,7 @@ export const PodLogControls = observer((props: Props) => {
</>
)}
</div>
<div className="flex gaps">
<div className="flex box grow gaps align-center">
<Icon
material="av_timer"
onClick={toggleTimestamps}
@ -109,7 +112,9 @@ export const PodLogControls = observer((props: Props) => {
material="get_app"
onClick={downloadLogs}
tooltip={_i18n._(t`Save`)}
className="download-icon"
/>
<PodLogSearch {...props} />
</div>
</div>
);

View File

@ -0,0 +1,10 @@
.PodLogsSearch {
.SearchInput {
min-width: 150px;
width: 150px;
.find-count {
margin-left: 2px;
}
}
}

View File

@ -0,0 +1,87 @@
import "./pod-log-search.scss";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { SearchInput } from "../input";
import { searchStore } from "../../../common/search-store";
import { Icon } from "../icon";
import { _i18n } from "../../i18n";
import { t } from "@lingui/macro";
export interface PodLogSearchProps {
onSearch: (query: string) => void
toPrevOverlay: () => void
toNextOverlay: () => void
logs: string[]
}
export const PodLogSearch = observer((props: PodLogSearchProps) => {
const { logs, onSearch, toPrevOverlay, toNextOverlay } = props;
const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore;
const jumpDisabled = !searchQuery || !occurrences.length;
const findCounts = (
<div className="find-count">
{activeFind}/{totalFinds}
</div>
);
const setSearch = (query: string) => {
searchStore.onSearch(logs, query);
onSearch(query);
};
const onPrevOverlay = () => {
setPrevOverlayActive();
toPrevOverlay();
}
const onNextOverlay = () => {
setNextOverlayActive();
toNextOverlay();
}
const onClear = () => {
setSearch("");
}
const onKeyDown = (evt: React.KeyboardEvent<any>) => {
if (evt.key === "Enter") {
onNextOverlay();
}
}
useEffect(() => {
// Refresh search when logs changed
searchStore.onSearch(logs);
}, [logs]);
return (
<div className="PodLogsSearch flex box grow justify-flex-end gaps align-center">
<SearchInput
value={searchQuery}
onChange={setSearch}
closeIcon={false}
contentRight={totalFinds > 0 && findCounts}
onClear={onClear}
onKeyDown={onKeyDown}
/>
<Icon
material="keyboard_arrow_up"
tooltip={_i18n._(t`Previous`)}
onClick={onPrevOverlay}
disabled={jumpDisabled}
/>
<Icon
material="keyboard_arrow_down"
tooltip={_i18n._(t`Next`)}
onClick={onNextOverlay}
disabled={jumpDisabled}
/>
<Icon
material="close"
tooltip={_i18n._(t`Clear`)}
onClick={onClear}
/>
</div>
);
});

View File

@ -6,19 +6,46 @@
// `overflow: overlay` don't allow scroll to the last line
overflow: auto;
position: relative;
color: $textColorAccent;
background: $logsBackground;
line-height: var(--log-line-height);
border-radius: 2px;
padding: $padding * 2;
font-family: $font-monospace;
font-size: smaller;
white-space: pre;
flex-grow: 1;
> div {
// Provides font better readability on large screens
-webkit-font-smoothing: subpixel-antialiased;
.find-overlay {
position: absolute;
border-radius: 2px;
background-color: #8cc474;
margin-top: 4px;
opacity: 0.5;
}
.VirtualList {
height: 100%;
.list {
.LogRow {
padding: 2px 16px;
height: 18px; // Must be equal to lineHeight variable in pod-logs.scss
font-family: $font-monospace;
font-size: smaller;
white-space: pre;
-webkit-font-smoothing: auto; // Better readability on non-retina screens
&:hover {
background: $logRowHoverBackground;
}
span {
border-radius: 2px;
background-color: #8cc474b8;
-webkit-font-smoothing: auto;
&.active {
background-color: orange;
}
}
}
}
}
}
@ -47,7 +74,8 @@
padding: $unit / 2 $unit * 1.5;
border-radius: $unit * 2;
opacity: 0;
transition: opacity 0.2s;
z-index: 2;
top: 20px;
&.active {
opacity: 1;
@ -57,4 +85,20 @@
--size: $unit * 2;
}
}
.PodLogControls {
.Select {
min-width: 150px;
}
}
.logs .VirtualList .list {
overflow-x: scroll!important;
}
&.noscroll {
.logs .VirtualList .list {
overflow-x: hidden!important; // fixing scroll to bottom issues in PodLogs
}
}
}

View File

@ -5,7 +5,7 @@ import { DockTabStore } from "./dock-tab.store";
import { dockStore, IDockTab, TabKind } from "./dock.store";
import { t } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { isDevelopment } from "../../../common/vars";
import { searchStore } from "../../../common/search-store";
export interface IPodLogsData {
pod: Pod;
@ -20,7 +20,7 @@ type TabId = string;
type PodLogLine = string;
// Number for log lines to load
export const logRange = isDevelopment ? 100 : 1000;
export const logRange = 500;
@autobind()
export class PodLogsStore extends DockTabStore<IPodLogsData> {
@ -49,6 +49,11 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
reaction(() => this.logs.get(dockStore.selectedTabId), () => {
this.setNewLogSince(dockStore.selectedTabId);
})
reaction(() => dockStore.selectedTabId, () => {
// Clear search query on tab change
searchStore.reset();
})
}
/**
@ -82,6 +87,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
* @param tabId
*/
loadMore = async (tabId: TabId) => {
if (!this.logs.get(tabId).length) return;
const oldLogs = this.logs.get(tabId);
const logs = await this.loadLogs(tabId, {
sinceTime: this.getLastSinceTime(tabId)
@ -120,7 +126,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
* @param tabId
*/
setNewLogSince(tabId: TabId) {
if (!this.logs.has(tabId) || this.newLogSince.has(tabId)) return;
if (!this.logs.has(tabId) || !this.logs.get(tabId).length || this.newLogSince.has(tabId)) return;
const timestamp = this.getLastSinceTime(tabId);
this.newLogSince.set(tabId, timestamp.split(".")[0]); // Removing milliseconds from string
}

View File

@ -1,9 +1,7 @@
import "./pod-logs.scss";
import React from "react";
import AnsiUp from "ansi_up";
import DOMPurify from "dompurify";
import { t, Trans } from "@lingui/macro";
import { computed, observable, reaction } from "mobx";
import { Trans } from "@lingui/macro";
import { action, computed, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { _i18n } from "../../i18n";
import { autobind, cssNames } from "../../utils";
@ -14,30 +12,33 @@ import { InfoPanel } from "./info-panel";
import { IPodLogsData, logRange, podLogsStore } from "./pod-logs.store";
import { Button } from "../button";
import { PodLogControls } from "./pod-log-controls";
import { VirtualList } from "../virtual-list";
import { searchStore } from "../../../common/search-store";
import { ListOnScrollProps } from "react-window";
interface Props {
className?: string
tab: IDockTab
}
const lineHeight = 18; // Height of a log line. Should correlate with styles in pod-logs.scss
@observer
export class PodLogs extends React.Component<Props> {
@observable ready = false;
@observable preloading = false; // Indicator for setting Spinner (loader) at the top of the logs
@observable showJumpToBottom = false;
@observable hideHorizontalScroll = true; // Hiding scrollbar allows to scroll logs down to last element
private logsElement: HTMLDivElement;
private logsElement = React.createRef<HTMLDivElement>(); // A reference for outer container in VirtualList
private virtualListRef = React.createRef<VirtualList>(); // A reference for VirtualList component
private lastLineIsShown = true; // used for proper auto-scroll content after refresh
private colorConverter = new AnsiUp();
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.tab.id, async () => {
if (podLogsStore.logs.has(this.tabId)) {
this.ready = true;
return;
}
await this.load();
this.scrollToBottom();
}, { fireImmediately: true }),
// Check if need to show JumpToBottom if new log amount is less than previous one
@ -53,8 +54,8 @@ export class PodLogs extends React.Component<Props> {
componentDidUpdate() {
// scroll logs only when it's already in the end,
// otherwise it can interrupt reading by jumping after loading new logs update
if (this.logsElement && this.lastLineIsShown) {
this.logsElement.scrollTop = this.logsElement.scrollHeight;
if (this.logsElement.current && this.lastLineIsShown) {
this.logsElement.current.scrollTop = this.logsElement.current.scrollHeight;
}
}
@ -86,60 +87,130 @@ export class PodLogs extends React.Component<Props> {
/**
* Function loads more logs (usually after user scrolls to top) and sets proper
* scrolling position
* @param scrollHeight previous scrollHeight position before adding new lines
*/
loadMore = async (scrollHeight: number) => {
if (podLogsStore.lines < logRange) return;
loadMore = async () => {
const lines = podLogsStore.lines;
if (lines < logRange) return;
this.preloading = true;
await podLogsStore.load(this.tabId).then(() => this.preloading = false);
if (this.logsElement.scrollHeight > scrollHeight) {
await podLogsStore.load(this.tabId);
this.preloading = false;
if (podLogsStore.lines > lines) {
// Set scroll position back to place where preloading started
this.logsElement.scrollTop = this.logsElement.scrollHeight - scrollHeight - 48;
this.logsElement.current.scrollTop = (podLogsStore.lines - lines) * lineHeight;
}
}
/**
* Computed prop which returns logs with or without timestamps added to each line and
* does separation between new and old logs
* @returns {Array} An array with 2 items - [oldLogs, newLogs]
* A function for various actions after search is happened
* @param query {string} A text from search field
*/
@computed
get logs() {
if (!podLogsStore.logs.has(this.tabId)) return [];
const logs = podLogsStore.logs.get(this.tabId);
const { getData, removeTimestamps, newLogSince } = podLogsStore;
const { showTimestamps } = getData(this.tabId);
let oldLogs: string[] = logs;
let newLogs: string[] = [];
if (newLogSince.has(this.tabId)) {
// Finding separator timestamp in logs
const index = logs.findIndex(item => item.includes(newLogSince.get(this.tabId)));
if (index !== -1) {
// Splitting logs to old and new ones
oldLogs = logs.slice(0, index);
newLogs = logs.slice(index);
}
}
if (!showTimestamps) {
return [oldLogs, newLogs].map(logs => logs.map(item => removeTimestamps(item)))
}
return [oldLogs, newLogs];
@autobind()
onSearch(query: string) {
this.toOverlay();
}
onScroll = (evt: React.UIEvent<HTMLDivElement>) => {
const logsArea = evt.currentTarget;
const toBottomOffset = 100 * 16; // 100 lines * 16px (height of each line)
const { scrollHeight, clientHeight, scrollTop } = logsArea;
if (scrollTop === 0) {
this.loadMore(scrollHeight);
/**
* Scrolling to active overlay (search word highlight)
*/
@autobind()
toOverlay() {
const { activeOverlayLine } = searchStore;
if (!this.virtualListRef.current || activeOverlayLine === undefined) return;
// Scroll vertically
this.virtualListRef.current.scrollToItem(activeOverlayLine, "center");
// Scroll horizontally in timeout since virtual list need some time to prepare its contents
setTimeout(() => {
const overlay = document.querySelector(".PodLogs .list span.active");
if (!overlay) return;
overlay.scrollIntoViewIfNeeded();
}, 100);
}
/**
* Computed prop which returns logs with or without timestamps added to each line
* @returns {Array} An array log items
*/
@computed
get logs(): string[] {
if (!podLogsStore.logs.has(this.tabId)) return [];
const logs = podLogsStore.logs.get(this.tabId);
const { getData, removeTimestamps } = podLogsStore;
const { showTimestamps } = getData(this.tabId);
if (!showTimestamps) {
return logs.map(item => removeTimestamps(item));
}
if (scrollHeight - scrollTop > toBottomOffset) {
this.showJumpToBottom = true;
return logs;
}
onScroll = (props: ListOnScrollProps) => {
if (!this.logsElement.current) return;
const toBottomOffset = 100 * lineHeight; // 100 lines * 18px (height of each line)
const { scrollHeight, clientHeight } = this.logsElement.current;
const { scrollDirection, scrollOffset, scrollUpdateWasRequested } = props;
if (scrollDirection == "forward") {
if (scrollHeight - scrollOffset < toBottomOffset) {
this.showJumpToBottom = false;
}
if (clientHeight + scrollOffset === scrollHeight) {
this.lastLineIsShown = true;
}
} else {
this.showJumpToBottom = false;
this.lastLineIsShown = false;
// Trigger loading only if scrolled by user
if (scrollOffset === 0 && !scrollUpdateWasRequested) {
this.loadMore();
}
if (scrollHeight - scrollOffset > toBottomOffset) {
this.showJumpToBottom = true;
}
}
this.lastLineIsShown = clientHeight + scrollTop === scrollHeight;
};
}
@action
scrollToBottom = () => {
if (!this.virtualListRef.current) return;
this.hideHorizontalScroll = true;
this.virtualListRef.current.scrollToItem(this.logs.length, "end");
this.showJumpToBottom = false;
// Showing horizontal scrollbar after VirtualList settles down
setTimeout(() => this.hideHorizontalScroll = false, 500);
}
/**
* A function is called by VirtualList for rendering each of the row
* @param rowIndex {Number} index of the log element in logs array
* @returns A react element with a row itself
*/
getLogRow = (rowIndex: number) => {
const { searchQuery, isActiveOverlay } = searchStore;
const item = this.logs[rowIndex];
const contents: React.ReactElement[] = [];
if (searchQuery) { // If search is enabled, replace keyword with backgrounded <span>
// Case-insensitive search (lowercasing query and keywords in line)
const regex = new RegExp(searchStore.escapeRegex(searchQuery), "gi");
const matches = item.matchAll(regex);
const modified = item.replace(regex, match => match.toLowerCase());
// Splitting text line by keyword
const pieces = modified.split(searchQuery.toLowerCase());
pieces.forEach((piece, index) => {
const active = isActiveOverlay(rowIndex, index);
const lastItem = index === pieces.length - 1;
const overlay = !lastItem ?
<span className={cssNames({ active })}>{matches.next().value}</span> :
null
contents.push(
<React.Fragment key={piece + index}>
{piece}{overlay}
</React.Fragment>
);
})
}
return (
<div className={cssNames("LogRow")}>
{contents.length > 1 ? contents : item}
</div>
);
}
renderJumpToBottom() {
if (!this.logsElement) return null;
@ -149,10 +220,7 @@ export class PodLogs extends React.Component<Props> {
className={cssNames("jump-to-bottom flex gaps", {active: this.showJumpToBottom})}
onClick={evt => {
evt.currentTarget.blur();
this.logsElement.scrollTo({
top: this.logsElement.scrollHeight,
behavior: "auto"
});
this.scrollToBottom();
}}
>
<Trans>Jump to bottom</Trans>
@ -162,13 +230,15 @@ export class PodLogs extends React.Component<Props> {
}
renderLogs() {
const [oldLogs, newLogs] = this.logs;
// Generating equal heights for each row with ability to do multyrow logs in future
// e. g. for wrapping logs feature
const rowHeights = new Array(this.logs.length).fill(lineHeight);
if (!this.ready) {
return <Spinner center/>;
}
if (!oldLogs.length && !newLogs.length) {
if (!this.logs.length) {
return (
<div className="flex align-center justify-center">
<div className="flex box grow align-center justify-center">
<Trans>There are no logs available for container.</Trans>
</div>
);
@ -177,16 +247,18 @@ export class PodLogs extends React.Component<Props> {
<>
{this.preloading && (
<div className="flex justify-center">
<Spinner />
<Spinner center />
</div>
)}
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(oldLogs.join("\n"))) }} />
{newLogs.length > 0 && (
<>
<p className="new-logs-sep" title={_i18n._(t`New logs since opening logs tab`)}/>
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(newLogs.join("\n"))) }} />
</>
)}
<VirtualList
items={this.logs}
rowHeights={rowHeights}
getRow={this.getLogRow}
onScroll={this.onScroll}
outerRef={this.logsElement}
ref={this.virtualListRef}
className="box grow"
/>
</>
);
}
@ -201,17 +273,20 @@ export class PodLogs extends React.Component<Props> {
logs={this.logs}
save={this.save}
reload={this.reload}
onSearch={this.onSearch}
toPrevOverlay={this.toOverlay}
toNextOverlay={this.toOverlay}
/>
)
return (
<div className={cssNames("PodLogs flex column", className)}>
<div className={cssNames("PodLogs flex column", className, { noscroll: this.hideHorizontalScroll })}>
<InfoPanel
tabId={this.props.tab.id}
controls={controls}
showSubmitClose={false}
showButtons={false}
/>
<div className="logs" onScroll={this.onScroll} ref={e => this.logsElement = e}>
<div className="logs flex">
{this.renderJumpToBottom()}
{this.renderLogs()}
</div>

View File

@ -2,11 +2,11 @@ import React from "react";
import { DrawerItem, DrawerItemProps } from "./drawer-item";
import { Badge } from "../badge";
interface Props extends DrawerItemProps {
export interface DrawerItemLabelsProps extends DrawerItemProps {
labels: string[];
}
export function DrawerItemLabels(props: Props) {
export function DrawerItemLabels(props: DrawerItemLabelsProps) {
const { labels, ...itemProps } = props;
if (!labels || !labels.length) {
return null;

View File

@ -5,14 +5,14 @@ import { Icon } from "../icon";
import { cssNames } from "../../utils";
import { _i18n } from "../../i18n";
interface Props {
export interface DrawerParamTogglerProps {
label: string | number;
}
interface State {
open?: boolean;
}
export class DrawerParamToggler extends React.Component<Props, State> {
export class DrawerParamToggler extends React.Component<DrawerParamTogglerProps, State> {
public state: State = {}
toggle = () => {

View File

@ -2,12 +2,12 @@ import "./drawer-title.scss";
import React from "react";
import { cssNames } from "../../utils";
interface Props {
export interface DrawerTitleProps {
className?: string;
title?: React.ReactNode;
}
export class DrawerTitle extends React.Component<Props> {
export class DrawerTitle extends React.Component<DrawerTitleProps> {
render() {
const { title, children, className } = this.props
return (

View File

@ -0,0 +1,28 @@
.EditableList {
.el-contents {
display: flex;
flex-direction: column;
margin-top: $padding * 2;
.el-value-remove {
.Icon {
justify-content: unset;
}
}
.el-item {
display: grid;
grid-template-columns: 1fr auto;
padding: $padding $padding * 2;
margin-bottom: 1px;
:last-child {
margin-bottom: unset;
}
:first-child {
align-self: center;
}
}
}
}

View File

@ -0,0 +1,71 @@
import "./editable-list.scss"
import React from "react";
import { Icon } from "../icon";
import { Input } from "../input";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { autobind } from "../../utils";
import { _i18n } from "../../i18n";
export interface Props<T> {
items: T[],
add: (newItem: string) => void,
remove: (info: { oldItem: T, index: number }) => void,
placeholder?: string,
// An optional prop used to convert T to a displayable string
// defaults to `String`
renderItem?: (item: T, index: number) => React.ReactNode,
}
const defaultProps: Partial<Props<any>> = {
placeholder: _i18n._("Add new item..."),
renderItem: (item: any, index: number) => <React.Fragment key={index}>{item}</React.Fragment>
}
@observer
export class EditableList<T> extends React.Component<Props<T>> {
static defaultProps = defaultProps as Props<any>;
@observable currentNewItem = "";
@autobind()
onSubmit(val: string) {
const { add } = this.props
if (val) {
add(val)
this.currentNewItem = ""
}
}
render() {
const { items, remove, renderItem, placeholder } = this.props;
return (
<div className="EditableList">
<div className="el-header">
<Input
theme="round-black"
value={this.currentNewItem}
onSubmit={this.onSubmit}
placeholder={placeholder}
onChange={val => this.currentNewItem = val}
/>
</div>
<div className="el-contents">
{
items.map((item, index) => (
<div key={item + `${index}`} className="el-item Badge">
<div>{renderItem(item, index)}</div>
<div className="el-value-remove">
<Icon material="delete_outline" onClick={() => remove(({ index, oldItem: item }))} />
</div>
</div>
))
}
</div>
</div>
)
}
}

View File

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

View File

@ -1,3 +1,4 @@
export * from './input'
export * from './search-input'
export * from './search-input-url'
export * from './file-input'

View File

@ -3,13 +3,16 @@ import "./input.scss";
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
import { autobind, cssNames, debouncePromise } from "../../utils";
import { Icon } from "../icon";
import { conditionalValidators, Validator } from "./input_validators";
import * as Validators from "./input_validators";
import { InputValidator } from "./input_validators";
import isString from "lodash/isString"
import isFunction from "lodash/isFunction"
import isBoolean from "lodash/isBoolean"
import uniqueId from "lodash/uniqueId"
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
const { conditionalValidators, ...InputValidators } = Validators;
export { InputValidators, InputValidator }
type InputElement = HTMLInputElement | HTMLTextAreaElement;
type InputElementProps = InputHTMLAttributes<InputElement> & TextareaHTMLAttributes<InputElement> & DOMAttributes<InputElement>;
@ -24,7 +27,8 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
showValidationLine?: boolean; // show animated validation line for async validators
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
iconRight?: string | React.ReactNode;
validators?: Validator | Validator[];
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
validators?: InputValidator | InputValidator[];
onChange?(value: T, evt: React.ChangeEvent<InputElement>): void;
onSubmit?(value: T): void;
}
@ -49,7 +53,7 @@ export class Input extends React.Component<InputProps, State> {
static defaultProps = defaultProps as object;
public input: InputElement;
public validators: Validator[] = [];
public validators: InputValidator[] = [];
public state: State = {
dirty: !!this.props.dirty,
@ -149,7 +153,7 @@ export class Input extends React.Component<InputProps, State> {
});
}
private getValidatorError(value: string, { message }: Validator) {
private getValidatorError(value: string, { message }: InputValidator) {
if (isFunction(message)) return message(value, this.props)
return message || "";
}
@ -213,6 +217,10 @@ export class Input extends React.Component<InputProps, State> {
onKeyDown(evt: React.KeyboardEvent<any>) {
const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey;
if (this.props.onKeyDown) {
this.props.onKeyDown(evt);
}
switch (evt.key) {
case "Enter":
if (this.props.onSubmit && !modified && !evt.repeat) {
@ -258,7 +266,7 @@ export class Input extends React.Component<InputProps, State> {
render() {
const {
multiLine, showValidationLine, validators, theme, maxRows, children,
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight,
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight,
...inputProps
} = this.props;
const { focused, dirty, valid, validating, errors } = this.state;
@ -288,9 +296,10 @@ export class Input extends React.Component<InputProps, State> {
return (
<div className={className}>
<label className="input-area flex gaps align-center">
{isString(iconLeft) ? <Icon material={iconLeft} /> : iconLeft}
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
{isString(iconRight) ? <Icon material={iconRight} /> : iconRight}
{contentRight}
</label>
<div className="input-info flex gaps">
{!valid && dirty && (

View File

@ -4,26 +4,26 @@ import { t } from "@lingui/macro";
import { _i18n } from '../../i18n';
import fse from "fs-extra";
export interface Validator {
export interface InputValidator {
debounce?: number; // debounce for async validators in ms
condition?(props: InputProps): boolean; // auto-bind condition depending on input props
message?: ReactNode | ((value: string, props?: InputProps) => ReactNode | string);
validate(value: string, props?: InputProps): boolean | Promise<any>; // promise can throw error message
}
export const isRequired: Validator = {
export const isRequired: InputValidator = {
condition: ({ required }) => required,
message: () => _i18n._(t`This field is required`),
validate: value => !!value.trim(),
};
export const isEmail: Validator = {
export const isEmail: InputValidator = {
condition: ({ type }) => type === "email",
message: () => _i18n._(t`Wrong email format`),
validate: value => !!value.match(/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/),
};
export const isNumber: Validator = {
export const isNumber: InputValidator = {
condition: ({ type }) => type === "number",
message: () => _i18n._(t`Invalid number`),
validate: (value, { min, max }) => {
@ -36,37 +36,37 @@ export const isNumber: Validator = {
},
};
export const isUrl: Validator = {
export const isUrl: InputValidator = {
condition: ({ type }) => type === "url",
message: () => _i18n._(t`Wrong url format`),
validate: value => !!value.match(/^$|^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
};
export const isPath: Validator = {
export const isPath: InputValidator = {
condition: ({ type }) => type === "text",
message: () => _i18n._(t`This field must be a valid path`),
validate: value => !value || fse.pathExistsSync(value),
}
export const minLength: Validator = {
export const minLength: InputValidator = {
condition: ({ minLength }) => !!minLength,
message: (value, { minLength }) => _i18n._(t`Minimum length is ${minLength}`),
validate: (value, { minLength }) => value.length >= minLength,
};
export const maxLength: Validator = {
export const maxLength: InputValidator = {
condition: ({ maxLength }) => !!maxLength,
message: (value, { maxLength }) => _i18n._(t`Maximum length is ${maxLength}`),
validate: (value, { maxLength }) => value.length <= maxLength,
};
const systemNameMatcher = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
export const systemName: Validator = {
export const systemName: InputValidator = {
message: () => _i18n._(t`A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.`),
validate: value => !!value.match(systemNameMatcher),
};
export const accountId: Validator = {
export const accountId: InputValidator = {
message: () => _i18n._(t`Invalid account ID`),
validate: value => (isEmail.validate(value) || systemName.validate(value))
};

View File

@ -0,0 +1,49 @@
import React from "react";
import debounce from "lodash/debounce";
import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { getSearch, setSearch } from "../../navigation";
import { InputProps } from "./input";
import { SearchInput } from "./search-input";
interface Props extends InputProps {
compact?: boolean; // show only search-icon when not focused
}
@observer
export class SearchInputUrl extends React.Component<Props> {
@observable inputVal = ""; // fix: use empty string to avoid react warnings
@disposeOnUnmount
updateInput = autorun(() => this.inputVal = getSearch())
updateUrl = debounce((val: string) => setSearch(val), 250)
setValue = (value: string) => {
this.inputVal = value;
this.updateUrl(value);
}
clear = () => {
this.setValue("");
this.updateUrl.flush();
}
onChange = (val: string, evt: React.ChangeEvent<any>) => {
this.setValue(val);
if (this.props.onChange) {
this.props.onChange(val, evt);
}
}
render() {
const { inputVal } = this;
return (
<SearchInput
value={inputVal}
onChange={this.onChange}
onClear={this.clear}
{...this.props}
/>
)
}
}

View File

@ -6,7 +6,10 @@
> label {
color: inherit;
background: none;
border: none;
border-radius: $radius;
box-shadow: 0 0 0 1px $halfGray;
padding: 6px 6px 6px 10px;
.Icon {

View File

@ -1,22 +1,22 @@
import "./search-input.scss";
import React from "react";
import debounce from "lodash/debounce"
import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import React, { createRef } from "react";
import { t } from "@lingui/macro";
import { observer } from "mobx-react";
import { _i18n } from "../../i18n";
import { autobind, cssNames } from "../../utils";
import { Icon } from "../icon";
import { cssNames } from "../../utils";
import { Input, InputProps } from "./input";
import { getSearch, setSearch } from "../../navigation";
import { _i18n } from '../../i18n';
interface Props extends InputProps {
compact?: boolean; // show only search-icon when not focused
closeIcon?: boolean;
onClear?: () => void;
}
const defaultProps: Partial<Props> = {
autoFocus: true,
closeIcon: true,
get placeholder() {
return _i18n._(t`Search...`)
},
@ -26,27 +26,24 @@ const defaultProps: Partial<Props> = {
export class SearchInput extends React.Component<Props> {
static defaultProps = defaultProps as object;
@observable inputVal = ""; // fix: use empty string to avoid react warnings
private input = createRef<Input>();
@disposeOnUnmount
updateInput = autorun(() => this.inputVal = getSearch())
updateUrl = debounce((val: string) => setSearch(val), 250)
componentDidMount() {
addEventListener("keydown", this.focus);
}
setValue = (value: string) => {
this.inputVal = value;
this.updateUrl(value);
componentWillUnmount() {
removeEventListener("keydown", this.focus);
}
clear = () => {
this.setValue("");
this.updateUrl.flush();
if (this.props.onClear) {
this.props.onClear();
}
}
onChange = (val: string, evt: React.ChangeEvent<any>) => {
this.setValue(val);
if (this.props.onChange) {
this.props.onChange(val, evt);
}
this.props.onChange(val, evt);
}
onKeyDown = (evt: React.KeyboardEvent<any>) => {
@ -61,20 +58,27 @@ export class SearchInput extends React.Component<Props> {
}
}
@autobind()
focus(evt: KeyboardEvent) {
const meta = evt.metaKey || evt.ctrlKey;
if (meta && evt.key == "f") {
this.input.current.focus();
}
}
render() {
const { inputVal } = this;
const { className, compact, ...inputProps } = this.props;
const icon = inputVal
? <Icon small material="close" onClick={this.clear}/>
const { className, compact, closeIcon, onClear, ...inputProps } = this.props;
const icon = this.props.value
? closeIcon ? <Icon small material="close" onClick={this.clear}/> : null
: <Icon small material="search"/>
return (
<Input
{...inputProps}
className={cssNames("SearchInput", className, { compact })}
value={inputVal}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
iconRight={icon}
ref={this.input}
/>
)
}

View File

@ -22,15 +22,6 @@
}
}
}
.SearchInput {
label {
background: none;
border: none;
border-radius: $radius;
box-shadow: 0 0 0 1px $halfGray;
}
}
}
> .items {

View File

@ -5,14 +5,14 @@ import React, { ReactNode } from "react";
import { computed, observable, reaction, toJS, when } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { Plural, Trans } from "@lingui/macro";
import { ConfirmDialog, IConfirmDialogParams } from "../confirm-dialog";
import { SortingCallback, Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps } from "../table";
import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog";
import { TableSortCallback, Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps } from "../table";
import { autobind, createStorage, cssNames, IClassName, isReactNode, noop, prevDefault, stopPropagation } from "../../utils";
import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons";
import { NoItems } from "../no-items";
import { Spinner } from "../spinner";
import { ItemObject, ItemStore } from "../../item.store";
import { SearchInput } from "../input";
import { SearchInputUrl } from "../input";
import { namespaceStore } from "../+namespaces/namespace.store";
import { Filter, FilterType, pageFilters } from "./page-filters.store";
import { PageFiltersList } from "./page-filters-list";
@ -52,7 +52,7 @@ export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
isSelectable?: boolean; // show checkbox in rows for selecting items
isSearchable?: boolean; // apply search-filter & add search-input
copyClassNameFromHeadCells?: boolean;
sortingCallbacks?: { [sortBy: string]: SortingCallback };
sortingCallbacks?: { [sortBy: string]: TableSortCallback };
tableProps?: Partial<TableProps>; // low-level table configuration
renderTableHeader: TableCellProps[] | null;
renderTableContents: (item: T) => (ReactNode | TableCellProps)[];
@ -67,7 +67,7 @@ export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
onDetails?: (item: T) => void;
// other
customizeRemoveDialog?: (selectedItems: T[]) => Partial<IConfirmDialogParams>;
customizeRemoveDialog?: (selectedItems: T[]) => Partial<ConfirmDialogParams>;
renderFooter?: (parent: ItemListLayout) => React.ReactNode;
}
@ -349,7 +349,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
[FilterType.NAMESPACE]: true, // namespace-select used instead
}}/>
</>,
search: <SearchInput/>,
search: <SearchInputUrl/>,
}
let header = this.renderHeaderContent(placeholders);
if (customizeHeader) {

View File

@ -1,3 +1,4 @@
export * from "./kube-object-details"
export * from "./kube-object-list-layout"
export * from "./kube-object-menu"
export * from "./kube-object-meta"

View File

@ -11,7 +11,7 @@ import { Spinner } from "../spinner";
import { apiManager } from "../../api/api-manager";
import { crdStore } from "../+custom-resources/crd.store";
import { CrdResourceDetails } from "../+custom-resources";
import { KubeObjectMenu } from "./kube-object-menu"
import { KubeObjectMenu } from "./kube-object-menu"
import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry";
export interface KubeObjectDetailsProps<T = KubeObject> {

View File

@ -2,17 +2,16 @@ import React from "react";
import { Trans } from "@lingui/macro";
import { IKubeMetaField, KubeObject } from "../../api/kube-object";
import { DrawerItem, DrawerItemLabels } from "../drawer";
import { WorkloadKubeObject } from "../../api/workload-kube-object";
import { getDetailsUrl } from "../../navigation";
import { lookupApiLink } from "../../api/kube-api";
import { Link } from "react-router-dom";
export interface Props {
export interface KubeObjectMetaProps {
object: KubeObject;
hideFields?: IKubeMetaField[];
}
export class KubeObjectMeta extends React.Component<Props> {
export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
static defaultHiddenFields: IKubeMetaField[] = [
"uid", "resourceVersion", "selfLink"
];

View File

@ -12,14 +12,14 @@ export interface TabRoute extends RouteProps {
url: string;
}
interface Props {
export interface TabLayoutProps {
children: ReactNode;
className?: any;
tabs?: TabRoute[];
contentClass?: string;
}
export const TabLayout = observer(({ className, contentClass, tabs, children }: Props) => {
export const TabLayout = observer(({ className, contentClass, tabs, children }: TabLayoutProps) => {
const routePath = navigation.location.pathname;
return (
<div className={cssNames("TabLayout", className)}>

View File

@ -3,7 +3,7 @@ import React from "react";
import { observer } from "mobx-react";
import { cssNames, IClassName } from "../../utils";
interface Props extends React.DOMAttributes<any> {
export interface WizardLayoutProps extends React.DOMAttributes<any> {
className?: IClassName;
header?: React.ReactNode;
headerClass?: IClassName;
@ -15,7 +15,7 @@ interface Props extends React.DOMAttributes<any> {
}
@observer
export class WizardLayout extends React.Component<Props> {
export class WizardLayout extends React.Component<WizardLayoutProps> {
render() {
const {
className, contentClass, infoPanelClass, infoPanel, header, headerClass, centered,

View File

@ -3,7 +3,7 @@ import React from "react";
import { cssNames } from "../../utils";
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
interface Props extends React.HTMLProps<any>, TooltipDecoratorProps {
export interface LineProgressProps extends React.HTMLProps<any>, TooltipDecoratorProps {
value: number;
min?: number;
max?: number;
@ -12,8 +12,8 @@ interface Props extends React.HTMLProps<any>, TooltipDecoratorProps {
}
@withTooltip
export class LineProgress extends React.PureComponent<Props> {
static defaultProps: Props = {
export class LineProgress extends React.PureComponent<LineProgressProps> {
static defaultProps: LineProgressProps = {
value: 0,
min: 0,
max: 100,

View File

@ -10,7 +10,7 @@ import debounce from "lodash/debounce"
export const MenuContext = React.createContext<MenuContextValue>(null);
export type MenuContextValue = Menu;
interface MenuPosition {
export interface MenuPosition {
left?: boolean;
top?: boolean;
right?: boolean;

View File

@ -1 +1,2 @@
export * from './notifications'
export * from './notifications.store'

View File

@ -5,8 +5,8 @@ import isObject from "lodash/isObject"
import uniqueId from "lodash/uniqueId";
import { JsonApiErrorParsed } from "../../api/json-api";
export type IMessageId = string | number;
export type IMessage = React.ReactNode | React.ReactNode[] | JsonApiErrorParsed;
export type NotificationId = string | number;
export type NotificationMessage = React.ReactNode | React.ReactNode[] | JsonApiErrorParsed;
export enum NotificationStatus {
OK = "ok",
@ -14,20 +14,20 @@ export enum NotificationStatus {
INFO = "info",
}
export interface INotification {
id?: IMessageId;
message: IMessage;
export interface Notification {
id?: NotificationId;
message: NotificationMessage;
status?: NotificationStatus;
timeout?: number; // auto-hiding timeout in milliseconds, 0 = no hide
}
@autobind()
export class NotificationsStore {
public notifications = observable<INotification>([], { deep: false });
public notifications = observable<Notification>([], { deep: false });
protected autoHideTimers = new Map<IMessageId, number>();
protected autoHideTimers = new Map<NotificationId, number>();
addAutoHideTimer(notification: INotification) {
addAutoHideTimer(notification: Notification) {
this.removeAutoHideTimer(notification);
const { id, timeout } = notification;
if (timeout) {
@ -36,7 +36,7 @@ export class NotificationsStore {
}
}
removeAutoHideTimer(notification: INotification) {
removeAutoHideTimer(notification: Notification) {
const { id } = notification;
if (this.autoHideTimers.has(id)) {
clearTimeout(this.autoHideTimers.get(id));
@ -45,7 +45,7 @@ export class NotificationsStore {
}
@action
add(notification: INotification) {
add(notification: Notification) {
if (!notification.id) {
notification.id = uniqueId("notification_");
}
@ -56,11 +56,11 @@ export class NotificationsStore {
}
@action
remove(itemOrId: IMessageId | INotification) {
remove(itemOrId: NotificationId | Notification) {
if (!isObject(itemOrId)) {
itemOrId = this.notifications.find(item => item.id === itemOrId);
}
return this.notifications.remove(itemOrId as INotification);
return this.notifications.remove(itemOrId as Notification);
}
}

View File

@ -5,7 +5,7 @@ import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"
import { JsonApiErrorParsed } from "../../api/json-api";
import { cssNames, prevDefault } from "../../utils";
import { IMessage, INotification, notificationsStore, NotificationStatus } from "./notifications.store";
import { NotificationMessage, Notification, notificationsStore, NotificationStatus } from "./notifications.store";
import { Animate } from "../animate";
import { Icon } from "../icon"
@ -13,7 +13,7 @@ import { Icon } from "../icon"
export class Notifications extends React.Component {
public elem: HTMLElement;
static ok(message: IMessage) {
static ok(message: NotificationMessage) {
notificationsStore.add({
message: message,
timeout: 2500,
@ -21,7 +21,7 @@ export class Notifications extends React.Component {
})
}
static error(message: IMessage) {
static error(message: NotificationMessage) {
notificationsStore.add({
message: message,
timeout: 10000,
@ -29,7 +29,7 @@ export class Notifications extends React.Component {
});
}
static info(message: IMessage, customOpts: Partial<INotification> = {}) {
static info(message: NotificationMessage, customOpts: Partial<Notification> = {}) {
return notificationsStore.add({
status: NotificationStatus.INFO,
timeout: 0,
@ -56,7 +56,7 @@ export class Notifications extends React.Component {
})
}
getMessage(notification: INotification) {
getMessage(notification: Notification) {
let { message } = notification;
if (message instanceof JsonApiErrorParsed) {
message = message.toString();

View File

@ -5,7 +5,7 @@ import uniqueId from "lodash/uniqueId";
// todo: refactor with contexts
interface RadioGroupProps {
export interface RadioGroupProps {
className?: any;
value?: any;
asButtons?: boolean;
@ -35,7 +35,7 @@ export class RadioGroup extends React.Component<RadioGroupProps, {}> {
}
}
type RadioProps = React.HTMLProps<any> & {
export type RadioProps = React.HTMLProps<any> & {
name?: string;
label?: React.ReactNode | any;
value?: any;

View File

@ -4,20 +4,20 @@ import "./slider.scss";
import React, { Component } from "react";
import { cssNames } from "../../utils";
import MaterialSlider, { SliderClassKey, SliderProps } from "@material-ui/core/Slider";
import MaterialSlider, { SliderClassKey, SliderProps as MaterialSliderProps } from "@material-ui/core/Slider";
interface Props extends Omit<SliderProps, "onChange"> {
export interface SliderProps extends Omit<MaterialSliderProps, "onChange"> {
className?: string;
onChange?(evt: React.FormEvent<any>, value: number): void;
}
const defaultProps: Partial<Props> = {
const defaultProps: Partial<SliderProps> = {
step: 1,
min: 0,
max: 100,
};
export class Slider extends Component<Props> {
export class Slider extends Component<SliderProps> {
static defaultProps = defaultProps as object;
private classNames: Partial<{ [P in SliderClassKey]: string }> = {

View File

@ -2,12 +2,12 @@ import './cube-spinner.scss'
import React from 'react'
import { cssNames } from "../../utils";
interface Props {
export interface CubeSpinnerProps {
className?: string;
center?: boolean;
}
export class CubeSpinner extends React.Component<Props> {
export class CubeSpinner extends React.Component<CubeSpinnerProps> {
render() {
const { className, center } = this.props;
return (

View File

@ -3,12 +3,12 @@ import './spinner.scss'
import React from 'react'
import { cssNames } from "../../utils";
interface Props extends React.HTMLProps<any> {
export interface SpinnerProps extends React.HTMLProps<any> {
singleColor?: boolean;
center?: boolean;
}
export class Spinner extends React.Component<Props, {}> {
export class Spinner extends React.Component<SpinnerProps, {}> {
private elem: HTMLElement;
static defaultProps = {

View File

@ -4,11 +4,11 @@ import React from "react";
import { cssNames } from "../../utils";
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
interface Props extends React.HTMLAttributes<any>, TooltipDecoratorProps {
export interface StatusBrickProps extends React.HTMLAttributes<any>, TooltipDecoratorProps {
}
@withTooltip
export class StatusBrick extends React.Component<Props> {
export class StatusBrick extends React.Component<StatusBrickProps> {
render() {
const { className, ...elemProps } = this.props
return (

View File

@ -2,7 +2,7 @@ import "./stepper.scss";
import React from "react";
import { cssNames } from "../../utils";
interface Props extends React.HTMLProps<any> {
export interface StepperProps extends React.HTMLProps<any> {
step: number;
steps: Step[];
}
@ -11,7 +11,7 @@ interface Step {
title?: string;
}
export class Stepper extends React.Component<Props, {}> {
export class Stepper extends React.Component<StepperProps, {}> {
render() {
const { className, steps, ...props } = this.props;
const stepsCount = steps.length;

View File

@ -1,5 +1,5 @@
import "./table-cell.scss";
import type { SortBy, SortParams } from "./table";
import type { TableSortBy, TableSortParams } from "./table";
import React, { ReactNode } from "react";
import { autobind, cssNames } from "../../utils";
@ -13,9 +13,9 @@ export interface TableCellProps extends React.DOMAttributes<HTMLDivElement> {
title?: ReactNode;
checkbox?: boolean; // render cell with a checkbox
isChecked?: boolean; // mark checkbox as checked or not
sortBy?: SortBy; // column name, must be same as key in sortable object <Table sortable={}/>
_sorting?: Partial<SortParams>; // <Table> sorting state, don't use this prop outside (!)
_sort?(sortBy: SortBy): void; // <Table> sort function, don't use this prop outside (!)
sortBy?: TableSortBy; // column name, must be same as key in sortable object <Table sortable={}/>
_sorting?: Partial<TableSortParams>; // <Table> sorting state, don't use this prop outside (!)
_sort?(sortBy: TableSortBy): void; // <Table> sort function, don't use this prop outside (!)
_nowrap?: boolean; // indicator, might come from parent <TableHead>, don't use this prop outside (!)
}

View File

@ -14,10 +14,10 @@ import { ItemObject } from "../../item.store";
// todo: refactor + decouple search from location
export type SortBy = string;
export type OrderBy = "asc" | "desc" | string;
export type SortParams = { sortBy: SortBy; orderBy: OrderBy }
export type SortingCallback<D = any> = (data: D) => string | number | (string | number)[];
export type TableSortBy = string;
export type TableOrderBy = "asc" | "desc" | string;
export type TableSortParams = { sortBy: TableSortBy; orderBy: TableOrderBy }
export type TableSortCallback<D = any> = (data: D) => string | number | (string | number)[];
export interface TableProps extends React.DOMAttributes<HTMLDivElement> {
items?: ItemObject[]; // Raw items data
@ -29,11 +29,11 @@ export interface TableProps extends React.DOMAttributes<HTMLDivElement> {
sortable?: {
// Define sortable callbacks for every column in <TableHead><TableCell sortBy="someCol"><TableHead>
// @sortItem argument in the callback is an object, provided in <TableRow sortItem={someColDataItem}/>
[sortBy: string]: SortingCallback;
[sortBy: string]: TableSortCallback;
};
sortSyncWithUrl?: boolean; // sorting state is managed globally from url params
sortByDefault?: Partial<SortParams>; // default sorting params
onSort?: (params: SortParams) => void; // callback on sort change, default: global sync with url
sortByDefault?: Partial<TableSortParams>; // default sorting params
onSort?: (params: TableSortParams) => void; // callback on sort change, default: global sync with url
noItems?: React.ReactNode; // Show no items state table list is empty
selectedItemId?: string; // Allows to scroll list to selected item
virtual?: boolean; // Use virtual list component to render only visible rows
@ -55,7 +55,7 @@ export class Table extends React.Component<TableProps> {
@observable sortParamsLocal = this.props.sortByDefault;
@computed get sortParams(): Partial<SortParams> {
@computed get sortParams(): Partial<TableSortParams> {
if (this.props.sortSyncWithUrl) {
const sortBy = navigation.searchParams.get("sortBy")
const orderBy = navigation.searchParams.get("orderBy")
@ -105,7 +105,7 @@ export class Table extends React.Component<TableProps> {
}
@autobind()
protected onSort(params: SortParams) {
protected onSort(params: TableSortParams) {
const { sortSyncWithUrl, onSort } = this.props;
if (sortSyncWithUrl) {
setQueryParams(params)
@ -119,11 +119,11 @@ export class Table extends React.Component<TableProps> {
}
@autobind()
sort(colName: SortBy) {
sort(colName: TableSortBy) {
const { sortBy, orderBy } = this.sortParams;
const sameColumn = sortBy == colName;
const newSortBy: SortBy = colName;
const newOrderBy: OrderBy = (!orderBy || !sameColumn || orderBy === "desc") ? "asc" : "desc";
const newSortBy: TableSortBy = colName;
const newOrderBy: TableOrderBy = (!orderBy || !sameColumn || orderBy === "desc") ? "asc" : "desc";
this.onSort({
sortBy: String(newSortBy),
orderBy: newOrderBy,
@ -159,7 +159,7 @@ export class Table extends React.Component<TableProps> {
<VirtualList
items={sortedItems}
rowHeights={rowHeights}
getTableRow={getTableRow}
getRow={getTableRow}
selectedItemId={selectedItemId}
className={className}
/>

View File

@ -9,6 +9,5 @@
}
overflow-y: overlay !important;
overflow-x: hidden !important;
}
}

View File

@ -4,8 +4,8 @@ import "./virtual-list.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { ListChildComponentProps, VariableSizeList } from "react-window";
import { cssNames } from "../../utils";
import { Align, ListChildComponentProps, ListOnScrollProps, VariableSizeList } from "react-window";
import { cssNames, noop } from "../../utils";
import { TableRowProps } from "../table/table-row";
import { ItemObject } from "../../item.store";
import throttle from "lodash/throttle";
@ -13,15 +13,17 @@ import debounce from "lodash/debounce";
import isEqual from "lodash/isEqual";
import ResizeSensor from "css-element-queries/src/ResizeSensor";
interface Props {
items: ItemObject[];
interface Props<T extends ItemObject = any> {
items: T[];
rowHeights: number[];
className?: string;
width?: number | string;
initialOffset?: number;
readyOffset?: number;
selectedItemId?: string;
getTableRow?: (uid: string) => React.ReactElement<TableRowProps>;
getRow?: (uid: string | number) => React.ReactElement<any>;
onScroll?: (props: ListOnScrollProps) => void;
outerRef?: React.Ref<any>
}
interface State {
@ -33,6 +35,7 @@ const defaultProps: Partial<Props> = {
width: "100%",
initialOffset: 1,
readyOffset: 10,
onScroll: noop
}
export class VirtualList extends Component<Props, State> {
@ -56,7 +59,7 @@ export class VirtualList extends Component<Props, State> {
componentDidUpdate(prevProps: Props) {
const { items, rowHeights } = this.props;
if (prevProps.items.length !== items.length || !isEqual(prevProps.rowHeights, rowHeights)) {
this.listRef.current.resetAfterIndex(0, true);
this.listRef.current.resetAfterIndex(0, false);
}
}
@ -73,18 +76,23 @@ export class VirtualList extends Component<Props, State> {
getItemSize = (index: number) => this.props.rowHeights[index];
scrollToSelectedItem = debounce(() => {
if (!this.props.selectedItemId) return;
const { items, selectedItemId } = this.props;
const index = items.findIndex(item => item.getId() == selectedItemId);
if (index === -1) return;
this.listRef.current.scrollToItem(index, "start");
})
scrollToItem = (index: number, align: Align) => {
this.listRef.current.scrollToItem(index, align)
}
render() {
const { width, className, items, getTableRow } = this.props;
const { width, className, items, getRow, onScroll, outerRef } = this.props;
const { height, overscanCount } = this.state;
const rowData: RowData = {
items,
getTableRow
getRow
};
return (
<div className={cssNames("VirtualList", className)} ref={this.parentRef}>
@ -97,7 +105,9 @@ export class VirtualList extends Component<Props, State> {
itemData={rowData}
overscanCount={overscanCount}
ref={this.listRef}
outerRef={outerRef}
children={Row}
onScroll={onScroll}
/>
</div>
);
@ -106,7 +116,7 @@ export class VirtualList extends Component<Props, State> {
interface RowData {
items: ItemObject[];
getTableRow?: (uid: string) => React.ReactElement<TableRowProps>;
getRow?: (uid: string | number) => React.ReactElement<TableRowProps>;
}
interface RowProps extends ListChildComponentProps {
@ -115,9 +125,10 @@ interface RowProps extends ListChildComponentProps {
const Row = observer((props: RowProps) => {
const { index, style, data } = props;
const { items, getTableRow } = data;
const uid = items[index].getId();
const row = getTableRow(uid);
const { items, getRow } = data;
const item = items[index];
const uid = typeof item == "string" ? index : items[index].getId();
const row = getRow(uid);
if (!row) return null;
return React.cloneElement(row, {
style: Object.assign({}, row.props.style, style)

View File

@ -61,6 +61,7 @@
"dockInfoBackground": "#1e2125",
"dockInfoBorderColor": "#303136",
"logsBackground": "#000000",
"logRowHoverBackground": "#35373a",
"terminalBackground": "#000000",
"terminalForeground": "#ffffff",
"terminalCursor": "#ffffff",

View File

@ -62,6 +62,7 @@
"dockInfoBackground": "#e8e8e8",
"dockInfoBorderColor": "#c9cfd3",
"logsBackground": "#ffffff",
"logRowHoverBackground": "#eeeeee",
"terminalBackground": "#ffffff",
"terminalForeground": "#2d2d2d",
"terminalCursor": "#2d2d2d",

View File

@ -93,6 +93,7 @@ $terminalBrightWhite: var(--terminalBrightWhite);
// Logs
$logsBackground: var(--logsBackground);
$logRowHoverBackground: var(--logRowHoverBackground);
// Dialogs
$dialogTextColor: var(--dialogTextColor);

7
types/dom.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
export {}
declare global {
interface Element {
scrollIntoViewIfNeeded(opt_center?: boolean): void;
}
}

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