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 displayName: Cache Yarn packages
- script: make install-deps - script: make install-deps
displayName: Install dependencies displayName: Install dependencies
- script: make build-npm
displayName: Generate npm package
- script: make build-extensions
displayName: Build bundled extensions
- script: make integration-win - script: make integration-win
displayName: Run integration tests displayName: Run integration tests
- script: make test-extensions - script: make test-extensions
@ -76,6 +80,10 @@ jobs:
condition: eq(variables.CACHE_RESTORED, 'true') condition: eq(variables.CACHE_RESTORED, 'true')
- script: make install-deps - script: make install-deps
displayName: Install dependencies displayName: Install dependencies
- script: make build-npm
displayName: Generate npm package
- script: make build-extensions
displayName: Build bundled extensions
- script: make test - script: make test
displayName: Run tests displayName: Run tests
- script: make integration-mac - script: make integration-mac
@ -127,6 +135,10 @@ jobs:
displayName: Run In-tree Extension tests displayName: Run In-tree Extension tests
- script: make lint - script: make lint
displayName: Lint displayName: Lint
- script: make build-npm
displayName: Generate npm package
- script: make build-extensions
displayName: Build bundled extensions
- script: make test - script: make test
displayName: Run tests displayName: Run tests
- bash: | - 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: 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) * [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) * [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) * [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) * [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) * [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) * [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! 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 # 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 ## Installing and Building the extension

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", () => { describe("cluster operations", () => {
beforeEach(appStartAddCluster, 40000) 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 - Publishing Extensions: extensions/testing-and-publishing/publishing.md
- Bundling Extensions: extensions/testing-and-publishing/bundling.md - Bundling Extensions: extensions/testing-and-publishing/bundling.md
- API Reference: extensions/api/modules/_src_extensions_extension_api_.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 - FAQ: faq/README.md
theme: theme:
name: 'material' name: 'material'

View File

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

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 rendererDir = path.join(contextDir, "src/renderer");
export const htmlTemplate = path.resolve(rendererDir, "template.html"); export const htmlTemplate = path.resolve(rendererDir, "template.html");
export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss"); export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
export const webpackDevServerPort = 9009
// Special runtime paths // Special runtime paths
defineGlobal("__static", { 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" // layouts
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"
export * from "../../renderer/components/layout/page-layout" 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/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 // kube helpers
export { KubeObjectDetailsProps, KubeObjectMenuProps } from "../../renderer/components/kube-object" export * from "../../renderer/components/kube-object"
export { KubeObjectMeta } from "../../renderer/components/kube-object/kube-object-meta" export * from "../../renderer/components/+events/kube-event-details"
export { KubeObjectListLayout, KubeObjectListLayoutProps } from "../../renderer/components/kube-object/kube-object-list-layout";
export { KubeEventDetails } from "../../renderer/components/+events/kube-event-details"
// specific exports // specific exports
export { ConfirmDialog } from "../../renderer/components/confirm-dialog"; export * from "../../renderer/components/status-brick";
export { MenuItem, SubMenu } from "../../renderer/components/menu";
export { StatusBrick } from "../../renderer/components/status-brick";
export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store"; export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store";
export { createPodLogsTab } from "../../renderer/components/dock/pod-logs.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 metadata: ClusterMetadata = {};
@observable allowedNamespaces: string[] = []; @observable allowedNamespaces: string[] = [];
@observable allowedResources: string[] = []; @observable allowedResources: string[] = [];
@observable accessibleNamespaces: string[] = [];
@computed get available() { @computed get available() {
return this.accessible && !this.disconnected; return this.accessible && !this.disconnected;
} }
get version(): string { get version(): string {
return String(this.metadata?.version) || "" return String(this.metadata?.version) || ""
} }
constructor(model: ClusterModel) { constructor(model: ClusterModel) {
@ -340,7 +341,7 @@ export class Cluster implements ClusterModel, ClusterState {
for (const w of warnings) { for (const w of warnings) {
if (w.involvedObject.kind === 'Pod') { if (w.involvedObject.kind === 'Pod') {
try { 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}`) logger.debug(`checking pod ${w.involvedObject.namespace}/${w.involvedObject.name}`)
if (podHasIssues(pod)) { if (podHasIssues(pod)) {
uniqEventSources.add(w.involvedObject.uid); uniqEventSources.add(w.involvedObject.uid);
@ -351,11 +352,10 @@ export class Cluster implements ClusterModel, ClusterState {
uniqEventSources.add(w.involvedObject.uid); uniqEventSources.add(w.involvedObject.uid);
} }
} }
let nodeNotificationCount = 0;
const nodes = (await client.listNode()).body.items; const nodes = (await client.listNode()).body.items;
nodes.map(n => { const nodeNotificationCount = nodes
nodeNotificationCount = nodeNotificationCount + getNodeWarningConditions(n).length .map(getNodeWarningConditions)
}); .reduce((sum, conditions) => sum + conditions.length, 0);
return uniqEventSources.size + nodeNotificationCount; return uniqEventSources.size + nodeNotificationCount;
} catch (error) { } catch (error) {
logger.error("Failed to fetch event count: " + JSON.stringify(error)) logger.error("Failed to fetch event count: " + JSON.stringify(error))
@ -371,7 +371,8 @@ export class Cluster implements ClusterModel, ClusterState {
workspace: this.workspace, workspace: this.workspace,
preferences: this.preferences, preferences: this.preferences,
metadata: this.metadata, metadata: this.metadata,
ownerRef: this.ownerRef ownerRef: this.ownerRef,
accessibleNamespaces: this.accessibleNamespaces,
}; };
return toJS(model, { return toJS(model, {
recurseEverything: true recurseEverything: true
@ -426,6 +427,10 @@ export class Cluster implements ClusterModel, ClusterState {
} }
protected async getAllowedNamespaces() { protected async getAllowedNamespaces() {
if (this.accessibleNamespaces.length) {
return this.accessibleNamespaces
}
const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api) const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api)
try { try {
const namespaceList = await api.listNamespace() const namespaceList = await api.listNamespace()
@ -442,7 +447,7 @@ export class Cluster implements ClusterModel, ClusterState {
} catch (error) { } catch (error) {
const ctx = this.getProxyKubeconfig().getContextObject(this.contextName) const ctx = this.getProxyKubeconfig().getContextObject(this.contextName)
if (ctx.namespace) return [ctx.namespace] if (ctx.namespace) return [ctx.namespace]
return [] return [];
} }
} }

View File

@ -4,7 +4,7 @@ import http from "http"
import path from "path" import path from "path"
import { readFile } from "fs-extra" import { readFile } from "fs-extra"
import { Cluster } from "./cluster" 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"; import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes";
export interface RouterRequestOpts { export interface RouterRequestOpts {
@ -94,22 +94,34 @@ export class Router {
return mimeTypes[path.extname(filename).slice(1)] || "text/plain" 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); const asset = path.join(__static, filePath);
try { 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); const data = await readFile(asset);
res.setHeader("Content-Type", this.getMimeType(asset)); res.setHeader("Content-Type", this.getMimeType(asset));
res.write(data) res.write(data);
res.end() res.end();
} catch (err) { } catch (err) {
this.handleStaticFile(`${publicPath}/${appName}.html`, res); this.handleStaticFile(`${publicPath}/${appName}.html`, res, req);
} }
} }
protected addRoutes() { protected addRoutes() {
// Static assets // Static assets
this.router.add({ method: 'get', path: '/{path*}' }, ({ params, response }: LensApiRequest) => { this.router.add(
this.handleStaticFile(params.path, response); { 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)) 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 { 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 { autobind } from "../utils";
import { KubeApi } from "./kube-api"; import { KubeApi } from "./kube-api";
export interface ApiComponents {
List?: React.ComponentType<KubeObjectListLayoutProps>;
Menu?: React.ComponentType<KubeObjectMenuProps>;
Details?: React.ComponentType<KubeObjectDetailsProps>;
}
@autobind() @autobind()
export class ApiManager { export class ApiManager {
private apis = observable.map<string, KubeApi>(); private apis = observable.map<string, KubeApi>();
private stores = observable.map<KubeApi, KubeObjectStore>(); private stores = observable.map<KubeApi, KubeObjectStore>();
private views = observable.map<KubeApi, ApiComponents>();
getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) {
if (typeof pathOrCallback === "string") { if (typeof pathOrCallback === "string") {
@ -46,8 +37,11 @@ export class ApiManager {
} }
} }
registerStore(api: KubeApi, store: KubeObjectStore) { @action
registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) {
apis.forEach(api => {
this.stores.set(api, store); this.stores.set(api, store);
})
} }
getStore(api: string | KubeApi): KubeObjectStore { getStore(api: string | KubeApi): KubeObjectStore {

View File

@ -9,7 +9,6 @@ import { kubeWatchApi } from "./kube-watch-api";
import { apiManager } from "./api-manager"; import { apiManager } from "./api-manager";
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
import { apiKubePrefix, isDevelopment } from "../../common/vars"; import { apiKubePrefix, isDevelopment } from "../../common/vars";
import * as URL from "url"
export interface IKubeApiOptions<T extends KubeObject> { export interface IKubeApiOptions<T extends KubeObject> {
apiBase?: string; // base api-path for listing all resources, e.g. "/api/v1/pods" 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 { ItemListLayout } from "../item-object-list/item-list-layout";
import { t, Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n"; import { _i18n } from "../../i18n";
import { SearchInput } from "../input"; import { SearchInputUrl } from "../input";
enum sortBy { enum sortBy {
name = "name", name = "name",
@ -72,7 +72,7 @@ export class HelmCharts extends Component<Props> {
(items: HelmChart[]) => items.filter(item => !item.deprecated) (items: HelmChart[]) => items.filter(item => !item.deprecated)
]} ]}
customizeHeader={() => ( customizeHeader={() => (
<SearchInput placeholder={_i18n._(t`Search Helm Charts`)} /> <SearchInputUrl placeholder={_i18n._(t`Search Helm Charts`)} />
)} )}
renderTableHeader={[ renderTableHeader={[
{ className: "icon" }, { 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 { ClusterProxySetting } from "./components/cluster-proxy-setting";
import { ClusterPrometheusSetting } from "./components/cluster-prometheus-setting"; import { ClusterPrometheusSetting } from "./components/cluster-prometheus-setting";
import { ClusterHomeDirSetting } from "./components/cluster-home-dir-setting"; import { ClusterHomeDirSetting } from "./components/cluster-home-dir-setting";
import { ClusterAccessibleNamespaces } from "./components/cluster-accessible-namespaces";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -21,6 +22,7 @@ export class General extends React.Component<Props> {
<ClusterProxySetting cluster={this.props.cluster} /> <ClusterProxySetting cluster={this.props.cluster} />
<ClusterPrometheusSetting cluster={this.props.cluster} /> <ClusterPrometheusSetting cluster={this.props.cluster} />
<ClusterHomeDirSetting cluster={this.props.cluster} /> <ClusterHomeDirSetting cluster={this.props.cluster} />
<ClusterAccessibleNamespaces cluster={this.props.cluster} />
</div>; </div>;
} }
} }

View File

@ -107,4 +107,4 @@ export class ClusterStore extends KubeObjectStore<Cluster> {
} }
export const clusterStore = new ClusterStore(); 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(); 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(); 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(); 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(); 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(); 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 { ICRDRouteParams } from "./crd.route";
import { autorun, computed } from "mobx"; import { autorun, computed } from "mobx";
import { crdStore } from "./crd.store"; import { crdStore } from "./crd.store";
import { SortingCallback } from "../table"; import { TableSortCallback } from "../table";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
interface Props extends RouteComponentProps<ICRDRouteParams> { interface Props extends RouteComponentProps<ICRDRouteParams> {
@ -50,7 +50,7 @@ export class CrdResources extends React.Component<Props> {
if (!crd) return null; if (!crd) return null;
const isNamespaced = crd.isNamespaced(); const isNamespaced = crd.isNamespaced();
const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details 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.name]: (item: KubeObject) => item.getName(),
[sortBy.namespace]: (item: KubeObject) => item.getNs(), [sortBy.namespace]: (item: KubeObject) => item.getNs(),
[sortBy.age]: (item: KubeObject) => item.metadata.creationTimestamp, [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 }); const api = apiManager.getApi(apiBase) || new KubeApi({ apiBase, kind, isNamespaced });
if (!apiManager.getStore(api)) { 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(); 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(); 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 { KubeObject } from "../../api/kube-object";
import { DrawerItem, DrawerTitle } from "../drawer"; import { DrawerItem, DrawerTitle } from "../drawer";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Icon } from "../icon";
import { eventStore } from "./event.store"; import { eventStore } from "./event.store";
interface Props { export interface KubeEventDetailsProps {
object: KubeObject; object: KubeObject;
} }
@observer @observer
export class KubeEventDetails extends React.Component<Props> { export class KubeEventDetails extends React.Component<KubeEventDetailsProps> {
async componentDidMount() { async componentDidMount() {
eventStore.loadAll(); eventStore.loadAll();
} }

View File

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

View File

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

View File

@ -14,4 +14,4 @@ export class ServiceAccountsStore extends KubeObjectStore<ServiceAccount> {
} }
export const serviceAccountsStore = new ServiceAccountsStore(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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 { Input } from "../input";
import { cssNames, prevDefault } from "../../utils"; import { cssNames, prevDefault } from "../../utils";
import { Button } from "../button"; import { Button } from "../button";
import { isRequired, Validator } from "../input/input_validators"; import { isRequired, InputValidator } from "../input/input_validators";
@observer @observer
export class Workspaces extends React.Component { export class Workspaces extends React.Component {
@ -122,7 +122,7 @@ export class Workspaces extends React.Component {
editing: isEditing, editing: isEditing,
default: isDefault, default: isDefault,
}); });
const existenceValidator: Validator = { const existenceValidator: InputValidator = {
message: () => `Workspace '${name}' already exists`, message: () => `Workspace '${name}' already exists`,
validate: value => !workspaceStore.getByName(value.trim()) validate: value => !workspaceStore.getByName(value.trim())
} }

View File

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

View File

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

View File

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

View File

@ -8,22 +8,26 @@ import { Icon } from "../icon";
import { _i18n } from "../../i18n"; import { _i18n } from "../../i18n";
import { cssNames, downloadFile } from "../../utils"; import { cssNames, downloadFile } from "../../utils";
import { Pod } from "../../api/endpoints"; import { Pod } from "../../api/endpoints";
import { PodLogSearch, PodLogSearchProps } from "./pod-log-search";
interface Props { interface Props extends PodLogSearchProps {
ready: boolean ready: boolean
tabId: string tabId: string
tabData: IPodLogsData tabData: IPodLogsData
logs: string[][] logs: string[]
save: (data: Partial<IPodLogsData>) => void save: (data: Partial<IPodLogsData>) => void
reload: () => void reload: () => void
onSearch: (query: string) => void
} }
export const PodLogControls = observer((props: Props) => { export const PodLogControls = observer((props: Props) => {
if (!props.ready) return null; 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 { 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 pod = new Pod(tabData.pod);
const toggleTimestamps = () => { const toggleTimestamps = () => {
save({ showTimestamps: !showTimestamps }); save({ showTimestamps: !showTimestamps });
} }
@ -35,8 +39,7 @@ export const PodLogControls = observer((props: Props) => {
const downloadLogs = () => { const downloadLogs = () => {
const fileName = selectedContainer ? selectedContainer.name : pod.getName(); const fileName = selectedContainer ? selectedContainer.name : pod.getName();
const [oldLogs, newLogs] = logs; downloadFile(fileName + ".log", logs.join("\n"), "text/plain");
downloadFile(fileName + ".log", [...oldLogs, ...newLogs].join("\n"), "text/plain");
} }
const onContainerChange = (option: SelectOption) => { const onContainerChange = (option: SelectOption) => {
@ -92,7 +95,7 @@ export const PodLogControls = observer((props: Props) => {
</> </>
)} )}
</div> </div>
<div className="flex gaps"> <div className="flex box grow gaps align-center">
<Icon <Icon
material="av_timer" material="av_timer"
onClick={toggleTimestamps} onClick={toggleTimestamps}
@ -109,7 +112,9 @@ export const PodLogControls = observer((props: Props) => {
material="get_app" material="get_app"
onClick={downloadLogs} onClick={downloadLogs}
tooltip={_i18n._(t`Save`)} tooltip={_i18n._(t`Save`)}
className="download-icon"
/> />
<PodLogSearch {...props} />
</div> </div>
</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: overlay` don't allow scroll to the last line
overflow: auto; overflow: auto;
position: relative;
color: $textColorAccent; color: $textColorAccent;
background: $logsBackground; background: $logsBackground;
line-height: var(--log-line-height); flex-grow: 1;
.find-overlay {
position: absolute;
border-radius: 2px; border-radius: 2px;
padding: $padding * 2; 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-family: $font-monospace;
font-size: smaller; font-size: smaller;
white-space: pre; white-space: pre;
flex-grow: 1; -webkit-font-smoothing: auto; // Better readability on non-retina screens
> div { &:hover {
// Provides font better readability on large screens background: $logRowHoverBackground;
-webkit-font-smoothing: subpixel-antialiased; }
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; padding: $unit / 2 $unit * 1.5;
border-radius: $unit * 2; border-radius: $unit * 2;
opacity: 0; opacity: 0;
transition: opacity 0.2s; z-index: 2;
top: 20px;
&.active { &.active {
opacity: 1; opacity: 1;
@ -57,4 +85,20 @@
--size: $unit * 2; --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 { dockStore, IDockTab, TabKind } from "./dock.store";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
import { _i18n } from "../../i18n"; import { _i18n } from "../../i18n";
import { isDevelopment } from "../../../common/vars"; import { searchStore } from "../../../common/search-store";
export interface IPodLogsData { export interface IPodLogsData {
pod: Pod; pod: Pod;
@ -20,7 +20,7 @@ type TabId = string;
type PodLogLine = string; type PodLogLine = string;
// Number for log lines to load // Number for log lines to load
export const logRange = isDevelopment ? 100 : 1000; export const logRange = 500;
@autobind() @autobind()
export class PodLogsStore extends DockTabStore<IPodLogsData> { export class PodLogsStore extends DockTabStore<IPodLogsData> {
@ -49,6 +49,11 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
reaction(() => this.logs.get(dockStore.selectedTabId), () => { reaction(() => this.logs.get(dockStore.selectedTabId), () => {
this.setNewLogSince(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 * @param tabId
*/ */
loadMore = async (tabId: TabId) => { loadMore = async (tabId: TabId) => {
if (!this.logs.get(tabId).length) return;
const oldLogs = this.logs.get(tabId); const oldLogs = this.logs.get(tabId);
const logs = await this.loadLogs(tabId, { const logs = await this.loadLogs(tabId, {
sinceTime: this.getLastSinceTime(tabId) sinceTime: this.getLastSinceTime(tabId)
@ -120,7 +126,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
* @param tabId * @param tabId
*/ */
setNewLogSince(tabId: 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); const timestamp = this.getLastSinceTime(tabId);
this.newLogSince.set(tabId, timestamp.split(".")[0]); // Removing milliseconds from string this.newLogSince.set(tabId, timestamp.split(".")[0]); // Removing milliseconds from string
} }

View File

@ -1,9 +1,7 @@
import "./pod-logs.scss"; import "./pod-logs.scss";
import React from "react"; import React from "react";
import AnsiUp from "ansi_up"; import { Trans } from "@lingui/macro";
import DOMPurify from "dompurify"; import { action, computed, observable, reaction } from "mobx";
import { t, Trans } from "@lingui/macro";
import { computed, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { _i18n } from "../../i18n"; import { _i18n } from "../../i18n";
import { autobind, cssNames } from "../../utils"; import { autobind, cssNames } from "../../utils";
@ -14,30 +12,33 @@ import { InfoPanel } from "./info-panel";
import { IPodLogsData, logRange, podLogsStore } from "./pod-logs.store"; import { IPodLogsData, logRange, podLogsStore } from "./pod-logs.store";
import { Button } from "../button"; import { Button } from "../button";
import { PodLogControls } from "./pod-log-controls"; import { PodLogControls } from "./pod-log-controls";
import { VirtualList } from "../virtual-list";
import { searchStore } from "../../../common/search-store";
import { ListOnScrollProps } from "react-window";
interface Props { interface Props {
className?: string className?: string
tab: IDockTab tab: IDockTab
} }
const lineHeight = 18; // Height of a log line. Should correlate with styles in pod-logs.scss
@observer @observer
export class PodLogs extends React.Component<Props> { export class PodLogs extends React.Component<Props> {
@observable ready = false; @observable ready = false;
@observable preloading = false; // Indicator for setting Spinner (loader) at the top of the logs @observable preloading = false; // Indicator for setting Spinner (loader) at the top of the logs
@observable showJumpToBottom = false; @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 lastLineIsShown = true; // used for proper auto-scroll content after refresh
private colorConverter = new AnsiUp();
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => this.props.tab.id, async () => { reaction(() => this.props.tab.id, async () => {
if (podLogsStore.logs.has(this.tabId)) {
this.ready = true;
return;
}
await this.load(); await this.load();
this.scrollToBottom();
}, { fireImmediately: true }), }, { fireImmediately: true }),
// Check if need to show JumpToBottom if new log amount is less than previous one // 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() { componentDidUpdate() {
// scroll logs only when it's already in the end, // scroll logs only when it's already in the end,
// otherwise it can interrupt reading by jumping after loading new logs update // otherwise it can interrupt reading by jumping after loading new logs update
if (this.logsElement && this.lastLineIsShown) { if (this.logsElement.current && this.lastLineIsShown) {
this.logsElement.scrollTop = this.logsElement.scrollHeight; 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 * Function loads more logs (usually after user scrolls to top) and sets proper
* scrolling position * scrolling position
* @param scrollHeight previous scrollHeight position before adding new lines
*/ */
loadMore = async (scrollHeight: number) => { loadMore = async () => {
if (podLogsStore.lines < logRange) return; const lines = podLogsStore.lines;
if (lines < logRange) return;
this.preloading = true; this.preloading = true;
await podLogsStore.load(this.tabId).then(() => this.preloading = false); await podLogsStore.load(this.tabId);
if (this.logsElement.scrollHeight > scrollHeight) { this.preloading = false;
if (podLogsStore.lines > lines) {
// Set scroll position back to place where preloading started // 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 * A function for various actions after search is happened
* does separation between new and old logs * @param query {string} A text from search field
* @returns {Array} An array with 2 items - [oldLogs, newLogs]
*/ */
@computed @autobind()
get logs() { onSearch(query: string) {
if (!podLogsStore.logs.has(this.tabId)) return []; this.toOverlay();
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];
} }
onScroll = (evt: React.UIEvent<HTMLDivElement>) => { /**
const logsArea = evt.currentTarget; * Scrolling to active overlay (search word highlight)
const toBottomOffset = 100 * 16; // 100 lines * 16px (height of each line) */
const { scrollHeight, clientHeight, scrollTop } = logsArea; @autobind()
if (scrollTop === 0) { toOverlay() {
this.loadMore(scrollHeight); 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);
} }
if (scrollHeight - scrollTop > toBottomOffset) {
this.showJumpToBottom = true; /**
} else { * 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));
}
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; this.showJumpToBottom = false;
} }
this.lastLineIsShown = clientHeight + scrollTop === scrollHeight; if (clientHeight + scrollOffset === scrollHeight) {
}; this.lastLineIsShown = true;
}
} else {
this.lastLineIsShown = false;
// Trigger loading only if scrolled by user
if (scrollOffset === 0 && !scrollUpdateWasRequested) {
this.loadMore();
}
if (scrollHeight - scrollOffset > toBottomOffset) {
this.showJumpToBottom = true;
}
}
}
@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() { renderJumpToBottom() {
if (!this.logsElement) return null; 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})} className={cssNames("jump-to-bottom flex gaps", {active: this.showJumpToBottom})}
onClick={evt => { onClick={evt => {
evt.currentTarget.blur(); evt.currentTarget.blur();
this.logsElement.scrollTo({ this.scrollToBottom();
top: this.logsElement.scrollHeight,
behavior: "auto"
});
}} }}
> >
<Trans>Jump to bottom</Trans> <Trans>Jump to bottom</Trans>
@ -162,13 +230,15 @@ export class PodLogs extends React.Component<Props> {
} }
renderLogs() { 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) { if (!this.ready) {
return <Spinner center/>; return <Spinner center/>;
} }
if (!oldLogs.length && !newLogs.length) { if (!this.logs.length) {
return ( 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> <Trans>There are no logs available for container.</Trans>
</div> </div>
); );
@ -177,16 +247,18 @@ export class PodLogs extends React.Component<Props> {
<> <>
{this.preloading && ( {this.preloading && (
<div className="flex justify-center"> <div className="flex justify-center">
<Spinner /> <Spinner center />
</div> </div>
)} )}
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(oldLogs.join("\n"))) }} /> <VirtualList
{newLogs.length > 0 && ( items={this.logs}
<> rowHeights={rowHeights}
<p className="new-logs-sep" title={_i18n._(t`New logs since opening logs tab`)}/> getRow={this.getLogRow}
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(newLogs.join("\n"))) }} /> 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} logs={this.logs}
save={this.save} save={this.save}
reload={this.reload} reload={this.reload}
onSearch={this.onSearch}
toPrevOverlay={this.toOverlay}
toNextOverlay={this.toOverlay}
/> />
) )
return ( return (
<div className={cssNames("PodLogs flex column", className)}> <div className={cssNames("PodLogs flex column", className, { noscroll: this.hideHorizontalScroll })}>
<InfoPanel <InfoPanel
tabId={this.props.tab.id} tabId={this.props.tab.id}
controls={controls} controls={controls}
showSubmitClose={false} showSubmitClose={false}
showButtons={false} showButtons={false}
/> />
<div className="logs" onScroll={this.onScroll} ref={e => this.logsElement = e}> <div className="logs flex">
{this.renderJumpToBottom()} {this.renderJumpToBottom()}
{this.renderLogs()} {this.renderLogs()}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -4,26 +4,26 @@ import { t } from "@lingui/macro";
import { _i18n } from '../../i18n'; import { _i18n } from '../../i18n';
import fse from "fs-extra"; import fse from "fs-extra";
export interface Validator { export interface InputValidator {
debounce?: number; // debounce for async validators in ms debounce?: number; // debounce for async validators in ms
condition?(props: InputProps): boolean; // auto-bind condition depending on input props condition?(props: InputProps): boolean; // auto-bind condition depending on input props
message?: ReactNode | ((value: string, props?: InputProps) => ReactNode | string); message?: ReactNode | ((value: string, props?: InputProps) => ReactNode | string);
validate(value: string, props?: InputProps): boolean | Promise<any>; // promise can throw error message validate(value: string, props?: InputProps): boolean | Promise<any>; // promise can throw error message
} }
export const isRequired: Validator = { export const isRequired: InputValidator = {
condition: ({ required }) => required, condition: ({ required }) => required,
message: () => _i18n._(t`This field is required`), message: () => _i18n._(t`This field is required`),
validate: value => !!value.trim(), validate: value => !!value.trim(),
}; };
export const isEmail: Validator = { export const isEmail: InputValidator = {
condition: ({ type }) => type === "email", condition: ({ type }) => type === "email",
message: () => _i18n._(t`Wrong email format`), 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,}))$/), 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", condition: ({ type }) => type === "number",
message: () => _i18n._(t`Invalid number`), message: () => _i18n._(t`Invalid number`),
validate: (value, { min, max }) => { validate: (value, { min, max }) => {
@ -36,37 +36,37 @@ export const isNumber: Validator = {
}, },
}; };
export const isUrl: Validator = { export const isUrl: InputValidator = {
condition: ({ type }) => type === "url", condition: ({ type }) => type === "url",
message: () => _i18n._(t`Wrong url format`), message: () => _i18n._(t`Wrong url format`),
validate: value => !!value.match(/^$|^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/), validate: value => !!value.match(/^$|^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
}; };
export const isPath: Validator = { export const isPath: InputValidator = {
condition: ({ type }) => type === "text", condition: ({ type }) => type === "text",
message: () => _i18n._(t`This field must be a valid path`), message: () => _i18n._(t`This field must be a valid path`),
validate: value => !value || fse.pathExistsSync(value), validate: value => !value || fse.pathExistsSync(value),
} }
export const minLength: Validator = { export const minLength: InputValidator = {
condition: ({ minLength }) => !!minLength, condition: ({ minLength }) => !!minLength,
message: (value, { minLength }) => _i18n._(t`Minimum length is ${minLength}`), message: (value, { minLength }) => _i18n._(t`Minimum length is ${minLength}`),
validate: (value, { minLength }) => value.length >= minLength, validate: (value, { minLength }) => value.length >= minLength,
}; };
export const maxLength: Validator = { export const maxLength: InputValidator = {
condition: ({ maxLength }) => !!maxLength, condition: ({ maxLength }) => !!maxLength,
message: (value, { maxLength }) => _i18n._(t`Maximum length is ${maxLength}`), message: (value, { maxLength }) => _i18n._(t`Maximum length is ${maxLength}`),
validate: (value, { maxLength }) => value.length <= 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])?)*$/; 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.`), 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), validate: value => !!value.match(systemNameMatcher),
}; };
export const accountId: Validator = { export const accountId: InputValidator = {
message: () => _i18n._(t`Invalid account ID`), message: () => _i18n._(t`Invalid account ID`),
validate: value => (isEmail.validate(value) || systemName.validate(value)) 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 { > label {
color: inherit; color: inherit;
background: none;
border: none;
border-radius: $radius; border-radius: $radius;
box-shadow: 0 0 0 1px $halfGray;
padding: 6px 6px 6px 10px; padding: 6px 6px 6px 10px;
.Icon { .Icon {

View File

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

View File

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

View File

@ -1,3 +1,4 @@
export * from "./kube-object-details" export * from "./kube-object-details"
export * from "./kube-object-list-layout" export * from "./kube-object-list-layout"
export * from "./kube-object-menu" 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 { apiManager } from "../../api/api-manager";
import { crdStore } from "../+custom-resources/crd.store"; import { crdStore } from "../+custom-resources/crd.store";
import { CrdResourceDetails } from "../+custom-resources"; 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"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry";
export interface KubeObjectDetailsProps<T = KubeObject> { export interface KubeObjectDetailsProps<T = KubeObject> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,8 +5,8 @@ import isObject from "lodash/isObject"
import uniqueId from "lodash/uniqueId"; import uniqueId from "lodash/uniqueId";
import { JsonApiErrorParsed } from "../../api/json-api"; import { JsonApiErrorParsed } from "../../api/json-api";
export type IMessageId = string | number; export type NotificationId = string | number;
export type IMessage = React.ReactNode | React.ReactNode[] | JsonApiErrorParsed; export type NotificationMessage = React.ReactNode | React.ReactNode[] | JsonApiErrorParsed;
export enum NotificationStatus { export enum NotificationStatus {
OK = "ok", OK = "ok",
@ -14,20 +14,20 @@ export enum NotificationStatus {
INFO = "info", INFO = "info",
} }
export interface INotification { export interface Notification {
id?: IMessageId; id?: NotificationId;
message: IMessage; message: NotificationMessage;
status?: NotificationStatus; status?: NotificationStatus;
timeout?: number; // auto-hiding timeout in milliseconds, 0 = no hide timeout?: number; // auto-hiding timeout in milliseconds, 0 = no hide
} }
@autobind() @autobind()
export class NotificationsStore { 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); this.removeAutoHideTimer(notification);
const { id, timeout } = notification; const { id, timeout } = notification;
if (timeout) { if (timeout) {
@ -36,7 +36,7 @@ export class NotificationsStore {
} }
} }
removeAutoHideTimer(notification: INotification) { removeAutoHideTimer(notification: Notification) {
const { id } = notification; const { id } = notification;
if (this.autoHideTimers.has(id)) { if (this.autoHideTimers.has(id)) {
clearTimeout(this.autoHideTimers.get(id)); clearTimeout(this.autoHideTimers.get(id));
@ -45,7 +45,7 @@ export class NotificationsStore {
} }
@action @action
add(notification: INotification) { add(notification: Notification) {
if (!notification.id) { if (!notification.id) {
notification.id = uniqueId("notification_"); notification.id = uniqueId("notification_");
} }
@ -56,11 +56,11 @@ export class NotificationsStore {
} }
@action @action
remove(itemOrId: IMessageId | INotification) { remove(itemOrId: NotificationId | Notification) {
if (!isObject(itemOrId)) { if (!isObject(itemOrId)) {
itemOrId = this.notifications.find(item => item.id === 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 { disposeOnUnmount, observer } from "mobx-react"
import { JsonApiErrorParsed } from "../../api/json-api"; import { JsonApiErrorParsed } from "../../api/json-api";
import { cssNames, prevDefault } from "../../utils"; 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 { Animate } from "../animate";
import { Icon } from "../icon" import { Icon } from "../icon"
@ -13,7 +13,7 @@ import { Icon } from "../icon"
export class Notifications extends React.Component { export class Notifications extends React.Component {
public elem: HTMLElement; public elem: HTMLElement;
static ok(message: IMessage) { static ok(message: NotificationMessage) {
notificationsStore.add({ notificationsStore.add({
message: message, message: message,
timeout: 2500, timeout: 2500,
@ -21,7 +21,7 @@ export class Notifications extends React.Component {
}) })
} }
static error(message: IMessage) { static error(message: NotificationMessage) {
notificationsStore.add({ notificationsStore.add({
message: message, message: message,
timeout: 10000, 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({ return notificationsStore.add({
status: NotificationStatus.INFO, status: NotificationStatus.INFO,
timeout: 0, timeout: 0,
@ -56,7 +56,7 @@ export class Notifications extends React.Component {
}) })
} }
getMessage(notification: INotification) { getMessage(notification: Notification) {
let { message } = notification; let { message } = notification;
if (message instanceof JsonApiErrorParsed) { if (message instanceof JsonApiErrorParsed) {
message = message.toString(); message = message.toString();

View File

@ -5,7 +5,7 @@ import uniqueId from "lodash/uniqueId";
// todo: refactor with contexts // todo: refactor with contexts
interface RadioGroupProps { export interface RadioGroupProps {
className?: any; className?: any;
value?: any; value?: any;
asButtons?: boolean; 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; name?: string;
label?: React.ReactNode | any; label?: React.ReactNode | any;
value?: any; value?: any;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import "./table-cell.scss"; import "./table-cell.scss";
import type { SortBy, SortParams } from "./table"; import type { TableSortBy, TableSortParams } from "./table";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { autobind, cssNames } from "../../utils"; import { autobind, cssNames } from "../../utils";
@ -13,9 +13,9 @@ export interface TableCellProps extends React.DOMAttributes<HTMLDivElement> {
title?: ReactNode; title?: ReactNode;
checkbox?: boolean; // render cell with a checkbox checkbox?: boolean; // render cell with a checkbox
isChecked?: boolean; // mark checkbox as checked or not isChecked?: boolean; // mark checkbox as checked or not
sortBy?: SortBy; // column name, must be same as key in sortable object <Table sortable={}/> sortBy?: TableSortBy; // column name, must be same as key in sortable object <Table sortable={}/>
_sorting?: Partial<SortParams>; // <Table> sorting state, don't use this prop outside (!) _sorting?: Partial<TableSortParams>; // <Table> sorting state, don't use this prop outside (!)
_sort?(sortBy: SortBy): void; // <Table> sort function, 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 (!) _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 // todo: refactor + decouple search from location
export type SortBy = string; export type TableSortBy = string;
export type OrderBy = "asc" | "desc" | string; export type TableOrderBy = "asc" | "desc" | string;
export type SortParams = { sortBy: SortBy; orderBy: OrderBy } export type TableSortParams = { sortBy: TableSortBy; orderBy: TableOrderBy }
export type SortingCallback<D = any> = (data: D) => string | number | (string | number)[]; export type TableSortCallback<D = any> = (data: D) => string | number | (string | number)[];
export interface TableProps extends React.DOMAttributes<HTMLDivElement> { export interface TableProps extends React.DOMAttributes<HTMLDivElement> {
items?: ItemObject[]; // Raw items data items?: ItemObject[]; // Raw items data
@ -29,11 +29,11 @@ export interface TableProps extends React.DOMAttributes<HTMLDivElement> {
sortable?: { sortable?: {
// Define sortable callbacks for every column in <TableHead><TableCell sortBy="someCol"><TableHead> // 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}/> // @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 sortSyncWithUrl?: boolean; // sorting state is managed globally from url params
sortByDefault?: Partial<SortParams>; // default sorting params sortByDefault?: Partial<TableSortParams>; // default sorting params
onSort?: (params: SortParams) => void; // callback on sort change, default: global sync with url onSort?: (params: TableSortParams) => void; // callback on sort change, default: global sync with url
noItems?: React.ReactNode; // Show no items state table list is empty noItems?: React.ReactNode; // Show no items state table list is empty
selectedItemId?: string; // Allows to scroll list to selected item selectedItemId?: string; // Allows to scroll list to selected item
virtual?: boolean; // Use virtual list component to render only visible rows 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; @observable sortParamsLocal = this.props.sortByDefault;
@computed get sortParams(): Partial<SortParams> { @computed get sortParams(): Partial<TableSortParams> {
if (this.props.sortSyncWithUrl) { if (this.props.sortSyncWithUrl) {
const sortBy = navigation.searchParams.get("sortBy") const sortBy = navigation.searchParams.get("sortBy")
const orderBy = navigation.searchParams.get("orderBy") const orderBy = navigation.searchParams.get("orderBy")
@ -105,7 +105,7 @@ export class Table extends React.Component<TableProps> {
} }
@autobind() @autobind()
protected onSort(params: SortParams) { protected onSort(params: TableSortParams) {
const { sortSyncWithUrl, onSort } = this.props; const { sortSyncWithUrl, onSort } = this.props;
if (sortSyncWithUrl) { if (sortSyncWithUrl) {
setQueryParams(params) setQueryParams(params)
@ -119,11 +119,11 @@ export class Table extends React.Component<TableProps> {
} }
@autobind() @autobind()
sort(colName: SortBy) { sort(colName: TableSortBy) {
const { sortBy, orderBy } = this.sortParams; const { sortBy, orderBy } = this.sortParams;
const sameColumn = sortBy == colName; const sameColumn = sortBy == colName;
const newSortBy: SortBy = colName; const newSortBy: TableSortBy = colName;
const newOrderBy: OrderBy = (!orderBy || !sameColumn || orderBy === "desc") ? "asc" : "desc"; const newOrderBy: TableOrderBy = (!orderBy || !sameColumn || orderBy === "desc") ? "asc" : "desc";
this.onSort({ this.onSort({
sortBy: String(newSortBy), sortBy: String(newSortBy),
orderBy: newOrderBy, orderBy: newOrderBy,
@ -159,7 +159,7 @@ export class Table extends React.Component<TableProps> {
<VirtualList <VirtualList
items={sortedItems} items={sortedItems}
rowHeights={rowHeights} rowHeights={rowHeights}
getTableRow={getTableRow} getRow={getTableRow}
selectedItemId={selectedItemId} selectedItemId={selectedItemId}
className={className} className={className}
/> />

View File

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

View File

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

View File

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

View File

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

View File

@ -93,6 +93,7 @@ $terminalBrightWhite: var(--terminalBrightWhite);
// Logs // Logs
$logsBackground: var(--logsBackground); $logsBackground: var(--logsBackground);
$logRowHoverBackground: var(--logRowHoverBackground);
// Dialogs // Dialogs
$dialogTextColor: var(--dialogTextColor); $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