diff --git a/app/components/base/base.module.ts b/app/components/base/base.module.ts index 65d1848434..45be068c6b 100644 --- a/app/components/base/base.module.ts +++ b/app/components/base/base.module.ts @@ -13,6 +13,7 @@ import { BreadcrumbModule } from "./breadcrumbs"; import { ButtonsModule } from "./buttons"; import { ChartsModule } from "./charts"; import { ContextMenuModule } from "./context-menu"; +import { DialogsModule } from "./dialogs"; import { DropdownModule } from "./dropdown"; import { EditorModule } from "./editor"; import { ElapsedTimeComponent } from "./elapsed-time"; @@ -43,6 +44,7 @@ const modules = [ BackgroundTaskModule, ChartsModule, ContextMenuModule, + DialogsModule, DropdownModule, EditorModule, FocusSectionModule, @@ -79,6 +81,7 @@ const components = [ declarations: components, entryComponents: [ DeleteSelectedItemsDialogComponent, + SimpleDialogComponent, ], exports: [...modules, ...components], imports: [ diff --git a/app/components/base/dialogs/confirmation-dialog.component.ts b/app/components/base/dialogs/confirmation-dialog.component.ts new file mode 100644 index 0000000000..e7b25ce97d --- /dev/null +++ b/app/components/base/dialogs/confirmation-dialog.component.ts @@ -0,0 +1,29 @@ +import { Component } from "@angular/core"; +import { MdDialogRef } from "@angular/material"; +import { autobind } from "core-decorators"; +import { AsyncSubject, Observable } from "rxjs"; + +@Component({ + selector: "bl-confirmation-dialog", + templateUrl: "confirmation-dialog.html", +}) +export class ConfirmationDialogComponent { + public title: string; + public description: string; + public execute: () => Observable; + + public response = new AsyncSubject(); + + constructor(public dialogRef: MdDialogRef) { + this.response.next(false); + } + + @autobind() + public submit() { + return this.execute(); + } + + public done() { + this.response.complete(); + } +} diff --git a/app/components/base/dialogs/confirmation-dialog.html b/app/components/base/dialogs/confirmation-dialog.html new file mode 100644 index 0000000000..d991d1c527 --- /dev/null +++ b/app/components/base/dialogs/confirmation-dialog.html @@ -0,0 +1,6 @@ + +
+

{{description}}

+
+
diff --git a/app/components/base/dialogs/dialog.service.ts b/app/components/base/dialogs/dialog.service.ts new file mode 100644 index 0000000000..29c4310902 --- /dev/null +++ b/app/components/base/dialogs/dialog.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@angular/core"; +import { ComponentType, MdDialog, MdDialogConfig, MdDialogRef } from "@angular/material"; +import { Observable } from "rxjs"; +import { ConfirmationDialogComponent } from "./confirmation-dialog.component"; + +export interface ConfirmOptions { + description?: string; + yes: () => Observable; +} + +/** + * Dialog service is a service to help open commonly used dialog such as a confirmation dialog. + * It can also open a dialog the same way material does so you only need to inject this service and not the MdDialog + */ +@Injectable() +export class DialogService { + constructor(private mdDialog: MdDialog) { } + + public confirm(title: string = "Are you sure?", options: ConfirmOptions) { + const ref = this.mdDialog.open(ConfirmationDialogComponent); + const component = ref.componentInstance; + component.title = title; + component.description = options.description; + component.execute = options.yes; + } + + public open(type: ComponentType, config?: MdDialogConfig): MdDialogRef { + return this.mdDialog.open(type, config); + } +} diff --git a/app/components/base/dialogs/dialogs.module.ts b/app/components/base/dialogs/dialogs.module.ts new file mode 100644 index 0000000000..b89cc95013 --- /dev/null +++ b/app/components/base/dialogs/dialogs.module.ts @@ -0,0 +1,31 @@ +import { NgModule } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { MaterialModule } from "@angular/material"; +import { BrowserModule } from "@angular/platform-browser"; + +import { FormModule } from "app/components/base/form"; +import { ConfirmationDialogComponent } from "./confirmation-dialog.component"; +import { DialogService } from "./dialog.service"; + +@NgModule({ + declarations: [ + ConfirmationDialogComponent, + ], + exports: [ + ConfirmationDialogComponent, + ], + imports: [ + BrowserModule, + FormsModule, + MaterialModule, + FormModule, + ], + providers: [ + DialogService, + ], + entryComponents: [ + ConfirmationDialogComponent, + ], +}) +export class DialogsModule { +} diff --git a/app/components/base/dialogs/index.ts b/app/components/base/dialogs/index.ts new file mode 100644 index 0000000000..1b720ea492 --- /dev/null +++ b/app/components/base/dialogs/index.ts @@ -0,0 +1,3 @@ +export * from "./confirmation-dialog.component"; +export * from "./dialog.service"; +export * from "./dialogs.module"; diff --git a/app/components/base/simple-dialog/simple-dialog.component.ts b/app/components/base/simple-dialog/simple-dialog.component.ts index 2a256bdf10..84dca7e888 100644 --- a/app/components/base/simple-dialog/simple-dialog.component.ts +++ b/app/components/base/simple-dialog/simple-dialog.component.ts @@ -16,7 +16,7 @@ export class SimpleDialogComponent { public done = new EventEmitter(); @Input() - public title: string; + public title: string = "Are you sure?"; @Input() public subtitle: string; diff --git a/app/components/node/details/node-details.component.ts b/app/components/node/details/node-details.component.ts index 5a740db66a..1ba2a1b977 100644 --- a/app/components/node/details/node-details.component.ts +++ b/app/components/node/details/node-details.component.ts @@ -3,6 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { autobind } from "core-decorators"; import { Subscription } from "rxjs"; +import { DialogService } from "app/components/base/dialogs"; import { SidebarManager } from "app/components/base/sidebar"; import { Node, Pool } from "app/models"; import { FileService, NodeParams, NodeService, PoolService } from "app/services"; @@ -33,8 +34,9 @@ export class NodeDetailsComponent implements OnInit, OnDestroy { constructor( private route: ActivatedRoute, - nodeService: NodeService, + private nodeService: NodeService, private poolService: PoolService, + private dialog: DialogService, fileService: FileService, private sidebarManager: SidebarManager) { @@ -92,4 +94,14 @@ export class NodeDetailsComponent implements OnInit, OnDestroy { ref.component.node = this.node; ref.component.pool = this.pool; } + + @autobind() + public delete() { + this.dialog.confirm("Are you sure you want to delete this node?", { + yes: () => { + return this.nodeService.delete(this.pool.id, this.nodeId) + .cascade(() => this.nodeService.getOnce(this.pool.id, this.node.id)); + }, + }); + } } diff --git a/app/components/node/details/node-details.html b/app/components/node/details/node-details.html index fbf05f9a35..085caf4abe 100644 --- a/app/components/node/details/node-details.html +++ b/app/components/node/details/node-details.html @@ -5,6 +5,7 @@ +
diff --git a/app/components/pool/graphs/nodes-heatmap.component.ts b/app/components/pool/graphs/nodes-heatmap.component.ts index e7e5996347..5c05f1285b 100644 --- a/app/components/pool/graphs/nodes-heatmap.component.ts +++ b/app/components/pool/graphs/nodes-heatmap.component.ts @@ -6,12 +6,13 @@ import { Router } from "@angular/router"; import * as d3 from "d3"; import * as elementResizeDetectorMaker from "element-resize-detector"; import { List } from "immutable"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, Observable } from "rxjs"; import { ContextMenu, ContextMenuItem, ContextMenuService } from "app/components/base/context-menu"; +import { NotificationService } from "app/components/base/notifications"; import { SidebarManager } from "app/components/base/sidebar"; import { NodeConnectComponent } from "app/components/node/connect"; -import { Job, Node, NodeState, Pool } from "app/models"; +import { Job, Node, NodeState, Pool, ServerError } from "app/models"; import { NodeService } from "app/services"; import { log } from "app/utils"; import { HeatmapColor } from "./heatmap-color"; @@ -117,6 +118,7 @@ export class NodesHeatmapComponent implements AfterViewInit, OnChanges, OnDestro private contextMenuService: ContextMenuService, private nodeService: NodeService, private sidebarManager: SidebarManager, + private notificationService: NotificationService, private router: Router, ) { this.colors = new HeatmapColor(stateTree); @@ -161,6 +163,19 @@ export class NodesHeatmapComponent implements AfterViewInit, OnChanges, OnDestro .attr("width", this._width) .attr("height", this._height); + const defs = this._svg.append("defs"); + const pattern = defs.append("pattern") + .attr("id", "low-pri-stripes") + .attr("width", "10") + .attr("height", "10") + .attr("patternUnits", "userSpaceOnUse") + .attr("patternTransform", "rotate(45 50 50)"); + + pattern.append("line") + .attr("stroke", "#fff") + .attr("stroke-width", "2px") + .attr("y2", "10"); + this._processNewData(); } @@ -429,18 +444,41 @@ export class NodesHeatmapComponent implements AfterViewInit, OnChanges, OnDestro private _buildContextMenu(node: Node) { return new ContextMenu([ new ContextMenuItem({ label: "Go to", click: () => this._gotoNode(node) }), + new ContextMenuItem({ label: "Connect", click: () => this._connectTo(node) }), new ContextMenuItem({ label: "Reboot", click: () => this._reboot(node) }), new ContextMenuItem({ label: "Reimage", click: () => this._reimage(node) }), - new ContextMenuItem({ label: "Connect", click: () => this._connectTo(node) }), + new ContextMenuItem({ label: "Delete", click: () => this._delete(node) }), ]); } private _reboot(node: Node) { - this.nodeService.reboot(this.pool.id, node.id).cascade(() => this.nodeService.getOnce(this.pool.id, node.id)); + this._nodeAction(node, this.nodeService.reboot(this.pool.id, node.id)).cascade(() => { + this.notificationService.success("Node reimaging!", `Node ${node.id} started reimaging`); + }); } private _reimage(node: Node) { - this.nodeService.reimage(this.pool.id, node.id).cascade(() => this.nodeService.getOnce(this.pool.id, node.id)); + this._nodeAction(node, this.nodeService.reimage(this.pool.id, node.id)).cascade(() => { + this.notificationService.success("Node rebooting!", `Node ${node.id} started rebooting`); + }); + } + + private _delete(node: Node) { + this._nodeAction(node, this.nodeService.delete(this.pool.id, node.id)).cascade(() => { + this.notificationService.success("Node deleting!", `Node ${node.id} is being removed from the pool.`); + }); + } + + private _nodeAction(node: Node, action: Observable): Observable { + action.subscribe({ + next: () => { + this.nodeService.getOnce(this.pool.id, node.id); + }, + error: (error: ServerError) => { + this.notificationService.error(error.body.code, error.body.message); + }, + }); + return action; } private _gotoNode(node: Node) { diff --git a/app/components/pool/graphs/nodes-heatmap.html b/app/components/pool/graphs/nodes-heatmap.html index 801d961d3e..e0b09910de 100644 --- a/app/components/pool/graphs/nodes-heatmap.html +++ b/app/components/pool/graphs/nodes-heatmap.html @@ -4,11 +4,11 @@
- +
diff --git a/app/services/node-service.ts b/app/services/node-service.ts index 0b3ad39ed6..435c62e1d6 100644 --- a/app/services/node-service.ts +++ b/app/services/node-service.ts @@ -177,6 +177,12 @@ export class NodeService extends ServiceBase { return observable; } + public delete(poolId: string, nodeId: string): Observable { + return this.callBatchClient((client) => client.node.delete(poolId, nodeId, {}), (error) => { + log.error("Error deleting node: " + nodeId, Object.assign({}, error)); + }); + } + public listNodeAgentSkus(initialOptions: any = { pageSize: 1000 }): RxListProxy<{}, NodeAgentSku> { return new RxBatchListProxy<{}, NodeAgentSku>(NodeAgentSku, this.batchService, { cache: (params) => this._nodeAgentSkusCache, diff --git a/app/styles/partials/grid.scss b/app/styles/partials/grid.scss index d4559754d8..cdc71753bc 100644 --- a/app/styles/partials/grid.scss +++ b/app/styles/partials/grid.scss @@ -1,9 +1,15 @@ .grow { display: flex; - margin: 0 -5px; - > .gcol { flex: 1; margin: 0 5px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } } } diff --git a/docs/dialog.md b/docs/dialog.md new file mode 100644 index 0000000000..571d961614 --- /dev/null +++ b/docs/dialog.md @@ -0,0 +1,52 @@ +# How to open a popup dialog + +You can open a popup dialog in batchlabs to ask for confirmation to the user, fill a small form, etc. + +First you need to import the `DialogService` + +```ts +import { DialogService } from "app/components/base/dialogs"; + +constructor(private dialog: DialogService) {} +``` + +## Common dialogs + +**1. Confirmation dialog** + +Simplest confirmation dialog that do some action when user confirm. +```ts +this.dialog.confirm("Are you sure?", { + yes: () => this.otherService.doSomething(), +}) +``` + +You can also have a description to detail a bit more what the user is about to confirm +```ts +this.dialog.confirm("Are you sure?", { + description: "This cannot be reverted.", + yes: () => this.otherService.doSomething(), +}) +``` + + +## Custom dialogs + +You can also open any component as a dialog. For that you'll need to first add the component to the entryComponents list of the module + +```ts +@NgModule({ + ... + entryComponents: [ + MyDialogComponent + ], + ... +}) +``` + +then just call open(Note that this is just a proxy function to material MdDialog open) + +```ts +const ref = this.dialog.open(MyDialogComponent); +reg.componentInstance # If you want to set some variables. +``` diff --git a/docs/readme.md b/docs/readme.md index f3bd6d3d8a..a363f3df03 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -8,6 +8,7 @@ ## How to * [Context Menu](context-menu.md) +* [Open a dialog](dialog.md) * [Store user data/Cache data](store-user-data.md) diff --git a/src/client/api/batch-client-proxy/nodeProxy.ts b/src/client/api/batch-client-proxy/nodeProxy.ts index 552cb148d9..611ac1283e 100644 --- a/src/client/api/batch-client-proxy/nodeProxy.ts +++ b/src/client/api/batch-client-proxy/nodeProxy.ts @@ -29,6 +29,16 @@ export default class NodeProxy { return this.client.computeNodeOperations.reboot(poolId, nodeId, wrapOptions(options)); } + /** + * Reinstalls the operating system on the specified compute node. + * @param poolId: The id of the pool. + * @param nodeId: The id of the node to delete + * @param options: Optional Parameters. + */ + public delete(poolId: string, nodeId: string, options?: any): Promise { + return this.client.pool.removeNodes(poolId, { nodeList: [nodeId] }, wrapOptions(options)); + } + /** * Reinstalls the operating system on the specified compute node. * http://azure.github.io/azure-sdk-for-node/azure-batch/latest/ComputeNodeOperations.html#reimage diff --git a/test/app/components/pool/graphs/nodes-heatmap.component.spec.ts b/test/app/components/pool/graphs/nodes-heatmap.component.spec.ts index e6871aae53..08e1d2900b 100644 --- a/test/app/components/pool/graphs/nodes-heatmap.component.spec.ts +++ b/test/app/components/pool/graphs/nodes-heatmap.component.spec.ts @@ -11,7 +11,7 @@ import { Node, NodeState, Pool } from "app/models"; import { NodeService } from "app/services"; import * as Fixture from "test/fixture"; import { click, dblclick, rightClick } from "test/utils/helpers"; -import { ContextMenuServiceMock } from "test/utils/mocks"; +import { ContextMenuServiceMock, NotificationServiceMock } from "test/utils/mocks"; @Component({ template: ` @@ -48,9 +48,10 @@ describe("NodesHeatmapComponent", () => { let svg: d3.Selection; let contextMenuService: ContextMenuServiceMock; let routerSpy; - + let notificationService; beforeEach(() => { contextMenuService = new ContextMenuServiceMock(); + notificationService = new NotificationServiceMock(); routerSpy = { navigate: jasmine.createSpy("router.navigate"), }; @@ -63,6 +64,7 @@ describe("NodesHeatmapComponent", () => { { provide: SidebarManager, useValue: {} }, { provide: Router, useValue: routerSpy }, contextMenuService.asProvider(), + notificationService.asProvider(), ], });