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

Merge branch 'master' into fix-k0s-distribution-detection

This commit is contained in:
Lauri Nevala 2020-12-23 14:34:34 +02:00 committed by GitHub
commit 6c93cb8c24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 227 additions and 132 deletions

View File

@ -10,7 +10,7 @@ jest.mock("chokidar", () => ({
jest.mock("../extension-installer", () => ({ jest.mock("../extension-installer", () => ({
extensionInstaller: { extensionInstaller: {
extensionPackagesRoot: "", extensionPackagesRoot: "",
installPackages: jest.fn() installPackage: jest.fn()
} }
})); }));
@ -41,7 +41,7 @@ describe("ExtensionDiscovery", () => {
// Need to force isLoaded to be true so that the file watching is started // Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true; extensionDiscovery.isLoaded = true;
await extensionDiscovery.initMain(); await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", (extension: InstalledExtension) => { extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
expect(extension).toEqual({ expect(extension).toEqual({
@ -81,7 +81,7 @@ describe("ExtensionDiscovery", () => {
// Need to force isLoaded to be true so that the file watching is started // Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true; extensionDiscovery.isLoaded = true;
await extensionDiscovery.initMain(); await extensionDiscovery.watchExtensions();
const onAdd = jest.fn(); const onAdd = jest.fn();

View File

@ -55,6 +55,7 @@ export class ExtensionDiscovery {
protected bundledFolderPath: string; protected bundledFolderPath: string;
private loadStarted = false; private loadStarted = false;
private extensions: Map<string, InstalledExtension> = new Map();
// True if extensions have been loaded from the disk after app startup // True if extensions have been loaded from the disk after app startup
@observable isLoaded = false; @observable isLoaded = false;
@ -69,13 +70,6 @@ export class ExtensionDiscovery {
this.events = new EventEmitter(); this.events = new EventEmitter();
} }
// Each extension is added as a single dependency to this object, which is written as package.json.
// Each dependency key is the name of the dependency, and
// each dependency value is the non-symlinked path to the dependency (folder).
protected packagesJson: PackageJson = {
dependencies: {}
};
get localFolderPath(): string { get localFolderPath(): string {
return path.join(os.homedir(), ".k8slens", "extensions"); return path.join(os.homedir(), ".k8slens", "extensions");
} }
@ -119,7 +113,6 @@ export class ExtensionDiscovery {
} }
async initMain() { async initMain() {
this.watchExtensions();
handleRequest(ExtensionDiscovery.extensionDiscoveryChannel, () => this.toJSON()); handleRequest(ExtensionDiscovery.extensionDiscoveryChannel, () => this.toJSON());
reaction(() => this.toJSON(), () => { reaction(() => this.toJSON(), () => {
@ -141,6 +134,7 @@ export class ExtensionDiscovery {
watch(this.localFolderPath, { watch(this.localFolderPath, {
// For adding and removing symlinks to work, the depth has to be 1. // For adding and removing symlinks to work, the depth has to be 1.
depth: 1, depth: 1,
ignoreInitial: true,
// Try to wait until the file has been completely copied. // Try to wait until the file has been completely copied.
// The OS might emit an event for added file even it's not completely written to the filesysten. // The OS might emit an event for added file even it's not completely written to the filesysten.
awaitWriteFinish: { awaitWriteFinish: {
@ -176,8 +170,9 @@ export class ExtensionDiscovery {
await this.removeSymlinkByManifestPath(manifestPath); await this.removeSymlinkByManifestPath(manifestPath);
// Install dependencies for the new extension // Install dependencies for the new extension
await this.installPackages(); await this.installPackage(extension.absolutePath);
this.extensions.set(extension.id, extension);
logger.info(`${logModule} Added extension ${extension.manifest.name}`); logger.info(`${logModule} Added extension ${extension.manifest.name}`);
this.events.emit("add", extension); this.events.emit("add", extension);
} }
@ -197,23 +192,19 @@ export class ExtensionDiscovery {
const extensionFolderName = path.basename(filePath); const extensionFolderName = path.basename(filePath);
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) { if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
const extensionName: string | undefined = Object const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
.entries(this.packagesJson.dependencies)
.find(([, extensionFolder]) => filePath === extensionFolder)?.[0]; if (extension) {
const extensionName = extension.manifest.name;
if (extensionName !== undefined) {
// If the extension is deleted manually while the application is running, also remove the symlink // If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName); await this.removeSymlinkByPackageName(extensionName);
delete this.packagesJson.dependencies[extensionName];
// Reinstall dependencies to remove the extension from package.json
await this.installPackages();
// The path to the manifest file is the lens extension id // The path to the manifest file is the lens extension id
// Note that we need to use the symlinked path // Note that we need to use the symlinked path
const lensExtensionId = path.join(this.nodeModulesPath, extensionName, manifestFilename); const lensExtensionId = extension.manifestPath;
this.extensions.delete(extension.id);
logger.info(`${logModule} removed extension ${extensionName}`); logger.info(`${logModule} removed extension ${extensionName}`);
this.events.emit("remove", lensExtensionId as LensExtensionId); this.events.emit("remove", lensExtensionId as LensExtensionId);
} else { } else {
@ -296,7 +287,7 @@ export class ExtensionDiscovery {
await fs.ensureDir(this.nodeModulesPath); await fs.ensureDir(this.nodeModulesPath);
await fs.ensureDir(this.localFolderPath); await fs.ensureDir(this.localFolderPath);
const extensions = await this.loadExtensions(); const extensions = await this.ensureExtensions();
this.isLoaded = true; this.isLoaded = true;
@ -335,7 +326,6 @@ export class ExtensionDiscovery {
manifestJson = __non_webpack_require__(manifestPath); manifestJson = __non_webpack_require__(manifestPath);
const installedManifestPath = this.getInstalledManifestPath(manifestJson.name); const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath);
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
return { return {
@ -347,29 +337,46 @@ export class ExtensionDiscovery {
isEnabled isEnabled
}; };
} catch (error) { } catch (error) {
logger.error(`${logModule}: can't install extension at ${manifestPath}: ${error}`, { manifestJson }); logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`, { manifestJson });
return null; return null;
} }
} }
async loadExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> { async ensureExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
const bundledExtensions = await this.loadBundledExtensions(); const bundledExtensions = await this.loadBundledExtensions();
await this.installPackages(); // install in-tree as a separate step await this.installBundledPackages(this.packageJsonPath, bundledExtensions);
const localExtensions = await this.loadFromFolder(this.localFolderPath);
await this.installPackages(); const userExtensions = await this.loadFromFolder(this.localFolderPath);
const extensions = bundledExtensions.concat(localExtensions);
return new Map(extensions.map(extension => [extension.id, extension])); for (const extension of userExtensions) {
if (await fs.pathExists(extension.manifestPath) === false) {
await this.installPackage(extension.absolutePath);
}
}
const extensions = bundledExtensions.concat(userExtensions);
return this.extensions = new Map(extensions.map(extension => [extension.id, extension]));
} }
/** /**
* Write package.json to file system and install dependencies. * Write package.json to file system and install dependencies.
*/ */
installPackages() { async installBundledPackages(packageJsonPath: string, extensions: InstalledExtension[]) {
return extensionInstaller.installPackages(this.packageJsonPath, this.packagesJson); const packagesJson: PackageJson = {
dependencies: {}
};
extensions.forEach((extension) => {
packagesJson.dependencies[extension.manifest.name] = extension.absolutePath;
});
return await extensionInstaller.installPackages(packageJsonPath, packagesJson);
}
async installPackage(name: string) {
return extensionInstaller.installPackage(name);
} }
async loadBundledExtensions() { async loadBundledExtensions() {

View File

@ -30,12 +30,49 @@ export class ExtensionInstaller {
return __non_webpack_require__.resolve("npm/bin/npm-cli"); return __non_webpack_require__.resolve("npm/bin/npm-cli");
} }
installDependencies(): Promise<void> { /**
return new Promise((resolve, reject) => { * Write package.json to the file system and execute npm install for it.
*/
async installPackages(packageJsonPath: string, packagesJson: PackageJson): Promise<void> {
// Mutual exclusion to install packages in sequence
await this.installLock.acquireAsync();
try {
// Write the package.json which will be installed in .installDependencies()
await fs.writeFile(path.join(packageJsonPath), JSON.stringify(packagesJson, null, 2), {
mode: 0o600
});
logger.info(`${logModule} installing dependencies at ${extensionPackagesRoot()}`); logger.info(`${logModule} installing dependencies at ${extensionPackagesRoot()}`);
const child = child_process.fork(this.npmPath, ["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], { await this.npm(["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"]);
logger.info(`${logModule} dependencies installed at ${extensionPackagesRoot()}`);
} finally {
this.installLock.release();
}
}
/**
* Install single package using npm
*/
async installPackage(name: string): Promise<void> {
// Mutual exclusion to install packages in sequence
await this.installLock.acquireAsync();
try {
logger.info(`${logModule} installing package from ${name} to ${extensionPackagesRoot()}`);
await this.npm(["install", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock", "--no-save", name]);
logger.info(`${logModule} package ${name} installed to ${extensionPackagesRoot()}`);
} finally {
this.installLock.release();
}
}
private npm(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = child_process.fork(this.npmPath, args, {
cwd: extensionPackagesRoot(), cwd: extensionPackagesRoot(),
silent: true silent: true,
env: {}
}); });
let stderr = ""; let stderr = "";
@ -56,25 +93,6 @@ export class ExtensionInstaller {
}); });
}); });
} }
/**
* Write package.json to the file system and execute npm install for it.
*/
async installPackages(packageJsonPath: string, packagesJson: PackageJson): Promise<void> {
// Mutual exclusion to install packages in sequence
await this.installLock.acquireAsync();
try {
// Write the package.json which will be installed in .installDependencies()
await fs.writeFile(path.join(packageJsonPath), JSON.stringify(packagesJson, null, 2), {
mode: 0o600
});
await this.installDependencies();
} finally {
this.installLock.release();
}
}
} }
export const extensionInstaller = new ExtensionInstaller(); export const extensionInstaller = new ExtensionInstaller();

View File

@ -12,6 +12,7 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ".
import type { LensMainExtension } from "./lens-main-extension"; import type { LensMainExtension } from "./lens-main-extension";
import type { LensRendererExtension } from "./lens-renderer-extension"; import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries"; import * as registries from "./registries";
import fs from "fs";
// lazy load so that we get correct userData // lazy load so that we get correct userData
export function extensionPackagesRoot() { export function extensionPackagesRoot() {
@ -115,7 +116,6 @@ export class ExtensionLoader {
protected async initMain() { protected async initMain() {
this.isLoaded = true; this.isLoaded = true;
this.loadOnMain(); this.loadOnMain();
this.broadcastExtensions();
reaction(() => this.toJSON(), () => { reaction(() => this.toJSON(), () => {
this.broadcastExtensions(); this.broadcastExtensions();
@ -276,6 +276,12 @@ export class ExtensionLoader {
} }
if (extEntrypoint !== "") { if (extEntrypoint !== "") {
if (!fs.existsSync(extEntrypoint)) {
console.log(`${logModule}: entrypoint ${extEntrypoint} not found, skipping ...`);
return;
}
return __non_webpack_require__(extEntrypoint).default; return __non_webpack_require__(extEntrypoint).default;
} }
} catch (err) { } catch (err) {

View File

@ -40,14 +40,26 @@ export class DistributionDetector extends BaseClusterDetector {
return { value: "k0s", accuracy: 80}; return { value: "k0s", accuracy: 80};
} }
if (this.isVMWare()) {
return { value: "vmware", accuracy: 90};
}
if (this.isMirantis()) { if (this.isMirantis()) {
return { value: "mirantis", accuracy: 90}; return { value: "mirantis", accuracy: 90};
} }
if (this.isAlibaba()) {
return { value: "alibaba", accuracy: 90};
}
if (this.isHuawei()) { if (this.isHuawei()) {
return { value: "huawei", accuracy: 90}; return { value: "huawei", accuracy: 90};
} }
if (this.isTke()) {
return { value: "tencent", accuracy: 90};
}
if (this.isMinikube()) { if (this.isMinikube()) {
return { value: "minikube", accuracy: 80}; return { value: "minikube", accuracy: 80};
} }
@ -123,10 +135,18 @@ export class DistributionDetector extends BaseClusterDetector {
return this.cluster.contextName === "docker-desktop"; return this.cluster.contextName === "docker-desktop";
} }
protected isTke() {
return this.version.includes("-tke.");
}
protected isCustom() { protected isCustom() {
return this.version.includes("+"); return this.version.includes("+");
} }
protected isVMWare() {
return this.version.includes("+vmware");
}
protected isRke() { protected isRke() {
return this.version.includes("-rancher"); return this.version.includes("-rancher");
} }
@ -139,6 +159,10 @@ export class DistributionDetector extends BaseClusterDetector {
return this.version.includes("-k0s"); return this.version.includes("-k0s");
} }
protected isAlibaba() {
return this.version.includes("-aliyun");
}
protected isHuawei() { protected isHuawei() {
return this.version.includes("-CCE"); return this.version.includes("-CCE");
} }

View File

@ -103,7 +103,6 @@ app.on("ready", async () => {
} }
extensionLoader.init(); extensionLoader.init();
extensionDiscovery.init(); extensionDiscovery.init();
windowManager = WindowManager.getInstance<WindowManager>(proxyPort); windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
@ -111,6 +110,9 @@ app.on("ready", async () => {
try { try {
const extensions = await extensionDiscovery.load(); const extensions = await extensionDiscovery.load();
// Start watching after bundled extensions are loaded
extensionDiscovery.watchExtensions();
// Subscribe to extensions that are copied or deleted to/from the extensions folder // Subscribe to extensions that are copied or deleted to/from the extensions folder
extensionDiscovery.events.on("add", (extension: InstalledExtension) => { extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
extensionLoader.addExtension(extension); extensionLoader.addExtension(extension);
@ -122,6 +124,8 @@ app.on("ready", async () => {
extensionLoader.initExtensions(extensions); extensionLoader.initExtensions(extensions);
} catch (error) { } catch (error) {
dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`);
console.error(error);
console.trace();
} }
setTimeout(() => { setTimeout(() => {

View File

@ -24,7 +24,7 @@ const kubectlMap: Map<string, string> = new Map([
["1.15", "1.15.11"], ["1.15", "1.15.11"],
["1.16", "1.16.15"], ["1.16", "1.16.15"],
["1.17", bundledVersion], ["1.17", bundledVersion],
["1.18", "1.18.15"], ["1.18", "1.18.14"],
["1.19", "1.19.5"], ["1.19", "1.19.5"],
["1.20", "1.20.0"] ["1.20", "1.20.0"]
]); ]);

View File

@ -120,6 +120,7 @@ export class ShellSession extends EventEmitter {
if(path.basename(env["PTYSHELL"]) === "zsh") { if(path.basename(env["PTYSHELL"]) === "zsh") {
env["OLD_ZDOTDIR"] = env.ZDOTDIR || env.HOME; env["OLD_ZDOTDIR"] = env.ZDOTDIR || env.HOME;
env["ZDOTDIR"] = this.kubectlBinDir; env["ZDOTDIR"] = this.kubectlBinDir;
env["DISABLE_AUTO_UPDATE"] = "true";
} }
env["PTYPID"] = process.pid.toString(); env["PTYPID"] = process.pid.toString();

View File

@ -14,7 +14,6 @@ import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
import { jobStore } from "../+workloads-jobs/job.store"; import { jobStore } from "../+workloads-jobs/job.store";
import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { cronJobStore } from "../+workloads-cronjobs/cronjob.store";
import { Spinner } from "../spinner";
import { Events } from "../+events"; import { Events } from "../+events";
import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectStore } from "../../kube-object.store";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
@ -24,7 +23,6 @@ interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> {
@observer @observer
export class WorkloadsOverview extends React.Component<Props> { export class WorkloadsOverview extends React.Component<Props> {
@observable isReady = false;
@observable isUnmounting = false; @observable isUnmounting = false;
async componentDidMount() { async componentDidMount() {
@ -61,10 +59,13 @@ export class WorkloadsOverview extends React.Component<Props> {
if (isAllowedResource("events")) { if (isAllowedResource("events")) {
stores.push(eventStore); stores.push(eventStore);
} }
this.isReady = stores.every(store => store.isLoaded);
await Promise.all(stores.map(store => store.loadAll())); const unsubscribeList: Array<() => void> = [];
this.isReady = true;
const unsubscribeList = stores.map(store => store.subscribe()); for (const store of stores) {
await store.loadAll();
unsubscribeList.push(store.subscribe());
}
await when(() => this.isUnmounting); await when(() => this.isUnmounting);
unsubscribeList.forEach(dispose => dispose()); unsubscribeList.forEach(dispose => dispose());
@ -74,11 +75,7 @@ export class WorkloadsOverview extends React.Component<Props> {
this.isUnmounting = true; this.isUnmounting = true;
} }
renderContents() { get contents() {
if (!this.isReady) {
return <Spinner center/>;
}
return ( return (
<> <>
<OverviewStatuses/> <OverviewStatuses/>
@ -94,7 +91,7 @@ export class WorkloadsOverview extends React.Component<Props> {
render() { render() {
return ( return (
<div className="WorkloadsOverview flex column gaps"> <div className="WorkloadsOverview flex column gaps">
{this.renderContents()} {this.contents}
</div> </div>
); );
} }

View File

@ -27,6 +27,7 @@ interface OptionalProps {
showSubmitClose?: boolean; showSubmitClose?: boolean;
showInlineInfo?: boolean; showInlineInfo?: boolean;
showNotifications?: boolean; showNotifications?: boolean;
showStatusPanel?: boolean;
} }
@observer @observer
@ -38,6 +39,7 @@ export class InfoPanel extends Component<Props> {
showSubmitClose: true, showSubmitClose: true,
showInlineInfo: true, showInlineInfo: true,
showNotifications: true, showNotifications: true,
showStatusPanel: true,
}; };
@observable error = ""; @observable error = "";
@ -93,7 +95,7 @@ export class InfoPanel extends Component<Props> {
} }
render() { render() {
const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showButtons, showSubmitClose } = this.props; const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showButtons, showSubmitClose, showStatusPanel } = this.props;
const { submit, close, submitAndClose, waiting } = this; const { submit, close, submitAndClose, waiting } = this;
const isDisabled = !!(disableSubmit || waiting || error); const isDisabled = !!(disableSubmit || waiting || error);
@ -102,9 +104,11 @@ export class InfoPanel extends Component<Props> {
<div className="controls"> <div className="controls">
{controls} {controls}
</div> </div>
<div className="info flex gaps align-center"> {showStatusPanel && (
{waiting ? <><Spinner /> {submittingMessage}</> : this.renderErrorIcon()} <div className="flex gaps align-center">
</div> {waiting ? <><Spinner /> {submittingMessage}</> : this.renderErrorIcon()}
</div>
)}
{showButtons && ( {showButtons && (
<> <>
<Button plain label={<Trans>Cancel</Trans>} onClick={close} /> <Button plain label={<Trans>Cancel</Trans>} onClick={close} />

View File

@ -22,10 +22,9 @@ interface Props extends PodLogSearchProps {
} }
export const PodLogControls = observer((props: Props) => { export const PodLogControls = observer((props: Props) => {
const { tabData, save, reload, tabId, logs } = props; const { tabData, save, reload, logs } = props;
const { selectedContainer, showTimestamps, previous } = tabData; const { selectedContainer, showTimestamps, previous } = tabData;
const rawLogs = podLogsStore.logs.get(tabId) || []; const since = logs.length ? podLogsStore.getTimestamps(logs[0]) : null;
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 = () => {
@ -39,8 +38,9 @@ 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 logsToDownload = showTimestamps ? logs : podLogsStore.logsWithoutTimestamps;
saveFileDialog(`${fileName}.log`, logs.join("\n"), "text/plain"); saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain");
}; };
const onContainerChange = (option: SelectOption) => { const onContainerChange = (option: SelectOption) => {
@ -118,7 +118,10 @@ export const PodLogControls = observer((props: Props) => {
tooltip={_i18n._(t`Save`)} tooltip={_i18n._(t`Save`)}
className="download-icon" className="download-icon"
/> />
<PodLogSearch {...props} /> <PodLogSearch
{...props}
logs={showTimestamps ? logs : podLogsStore.logsWithoutTimestamps}
/>
</div> </div>
</div> </div>
); );

View File

@ -5,7 +5,7 @@ import AnsiUp from "ansi_up";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { action, observable } from "mobx"; import { action, computed, observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Align, ListOnScrollProps } from "react-window"; import { Align, ListOnScrollProps } from "react-window";
@ -15,7 +15,7 @@ import { Button } from "../button";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { VirtualList } from "../virtual-list"; import { VirtualList } from "../virtual-list";
import { logRange } from "./pod-logs.store"; import { podLogsStore } from "./pod-logs.store";
interface Props { interface Props {
logs: string[] logs: string[]
@ -47,23 +47,25 @@ export class PodLogList extends React.Component<Props> {
return; return;
} }
if (logs == prevProps.logs || !this.virtualListDiv.current) return; if (logs == prevProps.logs || !this.virtualListDiv.current) return;
const newLogsLoaded = prevProps.logs.length < logs.length; const newLogsLoaded = prevProps.logs.length < logs.length;
const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0; const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0;
const fewLogsLoaded = logs.length < logRange;
if (this.isLastLineVisible) { if (this.isLastLineVisible || prevProps.logs.length == 0) {
this.scrollToBottom(); // Scroll down to keep user watching/reading experience this.scrollToBottom(); // Scroll down to keep user watching/reading experience
return; return;
} }
if (scrolledToBeginning && newLogsLoaded) { if (scrolledToBeginning && newLogsLoaded) {
this.virtualListDiv.current.scrollTop = (logs.length - prevProps.logs.length) * this.lineHeight; const firstLineContents = prevProps.logs[0];
} const lineToScroll = this.props.logs.findIndex((value) => value == firstLineContents);
if (fewLogsLoaded) { if (lineToScroll !== -1) {
this.isJumpButtonVisible = false; this.scrollToItem(lineToScroll, "start");
}
} }
if (!logs.length) { if (!logs.length) {
@ -71,6 +73,20 @@ export class PodLogList extends React.Component<Props> {
} }
} }
/**
* Returns logs with or without timestamps regarding to showTimestamps prop
*/
@computed
get logs() {
const showTimestamps = podLogsStore.getData(this.props.id).showTimestamps;
if (!showTimestamps) {
return podLogsStore.logsWithoutTimestamps;
}
return this.props.logs;
}
/** /**
* Checks if JumpToBottom button should be visible and sets its observable * Checks if JumpToBottom button should be visible and sets its observable
* @param props Scrolling props from virtual list core * @param props Scrolling props from virtual list core
@ -115,7 +131,6 @@ export class PodLogList extends React.Component<Props> {
@action @action
scrollToBottom = () => { scrollToBottom = () => {
if (!this.virtualListDiv.current) return; if (!this.virtualListDiv.current) return;
this.isJumpButtonVisible = false;
this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight; this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight;
}; };
@ -123,7 +138,13 @@ export class PodLogList extends React.Component<Props> {
this.virtualListRef.current.scrollToItem(index, align); this.virtualListRef.current.scrollToItem(index, align);
}; };
onScroll = debounce((props: ListOnScrollProps) => { onScroll = (props: ListOnScrollProps) => {
if (!this.virtualListDiv.current) return;
this.isLastLineVisible = false;
this.onScrollDebounced(props);
};
onScrollDebounced = debounce((props: ListOnScrollProps) => {
if (!this.virtualListDiv.current) return; if (!this.virtualListDiv.current) return;
this.setButtonVisibility(props); this.setButtonVisibility(props);
this.setLastLineVisibility(props); this.setLastLineVisibility(props);
@ -137,7 +158,7 @@ export class PodLogList extends React.Component<Props> {
*/ */
getLogRow = (rowIndex: number) => { getLogRow = (rowIndex: number) => {
const { searchQuery, isActiveOverlay } = searchStore; const { searchQuery, isActiveOverlay } = searchStore;
const item = this.props.logs[rowIndex]; const item = this.logs[rowIndex];
const contents: React.ReactElement[] = []; const contents: React.ReactElement[] = [];
const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi));
@ -174,20 +195,22 @@ export class PodLogList extends React.Component<Props> {
{contents.length > 1 ? contents : ( {contents.length > 1 ? contents : (
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(item) }} /> <span dangerouslySetInnerHTML={{ __html: ansiToHtml(item) }} />
)} )}
{/* For preserving copy-paste experience and keeping line breaks */}
<br />
</div> </div>
); );
}; };
render() { render() {
const { logs, isLoading } = this.props; const { isLoading } = this.props;
const isInitLoading = isLoading && !logs.length; const isInitLoading = isLoading && !this.logs.length;
const rowHeights = new Array(logs.length).fill(this.lineHeight); const rowHeights = new Array(this.logs.length).fill(this.lineHeight);
if (isInitLoading) { if (isInitLoading) {
return <Spinner center/>; return <Spinner center/>;
} }
if (!logs.length) { if (!this.logs.length) {
return ( return (
<div className="PodLogList flex box grow align-center justify-center"> <div className="PodLogList 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>
@ -198,7 +221,7 @@ export class PodLogList extends React.Component<Props> {
return ( return (
<div className={cssNames("PodLogList flex", { isLoading })}> <div className={cssNames("PodLogList flex", { isLoading })}>
<VirtualList <VirtualList
items={logs} items={this.logs}
rowHeights={rowHeights} rowHeights={rowHeights}
getRow={this.getLogRow} getRow={this.getLogRow}
onScroll={this.onScroll} onScroll={this.onScroll}

View File

@ -12,10 +12,13 @@ export interface PodLogSearchProps {
onSearch: (query: string) => void onSearch: (query: string) => void
toPrevOverlay: () => void toPrevOverlay: () => void
toNextOverlay: () => void toNextOverlay: () => void
}
interface Props extends PodLogSearchProps {
logs: string[] logs: string[]
} }
export const PodLogSearch = observer((props: PodLogSearchProps) => { export const PodLogSearch = observer((props: Props) => {
const { logs, onSearch, toPrevOverlay, toNextOverlay } = props; const { logs, onSearch, toPrevOverlay, toNextOverlay } = props;
const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore; const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore;
const jumpDisabled = !searchQuery || !occurrences.length; const jumpDisabled = !searchQuery || !occurrences.length;

View File

@ -27,11 +27,11 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
private refresher = interval(10, () => { private refresher = interval(10, () => {
const id = dockStore.selectedTabId; const id = dockStore.selectedTabId;
if (!this.logs.get(id)) return; if (!this.podLogs.get(id)) return;
this.loadMore(id); this.loadMore(id);
}); });
@observable logs = observable.map<TabId, PodLogLine[]>(); @observable podLogs = observable.map<TabId, PodLogLine[]>();
@observable newLogSince = observable.map<TabId, string>(); // Timestamp after which all logs are considered to be new @observable newLogSince = observable.map<TabId, string>(); // Timestamp after which all logs are considered to be new
constructor() { constructor() {
@ -48,7 +48,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
} }
}, { delay: 500 }); }, { delay: 500 });
reaction(() => this.logs.get(dockStore.selectedTabId), () => { reaction(() => this.podLogs.get(dockStore.selectedTabId), () => {
this.setNewLogSince(dockStore.selectedTabId); this.setNewLogSince(dockStore.selectedTabId);
}); });
@ -72,7 +72,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
}); });
this.refresher.start(); this.refresher.start();
this.logs.set(tabId, logs); this.podLogs.set(tabId, logs);
} catch ({error}) { } catch ({error}) {
const message = [ const message = [
_i18n._(t`Failed to load logs: ${error.message}`), _i18n._(t`Failed to load logs: ${error.message}`),
@ -80,7 +80,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
]; ];
this.refresher.stop(); this.refresher.stop();
this.logs.set(tabId, message); this.podLogs.set(tabId, message);
} }
}; };
@ -91,14 +91,14 @@ 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; if (!this.podLogs.get(tabId).length) return;
const oldLogs = this.logs.get(tabId); const oldLogs = this.podLogs.get(tabId);
const logs = await this.loadLogs(tabId, { const logs = await this.loadLogs(tabId, {
sinceTime: this.getLastSinceTime(tabId) sinceTime: this.getLastSinceTime(tabId)
}); });
// Add newly received logs to bottom // Add newly received logs to bottom
this.logs.set(tabId, [...oldLogs, ...logs]); this.podLogs.set(tabId, [...oldLogs, ...logs]);
}; };
/** /**
@ -134,7 +134,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
* @param tabId * @param tabId
*/ */
setNewLogSince(tabId: TabId) { setNewLogSince(tabId: TabId) {
if (!this.logs.has(tabId) || !this.logs.get(tabId).length || this.newLogSince.has(tabId)) return; if (!this.podLogs.has(tabId) || !this.podLogs.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
@ -147,18 +147,38 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
@computed @computed
get lines() { get lines() {
const id = dockStore.selectedTabId; const id = dockStore.selectedTabId;
const logs = this.logs.get(id); const logs = this.podLogs.get(id);
return logs ? logs.length : 0; return logs ? logs.length : 0;
} }
/**
* Returns logs with timestamps for selected tab
*/
get logs() {
const id = dockStore.selectedTabId;
if (!this.podLogs.has(id)) return [];
return this.podLogs.get(id);
}
/**
* Removes timestamps from each log line and returns changed logs
* @returns Logs without timestamps
*/
get logsWithoutTimestamps() {
return this.logs.map(item => this.removeTimestamps(item));
}
/** /**
* It gets timestamps from all logs then returns last one + 1 second * It gets timestamps from all logs then returns last one + 1 second
* (this allows to avoid getting the last stamp in the selection) * (this allows to avoid getting the last stamp in the selection)
* @param tabId * @param tabId
*/ */
getLastSinceTime(tabId: TabId) { getLastSinceTime(tabId: TabId) {
const logs = this.logs.get(tabId); const logs = this.podLogs.get(tabId);
const timestamps = this.getTimestamps(logs[logs.length - 1]); const timestamps = this.getTimestamps(logs[logs.length - 1]);
const stamp = new Date(timestamps ? timestamps[0] : null); const stamp = new Date(timestamps ? timestamps[0] : null);
@ -176,7 +196,7 @@ export class PodLogsStore extends DockTabStore<IPodLogsData> {
} }
clearLogs(tabId: TabId) { clearLogs(tabId: TabId) {
this.logs.delete(tabId); this.podLogs.delete(tabId);
} }
clearData(tabId: TabId) { clearData(tabId: TabId) {

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { computed, observable, reaction } from "mobx"; import { observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { searchStore } from "../../../common/search-store"; import { searchStore } from "../../../common/search-store";
@ -79,31 +79,15 @@ export class PodLogs extends React.Component<Props> {
}, 100); }, 100);
} }
/**
* Computed prop which returns logs with or without timestamps added to each line
* @returns {Array} An array log items
*/
@computed
get logs(): string[] {
if (!podLogsStore.logs.has(this.tabId)) return [];
const logs = podLogsStore.logs.get(this.tabId);
const { getData, removeTimestamps } = podLogsStore;
const { showTimestamps } = getData(this.tabId);
if (!showTimestamps) {
return logs.map(item => removeTimestamps(item));
}
return logs;
}
render() { render() {
const logs = podLogsStore.logs;
const controls = ( const controls = (
<PodLogControls <PodLogControls
ready={!this.isLoading} ready={!this.isLoading}
tabId={this.tabId} tabId={this.tabId}
tabData={this.tabData} tabData={this.tabData}
logs={this.logs} logs={logs}
save={this.save} save={this.save}
reload={this.reload} reload={this.reload}
onSearch={this.onSearch} onSearch={this.onSearch}
@ -119,11 +103,12 @@ export class PodLogs extends React.Component<Props> {
controls={controls} controls={controls}
showSubmitClose={false} showSubmitClose={false}
showButtons={false} showButtons={false}
showStatusPanel={false}
/> />
<PodLogList <PodLogList
logs={logs}
id={this.tabId} id={this.tabId}
isLoading={this.isLoading} isLoading={this.isLoading}
logs={this.logs}
load={this.load} load={this.load}
ref={this.logListElement} ref={this.logListElement}
/> />