import {
Component,
OnInit,
Input,
Output,
ViewChild,
ViewEncapsulation,
EventEmitter,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import * as _ from 'lodash';
// import * as ace from 'ace-builds/src-noconflict/ace';
import 'ace-builds';
import 'ace-builds/webpack-resolver';
import 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/theme-chrome';
import { config } from 'ace-builds';
import { ResourceService } from '../../resource/shared/resource.service';
import { HelperService } from '../../shared/helper.service';
import { Node } from '../models/node.model';
import { Settings } from '../../core/settings';
import { InputDialogComponent } from '../dialog/input-dialog/input-dialog.component';
import { ConfirmDialogComponent } from '../dialog/confirm-dialog/confirm-dialog.component';
export type IdealState = {
id: string;
simpleFields?: { [key: string]: any };
listFields?: { [key: string]: any };
mapFields?: { [key: string]: any };
};
config.set(
'basePath',
'https://cdn.jsdelivr.net/npm/ace-builds@1.6.0/src-noconflict/'
);
config.setModuleUrl(
'ace/mode/javascript_worker',
'https://cdn.jsdelivr.net/npm/ace-builds@1.6.0/src-noconflict/worker-json.js'
);
@Component({
selector: 'hi-node-viewer',
templateUrl: './node-viewer.component.html',
styleUrls: ['./node-viewer.component.scss'],
providers: [ResourceService],
// Since we are importing external styles in this component
// we will not use Shadow DOM at all to make sure the styles apply
encapsulation: ViewEncapsulation.None,
})
export class NodeViewerComponent implements OnInit {
isLoading = true;
clusterName: string;
resourceName: string;
@ViewChild('simpleTable', { static: true }) simpleTable;
@ViewChild('listTable', { static: true }) listTable;
@ViewChild('mapTable', { static: true }) mapTable;
@Output('update')
change: EventEmitter<Node> = new EventEmitter<Node>();
@Output('create')
create: EventEmitter<Node> = new EventEmitter<Node>();
@Output('delete')
delete: EventEmitter<Node> = new EventEmitter<Node>();
@Input()
unlockable = false;
@Input()
loadingIndicator = false;
private _editable = false;
protected _obj: any;
protected node: Node;
headerHeight = Settings.tableHeaderHeight;
rowHeight = Settings.tableRowHeight;
sorts = [{ prop: 'name', dir: 'asc' }];
keyword = '';
columns = {
simpleConfigs: [
{
name: 'Name',
editable: false,
},
{
name: 'Value',
editable: false,
},
],
listConfigs: [
{
name: 'Value',
editable: false,
},
],
};
_simpleConfigs: any[];
_listConfigs: any[];
_mapConfigs: any[];
// MODE 1: use directly in components
@Input()
set obj(value: any) {
if (value !== null) {
this._obj = value;
this.node = new Node(value);
}
}
get obj(): any {
return this._obj;
}
set objString(value: string | null) {
let parsedValue = null;
if (value && value !== null) {
parsedValue = JSON.parse(value);
}
this.obj = parsedValue;
this.node = new Node(parsedValue);
}
get objString(): string {
return JSON.stringify(this.obj, null, 2);
}
set editable(value: boolean) {
this._editable = value;
this.columns.simpleConfigs[1].editable = this._editable;
this.columns.listConfigs[0].editable = this._editable;
}
get editable() {
return this._editable;
}
get simpleConfigs(): any[] {
return this.node
? _.filter(
this.node.simpleFields,
(config) =>
config.name.toLowerCase().indexOf(this.keyword) >= 0 ||
config.value.toLowerCase().indexOf(this.keyword) >= 0
)
: [];
}
get listConfigs(): any[] {
return this.node
? _.filter(
this.node.listFields,
(config) =>
config.name.toLowerCase().indexOf(this.keyword) >= 0 ||
_.some(
config.value as any[],
(subconfig) =>
subconfig.value.toLowerCase().indexOf(this.keyword) >= 0
)
)
: [];
}
get mapConfigs(): any[] {
return this.node
? _.filter(
this.node.mapFields,
(config) =>
config.name.toLowerCase().trim().indexOf(this.keyword) >= 0 ||
_.some(
config.value as any[],
(subconfig) =>
subconfig.name.toLowerCase().trim().indexOf(this.keyword) >=
0 || subconfig.value.toLowerCase().indexOf(this.keyword) >= 0
)
)
: [];
}
public constructor(
protected dialog: MatDialog,
protected route: ActivatedRoute,
protected resourceService: ResourceService,
protected helper: HelperService
) {}
ngOnInit() {
// MODE 2: use in router
if (this.route.snapshot.data.path) {
const path = this.route.snapshot.data.path;
// try parent data first
this.obj = _.get(this.route.parent, `snapshot.data.${path}`);
if (this.obj == null) {
// try self data then
this.obj = _.get(this.route.snapshot.data, path);
}
this.objString = JSON.stringify(this.obj, null, 2);
}
if (this.route.parent) {
this.clusterName =
this.route.parent.snapshot.params.name ||
this.route.parent.snapshot.params.cluster_name;
this.resourceName = this.route.parent.snapshot.params.resource_name;
}
}
updateFilter(event) {
this.keyword = event.target.value.toLowerCase().trim();
// Whenever the filter changes, always go back to the first page
if (this.simpleTable) {
this.simpleTable.offset = 0;
}
if (this.listTable) {
this.listTable.offset = 0;
}
if (this.mapTable) {
this.mapTable.offset = 0;
}
}
getNameCellClass({ value }): any {
return {
// highlight HELIX own configs
primary: _.snakeCase(value).toUpperCase() === value,
};
}
onCreate(type) {
this.dialog
.open(InputDialogComponent, {
data: {
title: `Create a new ${type} configuration`,
message:
"Please enter the name of the new configuration. You'll be able to add values later:",
values: {
name: {
label: 'the name of the new configuration',
},
},
},
})
.afterClosed()
.subscribe((result) => {
if (result) {
const entry = [
{
name: result.name.value,
value: [],
},
];
const newNode: Node = new Node(null);
if (type === 'list') {
newNode.listFields = entry;
} else if (type === 'map') {
newNode.mapFields = entry;
}
this.create.emit(newNode);
}
});
}
beforeDelete(type, row) {
this.dialog
.open(ConfirmDialogComponent, {
data: {
title: 'Confirmation',
message: 'Are you sure you want to delete this configuration?',
},
})
.afterClosed()
.subscribe((result) => {
if (result) {
this.onDelete(type, row);
}
});
}
onDelete(type, row) {
const newNode: Node = new Node(null);
if (type === 'simple') {
newNode.appendSimpleField(row.name, '');
} else if (type === 'list') {
newNode.listFields = [{ name: row.name.trim(), value: [] }];
} else if (type === 'map') {
newNode.mapFields = [{ name: row.name.trim(), value: null }];
}
this.delete.emit(newNode);
}
created(type, data, key) {
const newNode: Node = new Node(null);
switch (type) {
case 'simple':
newNode.appendSimpleField(data.name.value, data.value.value);
break;
case 'list':
if (key) {
const entry = _.find(this.node.listFields, { name: key });
entry.value.push({
name: '',
value: data.value.value,
});
newNode.listFields.push(entry);
}
break;
case 'map':
if (key) {
const entry = _.find(this.node.mapFields, { name: key.trim() });
_.forEach(entry.value, (item: any) => {
newNode.appendMapField(key, item.name, item.value);
});
newNode.appendMapField(key, data.name.value, data.value.value);
}
break;
}
this.create.emit(newNode);
}
edited(type, { row, column, value }, key, isDeleting) {
if (!isDeleting && column.name !== 'Value') {
return;
}
const newNode: Node = new Node(null);
switch (type) {
case 'simple':
newNode.appendSimpleField(row.name, value);
break;
case 'list':
if (key) {
const entry = _.find(this.node.listFields, { name: key });
const index = _.findIndex(entry.value, { value: row.value });
if (isDeleting) {
entry.value.splice(index, 1);
} else {
entry.value[index].value = value;
}
newNode.listFields.push(entry);
}
break;
case 'map':
if (key) {
// have to fetch all other configs under this key
const entry = _.find(this.node.mapFields, { name: key.trim() });
newNode.mapFields = [{ name: key.trim(), value: [] }];
_.forEach(entry.value, (item: any) => {
if (item.name === row.name) {
if (!isDeleting) {
newNode.appendMapField(key.trim(), item.name.trim(), value);
}
} else {
newNode.appendMapField(key.trim(), item.name.trim(), item.value);
}
});
}
break;
}
const path = this?.route?.snapshot?.data?.path;
if (path && path === 'idealState') {
const idealState: IdealState = {
id: this.resourceName,
};
// format the payload the way that helix-rest expects
// before: { simpleFields: [{ name: 'NUM_PARTITIONS', value: 2 }] };
// after: { simpleFields: { NUM_PARTITIONS: 2 } };
function appendIdealStateProperty(property: keyof Node) {
if (Array.isArray(newNode[property]) && newNode[property].length > 0) {
idealState[property] = {} as any;
(newNode[property] as any[]).forEach((field) => {
idealState[property][field.name] = field.value;
});
}
}
Object.keys(newNode).forEach((key) =>
appendIdealStateProperty(key as keyof Node)
);
const observer = this.resourceService.setIdealState(
this.clusterName,
this.resourceName,
idealState
);
if (observer) {
this.isLoading = true;
observer.subscribe(
() => {
this.helper.showSnackBar('Ideal State updated!');
},
(error) => {
this.helper.showError(error);
this.isLoading = false;
},
() => (this.isLoading = false)
);
}
}
this.change.emit(newNode);
}
}
<!--
~ Licensed to the Apache Software Foundation (ASF) under one
~ or more contributor license agreements. See the NOTICE file
~ distributed with this work for additional information
~ regarding copyright ownership. The ASF licenses this file
~ to you under the Apache License, Version 2.0 (the
~ "License"); you may not use this file except in compliance
~ with the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing,
~ software distributed under the License is distributed on an
~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
~ KIND, either express or implied. See the License for the
~ specific language governing permissions and limitations
~ under the License.
-->
<section
class="node-viewer"
fxLayout="column"
fxLayoutAlign="center center"
fxLayoutGap="10px"
>
<mat-progress-bar
*ngIf="loadingIndicator"
mode="indeterminate"
></mat-progress-bar>
<mat-button-toggle-group #group="matButtonToggleGroup" value="table">
<mat-button-toggle value="table"> Table View </mat-button-toggle>
<mat-button-toggle value="tree"> Tree View </mat-button-toggle>
<mat-button-toggle value="json"> JSON View </mat-button-toggle>
</mat-button-toggle-group>
<section class="viewer" [ngSwitch]="group.value" fxFlexFill>
<ngx-json-viewer *ngSwitchCase="'tree'" [json]="obj"></ngx-json-viewer>
<ace-editor
*ngSwitchCase="'json'"
[(text)]="objString"
mode="json"
theme="chrome"
[options]="{ useWorker: false }"
style="min-height: 300px"
#editor
>
</ace-editor>
<section *ngSwitchCase="'table'">
<!-- TODO vxu: use mat-simple-table when it's available -->
<section fxLayout="row" fxLayoutAlign="center center">
<span fxFlex="1 1 auto"></span>
<mat-icon>search</mat-icon>
<mat-form-field class="search-form-field">
<input
matInput
placeholder="Type to filter the fields..."
(keyup)="updateFilter($event)"
/>
</mat-form-field>
<span fxFlex="1 1 auto"></span>
<!-- *ngIf="unlockable" -->
<button
mat-button
(click)="editable = !editable"
[matTooltip]="
editable
? 'Click to prevent further changes'
: 'Click to make changes'
"
>
<mat-icon>{{ editable ? 'lock_open' : 'lock' }}</mat-icon>
{{ editable ? 'Unlocked' : 'Locked' }}
</button>
</section>
<mat-card>
<mat-card-header>
<mat-card-title>
Simple Fields
<span *ngIf="simpleConfigs.length == 0">is empty.</span>
<span *ngIf="keyword" class="primary">(filtered)</span>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<hi-data-table
*ngIf="simpleConfigs.length || editable"
[rows]="simpleConfigs"
[sorts]="sorts"
[columns]="columns.simpleConfigs"
[deletable]="editable"
[insertable]="editable"
(update)="edited('simple', $event)"
(create)="created('simple', $event)"
(delete)="onDelete('simple', $event.row)"
>
</hi-data-table>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
<mat-card-title>
List Fields
<span *ngIf="listConfigs.length == 0">is empty.</span>
<span *ngIf="keyword" class="primary">(filtered)</span>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<ngx-datatable
*ngIf="listConfigs.length || editable"
#listTable
class="material"
[headerHeight]="headerHeight"
rowHeight="auto"
[footerHeight]="headerHeight"
columnMode="force"
[rows]="listConfigs"
[sorts]="sorts"
[limit]="10"
>
<ngx-datatable-column
*ngIf="editable"
[width]="40"
[resizeable]="false"
[draggable]="false"
[canAutoResize]="false"
>
<ng-template let-row="row" ngx-datatable-cell-template>
<button
mat-icon-button
class="delete-button"
matTooltip="Click to delete"
(click)="beforeDelete('list', row)"
>
<mat-icon>delete_forever</mat-icon>
</button>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column
name="Name"
[width]="80"
[cellClass]="getNameCellClass"
></ngx-datatable-column>
<ngx-datatable-column name="Value" [width]="400">
<ng-template
let-row="row"
let-value="value"
ngx-datatable-cell-template
>
<hi-data-table
[rows]="value"
[sorts]="sorts"
[columns]="columns.listConfigs"
[deletable]="editable"
[insertable]="editable"
(update)="edited('list', $event, row.name)"
(create)="created('list', $event, row.name)"
(delete)="edited('list', $event, row.name, true)"
>
</hi-data-table>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-footer>
<ng-template
ngx-datatable-footer-template
let-rowCount="rowCount"
let-pageSize="pageSize"
let-curPage="curPage"
>
<section
class="footer"
fxLayout="row"
fxLayoutAlign="space-between center"
>
<button
mat-button
*ngIf="editable"
(click)="onCreate('list')"
>
<mat-icon>add</mat-icon>
Add new entry
</button>
<section>{{ rowCount }} total</section>
<section>
<datatable-pager
[pagerLeftArrowIcon]="'datatable-icon-left'"
[pagerRightArrowIcon]="'datatable-icon-right'"
[pagerPreviousIcon]="'datatable-icon-prev'"
[pagerNextIcon]="'datatable-icon-skip'"
[page]="curPage"
[size]="pageSize"
[count]="rowCount"
[hidden]="!(rowCount / pageSize > 1)"
(change)="listTable.onFooterPage($event)"
>
</datatable-pager>
</section>
</section>
</ng-template>
</ngx-datatable-footer>
</ngx-datatable>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
<mat-card-title>
Map Fields
<span *ngIf="mapConfigs.length == 0">is empty.</span>
<span *ngIf="keyword" class="primary">(filtered)</span>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<ngx-datatable
*ngIf="mapConfigs.length || editable"
#mapTable
class="material"
[headerHeight]="headerHeight"
rowHeight="auto"
[footerHeight]="headerHeight"
columnMode="force"
[rows]="mapConfigs"
[sorts]="sorts"
[limit]="10"
>
<ngx-datatable-column
*ngIf="editable"
[width]="40"
[resizeable]="false"
[draggable]="false"
[canAutoResize]="false"
>
<ng-template let-row="row" ngx-datatable-cell-template>
<button
mat-icon-button
class="delete-button"
matTooltip="Click to delete"
(click)="beforeDelete('map', row)"
>
<mat-icon>delete_forever</mat-icon>
</button>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column
name="Name"
[width]="80"
[cellClass]="getNameCellClass"
></ngx-datatable-column>
<ngx-datatable-column name="Value" [width]="500">
<ng-template
let-row="row"
let-value="value"
ngx-datatable-cell-template
>
<hi-data-table
[rows]="value"
[sorts]="sorts"
[columns]="columns.simpleConfigs"
[deletable]="editable"
[insertable]="editable"
(update)="edited('map', $event, row.name)"
(create)="created('map', $event, row.name)"
(delete)="edited('map', $event, row.name, true)"
>
</hi-data-table>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-footer>
<ng-template
ngx-datatable-footer-template
let-rowCount="rowCount"
let-pageSize="pageSize"
let-curPage="curPage"
>
<section
class="footer"
fxLayout="row"
fxLayoutAlign="space-between center"
>
<button mat-button *ngIf="editable" (click)="onCreate('map')">
<mat-icon>add</mat-icon>
Add new entry
</button>
<section>{{ rowCount }} total</section>
<section>
<datatable-pager
[pagerLeftArrowIcon]="'datatable-icon-left'"
[pagerRightArrowIcon]="'datatable-icon-right'"
[pagerPreviousIcon]="'datatable-icon-prev'"
[pagerNextIcon]="'datatable-icon-skip'"
[page]="curPage"
[size]="pageSize"
[count]="rowCount"
[hidden]="!(rowCount / pageSize > 1)"
(change)="mapTable.onFooterPage($event)"
>
</datatable-pager>
</section>
</section>
</ng-template>
</ngx-datatable-footer>
</ngx-datatable>
</mat-card-content>
</mat-card>
</section>
</section>
</section>