This commit is contained in:
Robert Zhang 2019-10-20 22:54:15 +08:00
Родитель 73911f910c
Коммит 4a7c6c2642
16 изменённых файлов: 435 добавлений и 5 удалений

Просмотреть файл

@ -30,7 +30,10 @@
"styles": [
"src/styles.scss"
],
"scripts": []
"scripts": [
"./node_modules/jquery/dist/jquery.min.js",
"./node_modules/signalr/jquery.signalR.min.js"
]
},
"configurations": {
"production": {
@ -126,7 +129,10 @@
"styles": [
"src/styles.scss"
],
"scripts": [],
"scripts": [
"./node_modules/jquery/dist/jquery.min.js",
"./node_modules/signalr/jquery.signalR.min.js"
],
"codeCoverageExclude": ["src/app/api-client/**/*"]
}
},

37
package-lock.json сгенерированный
Просмотреть файл

@ -1275,6 +1275,15 @@
"@types/jasmine": "*"
}
},
"@types/jquery": {
"version": "3.3.31",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.31.tgz",
"integrity": "sha512-Lz4BAJihoFw5nRzKvg4nawXPzutkv7wmfQ5121avptaSIXlDNJCUuxZxX/G+9EVidZGuO0UBlk+YjKbwRKJigg==",
"dev": true,
"requires": {
"@types/sizzle": "*"
}
},
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -1299,6 +1308,21 @@
"integrity": "sha512-lMC2G0ItF2xv4UCiwbJGbnJlIuUixHrioOhNGHSCsYCJ8l4t9hMCUimCytvFv7qy6AfSzRxhRHoGa+UqaqwyeA==",
"dev": true
},
"@types/signalr": {
"version": "2.2.35",
"resolved": "https://registry.npmjs.org/@types/signalr/-/signalr-2.2.35.tgz",
"integrity": "sha512-YVIqZzmiNA8E1maMM57fqz44xJFTWxoNeuw3NfYv/j22lVhVXZeef1rOFo96ENpnrpEa+5ktKpDsGpozfKdTCw==",
"dev": true,
"requires": {
"@types/jquery": "*"
}
},
"@types/sizzle": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz",
"integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==",
"dev": true
},
"@types/source-list-map": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
@ -5387,6 +5411,11 @@
"integrity": "sha1-43zwsX8ZnM4jvqcbIDk5Uka07E4=",
"dev": true
},
"jquery": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz",
"integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw=="
},
"js-tokens": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
@ -8887,6 +8916,14 @@
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
"dev": true
},
"signalr": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/signalr/-/signalr-2.4.1.tgz",
"integrity": "sha512-HhIcA9kOE9WBs/DPHd+9jN90GDeSD7RRAETcmxn80laDBQmkQeHblzGBNw4rBzn1behe2WiFYQcbKyx11H3ADw==",
"requires": {
"jquery": ">=1.6.4"
}
},
"slash": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",

Просмотреть файл

@ -26,7 +26,9 @@
"@angular/platform-browser-dynamic": "~8.2.0",
"@angular/router": "~8.2.0",
"hammerjs": "^2.0.8",
"jquery": "^3.4.1",
"rxjs": "~6.4.0",
"signalr": "^2.4.1",
"tslib": "^1.10.0",
"zone.js": "~0.9.1"
},
@ -37,7 +39,9 @@
"@angular/language-service": "~8.2.0",
"@types/jasmine": "~3.3.8",
"@types/jasminewd2": "~2.0.3",
"@types/jquery": "^3.3.31",
"@types/node": "~8.9.4",
"@types/signalr": "^2.2.35",
"codelyzer": "^5.0.0",
"jasmine-core": "~3.4.0",
"jasmine-spec-reporter": "~4.2.1",

Просмотреть файл

