diff --git a/package.json b/package.json index a0d9c0050..fe60fec1a 100644 --- a/package.json +++ b/package.json @@ -190,6 +190,8 @@ "onCommand:openshift.component.push.palette", "onCommand:openshift.component.watch", "onCommand:openshift.component.watch.palette", + "onCommand:openshift.component.watch.terminate", + "onCommand:openshift.component.watch.showLog", "onCommand:openshift.catalog.listComponents", "onCommand:openshift.catalog.listServices", "onCommand:openshift.url.create", @@ -434,6 +436,16 @@ "title": "Link Service", "category": "OpenShift" }, + { + "command": "openshift.component.watch.terminate", + "title": "Stop", + "category": "OpenShift" + }, + { + "command": "openshift.component.watch.showLog", + "title": "Show Log", + "category": "OpenShift" + }, { "command": "openshift.openshiftConsole", "title": "Open Console Dashboard", @@ -712,6 +724,10 @@ { "id": "openshiftProjectExplorer", "name": "Application Explorer" + }, + { + "id": "openshiftWatchView", + "name": "Watch Sessions" } ] }, @@ -1143,6 +1159,14 @@ { "command": "openshift.explorer.login.credentialsLogin", "when": "view == openshiftProjectExplorer && viewItem == loginRequired" + }, + { + "command": "openshift.component.watch.terminate", + "when": "view == openshiftWatchView && viewItem == openshift.watch.process" + }, + { + "command": "openshift.component.watch.showLog", + "when": "view == openshiftWatchView && viewItem == openshift.watch.process" } ] }, diff --git a/src/extension.ts b/src/extension.ts index d03999698..f72835b38 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,7 @@ import { TokenStore } from './util/credentialManager'; import { registerCommands } from './vscommand'; import { ToolsConfig } from './tools'; import { extendClusterExplorer } from './k8s/clusterExplorer'; +import { WatchSessionsView } from './watch'; import path = require('path'); import fsx = require('fs-extra'); @@ -68,6 +69,7 @@ export async function activate(extensionContext: ExtensionContext): Promise commands.executeCommand('extension.vsKubernetesUseNamespace', context), ), OpenShiftExplorer.getInstance(), + WatchSessionsView.getInstance(), ...Component.init(extensionContext) ]; disposable.forEach((value) => extensionContext.subscriptions.push(value)); diff --git a/src/odo.ts b/src/odo.ts index 7debf0940..d795f0a06 100644 --- a/src/odo.ts +++ b/src/odo.ts @@ -49,6 +49,7 @@ export interface OpenShiftObject extends QuickPickItem { contextPath?: Uri; path?: string; builderImage?: BuilderImage; + iconPath?: Uri; } export enum ContextType { @@ -76,10 +77,20 @@ export abstract class OpenShiftObjectImpl implements OpenShiftObject { public readonly icon: string, // eslint-disable-next-line no-shadow public readonly collapsibleState: TreeItemCollapsibleState = Collapsed, - public contextPath: Uri = undefined, + private contextPathValue: Uri = undefined, public readonly compType: string = undefined, public readonly builderImage: BuilderImage = undefined) { OdoImpl.data.setPathToObject(this); + OdoImpl.data.setContextToObject(this); + } + + set contextPath(cp: Uri) { + this.contextPathValue = cp; + OdoImpl.data.setContextToObject(this); + } + + get contextPath(): Uri { + return this.contextPathValue; } get path(): string { @@ -290,6 +301,7 @@ export interface Odo { deleteService(service: OpenShiftObject): Promise; deleteURL(url: OpenShiftObject): Promise; createComponentCustomUrl(component: OpenShiftObject, name: string, port: string, secure?: boolean): Promise; + getOpenShiftObjectByContext(context: string): OpenShiftObject; readonly subject: Subject; } @@ -307,7 +319,7 @@ class OdoModel { private pathToObject = new Map(); - private contextToObject = new Map(); + private contextToObject = new Map(); private contextToSettings = new Map(); @@ -342,14 +354,14 @@ class OdoModel { public setContextToObject(object: OpenShiftObject): void { if (object.contextPath) { - if (!this.contextToObject.has(object.contextPath)) { - this.contextToObject.set(object.contextPath, object ); + if (!this.contextToObject.has(object.contextPath.fsPath)) { + this.contextToObject.set(object.contextPath.fsPath, object ); } } } public getObjectByContext(context: Uri): OpenShiftObject { - return this.contextToObject.get(context); + return this.contextToObject.get(context.fsPath); } public setContextToSettings (settings: odo.Component): void { @@ -392,7 +404,7 @@ class OdoModel { const array = await item.getParent().getChildren(); array.splice(array.indexOf(item), 1); this.pathToObject.delete(item.path); - this.contextToObject.delete(item.contextPath); + this.contextToObject.delete(item.contextPath.fsPath); } public deleteContext(context: string): void { @@ -933,6 +945,10 @@ export class OdoImpl implements Odo { this.subject.next(new OdoEventImpl('changed', null)); } + getOpenShiftObjectByContext(context: string): OpenShiftObject { + return OdoImpl.data.getObjectByContext(Uri.file(context)); + } + async loadWorkspaceComponents(event: WorkspaceFoldersChangeEvent): Promise { const clusters = (await this.getClusters()); if(!clusters) return; @@ -1009,7 +1025,6 @@ export class OdoImpl implements Odo { if ((result2.stdout !== '' && sis.length > 0) || (result1.stdout !== '' && dcs.length > 0)) { projectsToMigrate.push(project); } - } if (projectsToMigrate.length > 0) { const choice = await window.showWarningMessage(`Found the resources in cluster that must be updated to work with latest release of OpenShift Connector Extension.`, 'Update', 'Help', 'Cancel'); diff --git a/src/openshift/component.ts b/src/openshift/component.ts index ed3481831..264320b20 100644 --- a/src/openshift/component.ts +++ b/src/openshift/component.ts @@ -8,6 +8,7 @@ import { window, commands, QuickPickItem, Uri, workspace, ExtensionContext, debug, DebugConfiguration, extensions, ProgressLocation, DebugSession, Disposable } from 'vscode'; import { ChildProcess , exec } from 'child_process'; import { isURL } from 'validator'; +import { Subject } from 'rxjs'; import OpenShiftItem, { selectTargetApplication, selectTargetComponent } from './openshiftItem'; import { OpenShiftObject, ContextType } from '../odo'; import { Command } from "../odo/command"; @@ -30,9 +31,17 @@ import treeKill = require('tree-kill'); const waitPort = require('wait-port'); +export class ComponentEvent { + readonly type: 'watchStarted' | 'watchTerminated'; + readonly component: OpenShiftObject; + readonly process?: ChildProcess; +} + export class Component extends OpenShiftItem { public static extensionContext: ExtensionContext; public static debugSessions: Map = new Map(); + public static watchSessions: Map = new Map(); + public static readonly watchSubject: Subject = new Subject(); public static init(context: ExtensionContext): Disposable[] { Component.extensionContext = context; @@ -57,6 +66,13 @@ export class Component extends OpenShiftItem { } } + static stopWatchSession(component: OpenShiftObject): void { + const ws = Component.watchSessions.get(component.contextPath.fsPath); + if (ws) { + treeKill(ws.pid); + } + } + static async getOpenshiftData(context: OpenShiftObject): Promise { return Component.getOpenShiftCmdData(context, "In which Project you want to create a Component", @@ -119,6 +135,7 @@ export class Component extends OpenShiftItem { await Component.unlinkAllComponents(component); } Component.stopDebugSession(component); + Component.stopWatchSession(component); await Component.odo.deleteComponent(component); }).then(() => `Component '${name}' successfully deleted`) @@ -140,6 +157,7 @@ export class Component extends OpenShiftItem { if (value === 'Yes') { return Progress.execFunctionWithProgress(`Undeploying the Component '${component.getName()} '`, async () => { Component.stopDebugSession(component); + Component.stopWatchSession(component); await Component.odo.undeployComponent(component); }).then(() => `Component '${name}' successfully undeployed`) .catch((err) => Promise.reject(new VsCommandError(`Failed to undeploy Component with error '${err}'`))); @@ -451,6 +469,23 @@ export class Component extends OpenShiftItem { } } + static addWatchSession(component: OpenShiftObject, process: ChildProcess): void { + Component.watchSessions.set(component.contextPath.fsPath, process); + Component.watchSubject.next({ + type: 'watchStarted', + component, + process + }); + } + + static removeWatchSession(component: OpenShiftObject): void { + Component.watchSessions.delete(component.contextPath.fsPath); + Component.watchSubject.next({ + type: 'watchTerminated', + component + }); + } + @vsCommand('openshift.component.watch', true) @selectTargetComponent( 'Select a Project', @@ -458,9 +493,24 @@ export class Component extends OpenShiftItem { 'Select a Component you want to watch', (target) => target.contextValue === ContextType.COMPONENT_PUSHED ) - static watch(component: OpenShiftObject): Promise { + static async watch(component: OpenShiftObject): Promise { if (!component) return null; - Component.odo.executeInTerminal(Command.watchComponent(component.getParent().getParent().getName(), component.getParent().getName(), component.getName()), component.contextPath.fsPath, `OpenShift: Watch '${component.getName()}' Component`); + if (component.compType !== SourceType.LOCAL && component.compType !== SourceType.BINARY) { + window.showInformationMessage(`Watch is supported only for Components with local or binary source type.`) + return null; + } + if (Component.watchSessions.get(component.contextPath.fsPath)) { + const sel = await window.showInformationMessage(`Watch process is already running for '${component.getName()}'`, 'Show Log'); + if (sel === 'Show Log') { + commands.executeCommand('openshift.component.watch.showLog', component.contextPath.fsPath); + } + } else { + const process: ChildProcess = await Component.odo.spawn(Command.watchComponent(component.getParent().getParent().getName(), component.getParent().getName(), component.getName()), component.contextPath.fsPath); + Component.addWatchSession(component, process); + process.on('exit', () => { + Component.removeWatchSession(component); + }); + } } @vsCommand('openshift.component.openUrl', true) diff --git a/src/view/log/LogViewLoader.ts b/src/view/log/LogViewLoader.ts index cc72ce61a..0e62f5b47 100644 --- a/src/view/log/LogViewLoader.ts +++ b/src/view/log/LogViewLoader.ts @@ -9,6 +9,7 @@ import { ExtenisonID } from '../../util/constants'; import { OpenShiftObject } from '../../odo'; import * as odo from '../../odo'; import treeKill = require('tree-kill'); +import { ChildProcess } from 'child_process'; export default class LogViewLoader { @@ -16,7 +17,7 @@ export default class LogViewLoader { return vscode.extensions.getExtension(ExtenisonID).extensionPath } - static async loadView(title: string, cmdFunction: (prj, app, comp) => string, target: OpenShiftObject): Promise { + static async loadView(title: string, cmdFunction: (prj, app, comp) => string, target: OpenShiftObject, existingProcess?: ChildProcess): Promise { const localResourceRoot = vscode.Uri.file(path.join(LogViewLoader.extensionPath, 'out', 'logViewer')); const panel = vscode.window.createWebviewPanel('logView', title, vscode.ViewColumn.One, { @@ -29,9 +30,9 @@ export default class LogViewLoader { const cmd = cmdFunction(target.getParent().getParent().getName(), target.getParent().getName(), target.getName()); // TODO: When webview is going to be ready? - panel.webview.html = LogViewLoader.getWebviewContent(LogViewLoader.extensionPath, cmd); + panel.webview.html = LogViewLoader.getWebviewContent(LogViewLoader.extensionPath, cmd.replace(/\\/g, '\\\\')); - const process = await odo.getInstance().spawn(cmd, target.contextPath.fsPath); + const process = existingProcess? existingProcess : await odo.getInstance().spawn(cmd, target.contextPath.fsPath); process.stdout.on('data', (data) => { panel.webview.postMessage({action: 'add', data: `${data}`.trim().split('\n')}); }).on('close', ()=>{ @@ -43,9 +44,11 @@ export default class LogViewLoader { recieveDisposable.dispose(); } }) - const disposable = panel.onDidDispose(()=> { - treeKill(process.pid); - disposable.dispose(); + panel.onDidDispose(()=> { + process.stdout.removeAllListeners(); + if(!existingProcess) { + treeKill(process.pid); + } }); return panel; } diff --git a/src/view/log/app/spinner.tsx b/src/view/log/app/spinner.tsx index 3b54292d9..9f547d3e5 100644 --- a/src/view/log/app/spinner.tsx +++ b/src/view/log/app/spinner.tsx @@ -72,8 +72,9 @@ declare global { } } +const vscode = window.acquireVsCodeApi(); + function stop() { - const vscode = window.acquireVsCodeApi(); vscode.postMessage({action: 'stop'}); } diff --git a/src/watch.ts b/src/watch.ts new file mode 100644 index 000000000..0a1101b67 --- /dev/null +++ b/src/watch.ts @@ -0,0 +1,117 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { + TreeDataProvider, + TreeItem, + Event, + ProviderResult, + EventEmitter, + Disposable, + TreeView, + window, + TreeItemCollapsibleState, +} from 'vscode'; + +import { ChildProcess } from 'child_process'; +import { Odo, OdoImpl } from './odo'; +import { vsCommand } from './vscommand'; +import { Component, ComponentEvent } from './openshift/component'; +import LogViewLoader from './view/log/LogViewLoader'; + +import treeKill = require('tree-kill'); + +class WatchSessionEntry { + label: string; + process: ChildProcess; +} + +export class WatchSessionsView implements TreeDataProvider, Disposable { + private static instance: WatchSessionsView; + private static sessions: Map = new Map(); + + private static odoctl: Odo = OdoImpl.Instance; + + private treeView: TreeView; + + private onDidChangeTreeDataEmitter: EventEmitter = + new EventEmitter(); + + readonly onDidChangeTreeData: Event = this + .onDidChangeTreeDataEmitter.event; + + private constructor() { + this.treeView = window.createTreeView('openshiftWatchView', { + treeDataProvider: this, + }); + Component.watchSubject.subscribe((event: ComponentEvent) => { + if (event.type === 'watchStarted') { + const osObj = WatchSessionsView.odoctl.getOpenShiftObjectByContext(event.component.contextPath.fsPath); + WatchSessionsView.sessions.set(event.component.contextPath.fsPath, { + label: `${osObj.getParent().getParent().getName()}/${osObj.getParent().getName()}/${osObj.getName()}`, + process: event.process + }); + this.refresh(); + } else if (event.type === 'watchTerminated') { + WatchSessionsView.sessions.delete(event.component.contextPath.fsPath); + this.refresh(); + } + }) + } + + static getInstance(): WatchSessionsView { + if (!WatchSessionsView.instance) { + WatchSessionsView.instance = new WatchSessionsView(); + } + return WatchSessionsView.instance; + } + + // eslint-disable-next-line class-methods-use-this + getTreeItem(element: string): TreeItem | Thenable { + return { + label: WatchSessionsView.sessions.get(element).label, + collapsibleState: TreeItemCollapsibleState.None, + contextValue: 'openshift.watch.process', + iconPath: WatchSessionsView.odoctl.getOpenShiftObjectByContext(element).iconPath + }; + } + + // eslint-disable-next-line class-methods-use-this + getChildren(): ProviderResult { + return [...WatchSessionsView.sessions.keys()]; + } + + // eslint-disable-next-line class-methods-use-this + getParent?(): string { + return undefined; + } + + @vsCommand('openshift.component.watch.terminate') + static terminateWatchSession(context: string): void { + treeKill(WatchSessionsView.sessions.get(context).process.pid, 'SIGSTOP'); + } + + @vsCommand('openshift.component.watch.showLog') + static showWatchSessionLog(context: string): void { + LogViewLoader.loadView(`${context} Watch Log`, () => `odo watch --context ${context}`, WatchSessionsView.odoctl.getOpenShiftObjectByContext(context), WatchSessionsView.sessions.get(context).process); + } + + refresh(): void { + this.onDidChangeTreeDataEmitter.fire(); + } + + dispose(): void { + this.treeView.dispose(); + } + + async reveal(item: string): Promise { + this.refresh(); + // double call of reveal is workaround for possible upstream issue + // https://github.com/redhat-developer/vscode-openshift-tools/issues/762 + await this.treeView.reveal(item); + this.treeView.reveal(item); + } + +} diff --git a/test/unit/openshift/component.test.ts b/test/unit/openshift/component.test.ts index 111ac3d55..9eb6fe8d3 100644 --- a/test/unit/openshift/component.test.ts +++ b/test/unit/openshift/component.test.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; import * as chai from 'chai'; import * as sinonChai from 'sinon-chai'; import * as sinon from 'sinon'; +import { ChildProcess } from 'child_process'; import { TestItem } from './testOSItem'; import { OdoImpl, ContextType } from '../../../src/odo'; import { Command } from "../../../src/odo/command"; @@ -19,6 +20,7 @@ import OpenShiftItem from '../../../src/openshift/openshiftItem'; import pq = require('proxyquire'); import globby = require('globby'); + const {expect} = chai; chai.use(sinonChai); @@ -43,6 +45,7 @@ suite('OpenShift/Component', () => { let Component: any; let fetchTag: sinon.SinonStub; let commandStub: sinon.SinonStub; + let spawnStub: sinon.SinonStub; setup(() => { sandbox = sinon.createSandbox(); @@ -51,6 +54,7 @@ suite('OpenShift/Component', () => { Component = pq('../../../src/openshift/component', {}).Component; termStub = sandbox.stub(OdoImpl.prototype, 'executeInTerminal'); execStub = sandbox.stub(OdoImpl.prototype, 'execute').resolves({ stdout: "" }); + spawnStub = sandbox.stub(OdoImpl.prototype, 'spawn'); sandbox.stub(OdoImpl.prototype, 'getServices'); sandbox.stub(OdoImpl.prototype, 'getProjects').resolves([]); sandbox.stub(OdoImpl.prototype, 'getApplications').resolves([]); @@ -1122,15 +1126,17 @@ suite('OpenShift/Component', () => { }); test('calls the correct odo command w/ context', async () => { + const cpStub = {on: sinon.stub()} as any as ChildProcess; + spawnStub.resolves(cpStub); await Component.watch(componentItem); - - expect(termStub).calledOnceWith(Command.watchComponent(projectItem.getName(), appItem.getName(), componentItem.getName())); + expect(spawnStub).calledOnceWith(Command.watchComponent(projectItem.getName(), appItem.getName(), componentItem.getName())); }); test('calls the correct odo command w/o context', async () => { + const cpStub = {on: sinon.stub()} as any as ChildProcess; + spawnStub.resolves(cpStub); await Component.watch(null); - - expect(termStub).calledOnceWith(Command.watchComponent(projectItem.getName(), appItem.getName(), componentItem.getName())); + expect(spawnStub).calledOnceWith(Command.watchComponent(projectItem.getName(), appItem.getName(), componentItem.getName())); }); }); diff --git a/test/unit/openshift/testOSItem.ts b/test/unit/openshift/testOSItem.ts index e289e86ef..56193effb 100644 --- a/test/unit/openshift/testOSItem.ts +++ b/test/unit/openshift/testOSItem.ts @@ -5,6 +5,7 @@ import { Uri } from "vscode"; import { OpenShiftObject, ContextType } from "../../../src/odo"; +import { SourceType } from "../../../src/odo/config"; export class TestItem implements OpenShiftObject { public treeItem = null; @@ -16,7 +17,8 @@ export class TestItem implements OpenShiftObject { public contextValue: ContextType, private children = [], public contextPath = Uri.parse('file:///c%3A/Temp'), - public path?: string) { + public path?: string, + public compType: string = SourceType.LOCAL) { } getName(): string {