@ -13,6 +13,7 @@ import { AuthService } from './services/auth.service';
import { UserService } from './services/user.service';
import { ApiService, BASE_PATH, Configuration } from './services/api.service';
import { ApiConfigService } from './services/api-config.service';
import { RemoteCommandService } from './services/remote-command.service'
import { BreadcrumbComponent } from './breadcrumb/breadcrumb.component';
import { SharedComponents } from './shared-components/shared-components.module'
@ -74,6 +75,7 @@ const routes: Routes = [{
AuthService,
UserService,
ApiService,
RemoteCommandService,
{ provide: BASE_PATH, useValue: environment.API_BASE_PATH },
{ provide: Configuration, useClass: ApiConfigService },
],

Просмотреть файл

@ -0,0 +1,40 @@
<h2 mat-dialog-title>
Run a Command
</h2>
<mat-dialog-content>
<div class="command">
<mat-form-field class="cmd-line">
<mat-label>Comamnd Line</mat-label>
<input matInput required autofocus [formControl]="cmdInput" (keyup.enter)="run()" />
</mat-form-field>
<button mat-button color="primary" [disabled]="!readyToRun" (click)="run()">Run</button>
<button mat-button [disabled]="!running" (click)="cancel()">Cancel</button>
</div>
<div class="result">
<div class="node-select">
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Node</th>
<td mat-cell *matCellDef="let node">{{node.name}}</td>
</ng-container>
<ng-container matColumnDef="state">
<th mat-header-cell *matHeaderCellDef mat-sort-header>State</th>
<td mat-cell *matCellDef="let node">{{node.state}}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let node; columns: displayedColumns;" (click)="select(node)" [ngClass]="cssClass(node)"></tr>
</table>
</div>
<div class="output">
<pre>
{{selected?.output}}
<span class="error">{{selected?.errorOut}}</span>
</pre>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button [mat-dialog-close]="undefined">Close</button>
</mat-dialog-actions>

Просмотреть файл

@ -0,0 +1,60 @@
mat-dialog-content {
display: flex;
flex-direction: column;
.command {
display: flex;
flex-direction: row;
.cmd-line {
flex: 1;
}
}
.result {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
.node-select {
box-sizing: border-box;
height: 100%;
overflow: auto;
.selected {
background-color: #fafafa;
}
tr:hover {
cursor: pointer;
}
}
.output {
box-sizing: border-box;
height: 100%;
flex: 1;
overflow: auto;
margin-left: 1em;
pre {
box-sizing: border-box;
height: 100%;
padding: 1em;
margin: 0;
background-color: black;
color: white;
overflow: auto;
}
.error {
color: red;
}
}
}
}
mat-dialog-actions {
justify-content: flex-end;
}

Просмотреть файл

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommanderComponent } from './commander.component';
describe('CommanderComponent', () => {
let component: CommanderComponent;
let fixture: ComponentFixture<CommanderComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ CommanderComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CommanderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

Просмотреть файл

@ -0,0 +1,143 @@
import { Component, Inject, ViewChild, OnInit, OnDestroy } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA, MatTableDataSource } from '@angular/material';
import { MatSort } from '@angular/material/sort';
import { RemoteCommandService, RemoteCommand, CommandOutput } from '../../services/remote-command.service'
class NodeOut {
name: string;
eof: boolean;
errorOut: string;
output: string;
get state(): string {
if (this.eof === undefined)
return '';
if (this.errorOut) {
return 'Error';
}
return this.eof ? 'End' : 'Running';
}
constructor(name: string) {
this.name = name;
}
update(output: CommandOutput): void {
this.eof = output.eof;
if (!this.eof) {
if (output.error) {
if (this.errorOut) {
this.errorOut += `\n${output.line}`;
}
else {
this.errorOut = output.line;
}
}
else {
if (this.output) {
this.output += `\n${output.line}`;
}
else {
this.output = output.line;
}
}
}
}
}
@Component({
selector: 'app-commander',
templateUrl: './commander.component.html',
styleUrls: ['./commander.component.scss']
})
export class CommanderComponent implements OnInit, OnDestroy {
private command: RemoteCommand;
dataSource: MatTableDataSource<NodeOut> = new MatTableDataSource();
displayedColumns = ['name', 'state'];
@ViewChild(MatSort, {static: true})
private sort: MatSort;
selected: NodeOut;
cmdInput: FormControl;
get cmdLine(): string {
try {
return this.cmdInput.value.trim();
}
catch {
return '';
}
}
private nameToIndex: Map<string, number>;
constructor(
@Inject(MAT_DIALOG_DATA) public nodeNames: string[],
private fb: FormBuilder,
private commandService: RemoteCommandService,
) {
this.cmdInput = this.fb.control('');
}
ngOnInit() {
this.dataSource.sort = this.sort;
this.dataSource.data = this.nodeNames.map(name => new NodeOut(name));
}
ngOnDestroy(): void {
if (this.command) {
this.command.disconnect();
this.command = null;
}
}
get readyToRun(): boolean {
return !this.running && this.cmdLine.length != 0;
}
get running(): boolean {
return this.command && this.dataSource.data.findIndex(node => node.eof != true) >= 0;
}
run(): void {
if (!this.readyToRun) {
return;
}
this.dataSource.data = this.nodeNames.map(name => new NodeOut(name));
this.select(this.dataSource.data[0]);
this.nameToIndex = new Map(this.nodeNames.map((name, idx) => [name, idx]));
this.command = this.commandService.create(this.cmdLine, this.nodeNames);
this.command.start(
output => {
console.log(output);
let idx = this.nameToIndex.get(output.nodeName);
let nodeOut = this.dataSource.data[idx];
nodeOut.update(output);
},
err => {
console.error(err);
}
);
}
cancel(): void {
if (this.command) {
this.command.cancel();
this.command = null;
}
}
select(node: NodeOut): void {
this.selected = node;
}
cssClass(node: NodeOut): string {
return node == this.selected ? 'selected' : '';
}
}

Просмотреть файл

@ -10,6 +10,7 @@
<mat-list-item><button mat-button [disabled]="!canLoadMore" (click)="loadMoreData()">{{loadDataActionText}}</button></mat-list-item>
<mat-list-item><button mat-button [disabled]="!anySelected" (click)="bringOnline()">Bring Online</button></mat-list-item>
<mat-list-item><button mat-button [disabled]="!anySelected" (click)="takeOffline()">Take Offline</button></mat-list-item>
<mat-list-item><button mat-button [disabled]="!anySelected" (click)="runCommand()">Run Command...</button></mat-list-item>
</mat-list>
</div>
</mat-sidenav>

Просмотреть файл

@ -10,6 +10,7 @@ import { UserService } from '../../services/user.service'
import { ApiService } from '../../services/api.service';
import { ColumnDef, ColumnSelectorComponent, ColumnSelectorInput, ColumnSelectorResult }
from '../../shared-components/column-selector/column-selector.component'
import { CommanderComponent } from '../commander/commander.component'
@Component({
selector: 'app-node-list',
@ -257,4 +258,9 @@ export class NodeListComponent implements OnInit, OnDestroy, AfterViewInit {
get toggleActionListIcon(): string {
return this.actionListHidden ? 'keyboard_arrow_left' : 'keyboard_arrow_right';
}
runCommand(): void {
let nodeNames = this.selection.selected.map(node => node.Name);
let dialogRef = this.dialog.open(CommanderComponent, { data: nodeNames, disableClose: true, minWidth: '50%' });
}
}

Просмотреть файл

@ -8,6 +8,7 @@ import { SharedComponents } from '../shared-components/shared-components.module'
import { NodesComponent } from './nodes.component'
import { NodeListComponent } from './node-list/node-list.component';
import { NodeMapComponent } from './node-map/node-map.component';
import { CommanderComponent } from './commander/commander.component';
const routes: Routes = [{
path: '',
@ -24,6 +25,7 @@ const routes: Routes = [{
NodesComponent,
NodeListComponent,
NodeMapComponent,
CommanderComponent,
],
imports: [
CommonModule,
@ -32,5 +34,6 @@ const routes: Routes = [{
MaterialModule,
SharedComponents,
],
entryComponents: [CommanderComponent],
})
export class NodesModule { }

Просмотреть файл

@ -1,6 +1,6 @@
import { Injectable, Optional, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, Subscriber } from 'rxjs'
import { Observable } from 'rxjs'
import { DefaultService, BASE_PATH, Configuration, NodeMetric } from '../api-client'
import { Node } from '../models/node'
import { Job } from '../models/job'

Просмотреть файл

@ -0,0 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { RemoteCommandService } from './remote-command.service';
describe('RemoteCommandService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: RemoteCommandService = TestBed.get(RemoteCommandService);
expect(service).toBeTruthy();
});
});

Просмотреть файл

@ -0,0 +1,86 @@
import { Injectable, Inject } from '@angular/core';
import { Observable } from 'rxjs'
import { BASE_PATH } from './api.service'
import { UserService } from './user.service'
import { User } from '../models/user';
@Injectable({
providedIn: 'root'
})
export class RemoteCommandService {
constructor(
@Inject(BASE_PATH) private basePath: string,
private userService: UserService,
) {
//NOTE: SignalR client relies on jQuery for XHR and thus auth header. However,
//there's no way for WS auth, so SignalR falls back to Long Poll!
$.ajaxSetup({
headers: {
'Authorization': this.basicAuthHeader
}
});
}
private get basicAuthHeader(): string {
let v = `${this.userService.user.username}:${this.userService.user.password}`;
return `Basic ${btoa(v)}`;
}
create(cmd: string, nodes: string[]): RemoteCommand {
return new RemoteCommand(this.basePath, cmd, nodes);
}
}
export interface CommandOutput {
jobId: number;
nodeName: string;
line: string;
eof: boolean;
error: boolean;
}
export class RemoteCommand {
private connection: SignalR.Hub.Connection;
private proxy: SignalR.Hub.Proxy;
private jobId: number;
constructor(private basePath: string, private cmd: string, private nodes: string[]) {}
start(output?: (output: CommandOutput) => void, errorOut?: (error: string) => void): void {
if (this.connection) {
return;
}
this.connection = $.hubConnection(this.basePath);
this.proxy = this.connection.createHubProxy('CommandHub');
if (output) {
this.proxy.on('OutputLine', (jobId: number, nodeName: string, line: string, eof: boolean, error: boolean) => {
output({ jobId, nodeName, line, eof, error });
});
}
if (errorOut) {
this.connection.error((err: any) => errorOut(err));
}
let result = this.connection.start()
.then(() => this.proxy.invoke('CreateCommand', this.cmd, this.nodes))
.then((jobId) => { this.jobId = jobId; return this.proxy.invoke('StartCommand', jobId); });
if (errorOut) {
result.catch((err) => errorOut(err));
}
}
cancel(): void {
if (this.jobId) {
this.proxy.invoke('CancelCommand', this.jobId);
}
}
disconnect(): void {
if (this.connection) {
this.connection.stop(true, true);
}
}
}

Просмотреть файл

@ -2,7 +2,10 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
"types": [
"jquery",
"signalr"
]
},
"files": [
"src/main.ts",

Просмотреть файл

@ -4,7 +4,9 @@
"outDir": "./out-tsc/spec",
"types": [
"jasmine",
"node"
"node",
"jquery",
"signalr"
]
},
"files": [