feat(#3810): Support cloning of dashboards (#3811)
Co-authored-by: Philipp Zehnder <tenthe@users.noreply.github.com>
diff --git a/streampipes-model/src/main/java/org/apache/streampipes/model/dashboard/DashboardModel.java b/streampipes-model/src/main/java/org/apache/streampipes/model/dashboard/DashboardModel.java
index 2d1108c..ffa188e 100644
--- a/streampipes-model/src/main/java/org/apache/streampipes/model/dashboard/DashboardModel.java
+++ b/streampipes-model/src/main/java/org/apache/streampipes/model/dashboard/DashboardModel.java
@@ -45,6 +45,7 @@
private String name;
private String description;
private boolean displayHeader;
+ private int gridColumns = 8;
private Map<String, Object> dashboardTimeSettings;
private Map<String, Object> dashboardGeneralSettings;
@@ -148,6 +149,10 @@
this.dashboardLiveSettings = dashboardLiveSettings;
}
+ public int getGridColumns() {
+ return gridColumns;
+ }
+
@Override
public ResourceMetadata getMetadata() {
return metadata;
diff --git a/ui/cypress/support/utils/GeneralUtils.ts b/ui/cypress/support/utils/GeneralUtils.ts
index 8db505e..8932400 100644
--- a/ui/cypress/support/utils/GeneralUtils.ts
+++ b/ui/cypress/support/utils/GeneralUtils.ts
@@ -20,4 +20,20 @@
public static tab(identifier: string) {
return cy.dataCy(`tab-${identifier}`).click();
}
+
+ public static openMenuForRow(rowText: string) {
+ cy.contains('[role="row"], tr, mat-row', rowText) // be flexible on row element
+ .scrollIntoView()
+ .within(() => {
+ // Hover the trigger to open the menu
+ cy.dataCy('more-options').trigger('mouseenter', {
+ force: true,
+ });
+ });
+
+ // Wait for the CDK overlay panel to become visible
+ cy.get('.cdk-overlay-container .mat-mdc-menu-panel:visible').should(
+ 'exist',
+ );
+ }
}
diff --git a/ui/cypress/support/utils/datalake/DataLakeUtils.ts b/ui/cypress/support/utils/datalake/DataLakeUtils.ts
index b246800..4b95327 100644
--- a/ui/cypress/support/utils/datalake/DataLakeUtils.ts
+++ b/ui/cypress/support/utils/datalake/DataLakeUtils.ts
@@ -25,6 +25,7 @@
import { ConnectBtns } from '../connect/ConnectBtns';
import { AdapterBuilder } from '../../builder/AdapterBuilder';
import { differenceInMonths } from 'date-fns';
+import { GeneralUtils } from '../GeneralUtils';
export class DataLakeUtils {
public static goToDatalake() {
@@ -174,8 +175,7 @@
}
public static editDashboard(dashboardName: string) {
- // Click edit button
- // following only works if single view is available
+ GeneralUtils.openMenuForRow(dashboardName);
cy.dataCy('edit-dashboard-' + dashboardName).click();
}
@@ -200,6 +200,7 @@
}
public static deleteDashboard(dashboardName: string) {
+ GeneralUtils.openMenuForRow(dashboardName);
cy.dataCy('delete-dashboard-' + dashboardName, {
timeout: 10000,
}).click();
@@ -214,6 +215,7 @@
}
public static cancelDeleteDashboard(dashboardName: string) {
+ GeneralUtils.openMenuForRow(dashboardName);
cy.dataCy('delete-dashboard-' + dashboardName, {
timeout: 10000,
}).click();
@@ -364,7 +366,7 @@
.click();
}
- public static clickOrderBy(order: String) {
+ public static clickOrderBy(order: string) {
if (order == 'ascending') {
cy.dataCy('ascending-radio-button').click();
} else {
diff --git a/ui/projects/streampipes/platform-services/src/lib/model/dashboard/dashboard.model.ts b/ui/projects/streampipes/platform-services/src/lib/model/dashboard/dashboard.model.ts
index 79bd32d..70c2e3e 100644
--- a/ui/projects/streampipes/platform-services/src/lib/model/dashboard/dashboard.model.ts
+++ b/ui/projects/streampipes/platform-services/src/lib/model/dashboard/dashboard.model.ts
@@ -52,6 +52,7 @@
dashboardLiveSettings: DashboardLiveSettings;
elementId?: string;
metadata: ResourceMetadata;
+ gridColumns: number;
rev?: string;
}
diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-actions.directive.ts b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-actions.directive.ts
new file mode 100644
index 0000000..0b7e740
--- /dev/null
+++ b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-actions.directive.ts
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ *
+ */
+
+import { Directive } from '@angular/core';
+@Directive({ selector: 'ng-template[spTableActions]', standalone: false })
+export class SpTableActionsDirective {}
diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
index c9134d4..5f7ad2c 100644
--- a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
+++ b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
@@ -20,8 +20,61 @@
<table mat-table class="sp-table" [dataSource]="dataSource">
<ng-content></ng-content>
+ @if (showActionsMenu) {
+ <ng-container matColumnDef="actions">
+ <th
+ fxFlex
+ fxLayoutAlign="center center"
+ mat-header-cell
+ *matHeaderCellDef
+ ></th>
+ <td
+ fxFlex
+ fxLayoutAlign="end center"
+ mat-cell
+ *matCellDef="let element"
+ >
+ <div
+ [matMenuTriggerFor]="menu"
+ #menuTrigger="matMenuTrigger"
+ (mouseenter)="mouseEnter(menuTrigger)"
+ (mouseleave)="mouseLeave(menuTrigger)"
+ >
+ <button
+ mat-icon-button
+ [matMenuTriggerFor]="menu"
+ #menuTrigger="matMenuTrigger"
+ (click)="$event.stopPropagation()"
+ [attr.data-cy]="'more-options'"
+ >
+ <mat-icon>more_vert</mat-icon>
+ </button>
+ </div>
+ <mat-menu #menu="matMenu" [hasBackdrop]="false">
+ <div
+ (mouseenter)="mouseEnter(menuTrigger)"
+ (mouseleave)="mouseLeave(menuTrigger)"
+ >
+ <ng-container
+ *ngTemplateOutlet="
+ actionsTemplate;
+ context: { $implicit: element }
+ "
+ >
+ </ng-container>
+ </div>
+ </mat-menu>
+ </td>
+ </ng-container>
+ }
+
<tr mat-header-row *matHeaderRowDef="columns"></tr>
- <tr mat-row *matRowDef="let row; columns: columns"></tr>
+ <tr
+ mat-row
+ *matRowDef="let row; columns: columns"
+ (click)="rowClicked.emit(row)"
+ [ngClass]="rowsClickable ? 'cursor-pointer' : ''"
+ ></tr>
<tr class="mat-row" *matNoDataRow>
<td
diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
index d390c08..bd8eea2 100644
--- a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
+++ b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
@@ -28,3 +28,7 @@
height: var(--mat-table-row-item-container-height, 52px);
text-align: center;
}
+
+.cursor-pointer {
+ cursor: pointer;
+}
diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
index 2b31397..42fa5d2 100644
--- a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
+++ b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
@@ -22,8 +22,11 @@
Component,
ContentChild,
ContentChildren,
+ EventEmitter,
Input,
+ Output,
QueryList,
+ TemplateRef,
ViewChild,
} from '@angular/core';
import {
@@ -35,6 +38,8 @@
MatTableDataSource,
} from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
+import { SpTableActionsDirective } from './sp-table-actions.directive';
+import { MatMenuTrigger } from '@angular/material/menu';
@Component({
selector: 'sp-table',
@@ -51,12 +56,20 @@
@ViewChild(MatTable, { static: true }) table: MatTable<T>;
@Input() columns: string[];
+ @Input() rowsClickable = false;
+ @Input() showActionsMenu = false;
@Input() dataSource: MatTableDataSource<T>;
+ @Output() rowClicked = new EventEmitter<T>();
+
@ViewChild('paginator') paginator: MatPaginator;
+ @ContentChild(SpTableActionsDirective, { read: TemplateRef })
+ actionsTemplate?: TemplateRef<any>;
pageSize = 1;
+ timedOutCloser: any;
+ trigger: MatMenuTrigger | undefined = undefined;
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
@@ -72,4 +85,22 @@
);
this.table.setNoDataRow(this.noDataRow);
}
+
+ mouseEnter(trigger) {
+ if (this.timedOutCloser) {
+ clearTimeout(this.timedOutCloser);
+ }
+ if (this.trigger !== undefined) {
+ this.trigger.closeMenu();
+ }
+ trigger.openMenu();
+ this.trigger = trigger;
+ }
+
+ mouseLeave(trigger) {
+ this.timedOutCloser = setTimeout(() => {
+ trigger.closeMenu();
+ this.trigger = undefined;
+ }, 50);
+ }
}
diff --git a/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts b/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts
index 0ead401..0bbe262 100644
--- a/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts
+++ b/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts
@@ -98,6 +98,7 @@
import { MatExpansionModule } from '@angular/material/expansion';
import { SortByRuntimeNamePipe } from './pipes/sort-by-runtime-name.pipe';
import { DragDropModule } from '@angular/cdk/drag-drop';
+import { SpTableActionsDirective } from './components/sp-table/sp-table-actions.directive';
@NgModule({
declarations: [
@@ -149,6 +150,7 @@
InputSchemaPanelComponent,
InputSchemaPropertyComponent,
SortByRuntimeNamePipe,
+ SpTableActionsDirective,
],
imports: [
CommonModule,
@@ -216,6 +218,7 @@
PipelineElementComponent,
InputSchemaPanelComponent,
SidebarResizeComponent,
+ SpTableActionsDirective,
],
})
export class SharedUiModule {}
diff --git a/ui/projects/streampipes/shared-ui/src/public-api.ts b/ui/projects/streampipes/shared-ui/src/public-api.ts
index ae6cdd2..f416efe 100644
--- a/ui/projects/streampipes/shared-ui/src/public-api.ts
+++ b/ui/projects/streampipes/shared-ui/src/public-api.ts
@@ -42,6 +42,7 @@
export * from './lib/components/sp-exception-message/exception-details/exception-details.component';
export * from './lib/components/sp-label/sp-label.component';
export * from './lib/components/sp-table/sp-table.component';
+export * from './lib/components/sp-table/sp-table-actions.directive';
export * from './lib/components/warning-box/warning-box.component';
export * from './lib/components/time-selector/time-selector.model';
export * from './lib/components/time-selector/time-range-selector.component';
diff --git a/ui/src/app/core-services/id-generator/id-generator.service.ts b/ui/src/app/core-services/id-generator/id-generator.service.ts
index d3d4a0b..b75a5b7 100644
--- a/ui/src/app/core-services/id-generator/id-generator.service.ts
+++ b/ui/src/app/core-services/id-generator/id-generator.service.ts
@@ -46,4 +46,8 @@
public generatePrefixedId(): string {
return this.idPrefix + this.generate(6);
}
+
+ public generateWithPrefix(prefix: string, length: number): string {
+ return prefix + this.generate(length);
+ }
}
diff --git a/ui/src/app/core-services/template/PipelineInvocationBuilder.ts b/ui/src/app/core-services/template/PipelineInvocationBuilder.ts
deleted file mode 100644
index 9192a73..0000000
--- a/ui/src/app/core-services/template/PipelineInvocationBuilder.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * 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.
- *
- */
-import {
- FreeTextStaticProperty,
- MappingPropertyUnary,
- PipelineTemplateInvocation,
-} from '@streampipes/platform-services';
-
-export class PipelineInvocationBuilder {
- private pipelineTemplateInvocation: PipelineTemplateInvocation;
-
- constructor(pipelineTemplateInvocation: PipelineTemplateInvocation) {
- this.pipelineTemplateInvocation = pipelineTemplateInvocation;
- }
-
- public static create(
- pipelineTemplateInvocation: PipelineTemplateInvocation,
- ) {
- return new PipelineInvocationBuilder(pipelineTemplateInvocation);
- }
-
- public setTemplateId(id: string) {
- this.pipelineTemplateInvocation.pipelineTemplateId = id;
- return this;
- }
-
- public setName(name: string) {
- this.pipelineTemplateInvocation.kviName = name;
- return this;
- }
-
- public setFreeTextStaticProperty(name: string, value: string) {
- this.pipelineTemplateInvocation.staticProperties.forEach(property => {
- if (
- property instanceof FreeTextStaticProperty &&
- 'jsplumb_domId2' + name === property.internalName
- ) {
- property.value = value;
- }
- });
-
- return this;
- }
-
- public setOneOfStaticProperty(name: string, value: string) {
- this.pipelineTemplateInvocation.staticProperties.forEach(property => {
- if (
- // property instanceof OneOfStaticProperty &&
- property['@class'] ===
- 'org.apache.streampipes.model.staticproperty.OneOfStaticProperty' &&
- 'jsplumb_domId2' + name === property.internalName
- ) {
- // set selected for selected option
- property.options.forEach(option => {
- if (option.name === value) {
- option.selected = true;
- }
- });
- }
- });
-
- return this;
- }
-
- public setMappingPropertyUnary(name: string, value: string) {
- this.pipelineTemplateInvocation.staticProperties.forEach(property => {
- if (
- property instanceof MappingPropertyUnary &&
- 'jsplumb_domId2' + name === property.internalName
- ) {
- property.selectedProperty = value;
- }
- });
-
- return this;
- }
-
- public build() {
- return this.pipelineTemplateInvocation;
- }
-}
diff --git a/ui/src/app/dashboard-shared/components/chart-view/grid-view/dashboard-grid-view.component.ts b/ui/src/app/dashboard-shared/components/chart-view/grid-view/dashboard-grid-view.component.ts
index d11558f..4e8d67e 100644
--- a/ui/src/app/dashboard-shared/components/chart-view/grid-view/dashboard-grid-view.component.ts
+++ b/ui/src/app/dashboard-shared/components/chart-view/grid-view/dashboard-grid-view.component.ts
@@ -59,8 +59,8 @@
disablePushOnDrag: true,
draggable: { enabled: this.editMode },
gridType: GridType.VerticalFixed,
- minCols: 8,
- maxCols: 8,
+ minCols: this.dashboard.gridColumns,
+ maxCols: this.dashboard.gridColumns,
minRows: 4,
fixedRowHeight: 100,
fixedColWidth: 100,
diff --git a/ui/src/app/dashboard-shared/components/chart-view/slide-view/dashboard-slide-view.component.scss b/ui/src/app/dashboard-shared/components/chart-view/slide-view/dashboard-slide-view.component.scss
index 96db439..dd87689 100644
--- a/ui/src/app/dashboard-shared/components/chart-view/slide-view/dashboard-slide-view.component.scss
+++ b/ui/src/app/dashboard-shared/components/chart-view/slide-view/dashboard-slide-view.component.scss
@@ -23,6 +23,7 @@
cursor: pointer;
margin: 5px 10px;
padding: 5px;
+ background: var(--color-bg-0);
}
.viz-preview-selected {
@@ -32,8 +33,8 @@
.selection-box {
overflow-y: auto;
overflow-x: hidden;
- margin-bottom: 5px;
- height: calc(100vh - 147px);
+ height: calc(100vh - 137px);
+ border-right: 1px solid var(--color-bg-3);
max-width: 100%;
}
diff --git a/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.html b/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.html
index f311783..72f7db2 100644
--- a/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.html
+++ b/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.html
@@ -24,7 +24,10 @@
<sp-table
fxFlex="100"
[columns]="displayedColumns"
+ [showActionsMenu]="true"
+ [rowsClickable]="true"
[dataSource]="dataSource"
+ (rowClicked)="onRowClicked($event)"
>
<ng-container matColumnDef="name">
<th
@@ -98,77 +101,73 @@
</td>
</ng-container>
- <ng-container matColumnDef="actions">
- <th
- fxFlex
- fxLayoutAlign="center center"
- mat-header-cell
- *matHeaderCellDef
- ></th>
- <td
- fxFlex
- fxLayoutAlign="start center"
- mat-cell
- *matCellDef="let element"
+ <ng-template spTableActions let-element>
+ <button
+ mat-menu-item
+ (click)="showDashboard(element); $event.stopPropagation()"
>
- <div fxLayout="row" fxFlex="100" fxLayoutAlign="end center">
- <button
- mat-icon-button
- color="accent"
- [matTooltip]="'Show dashboard' | translate"
- (click)="showDashboard(element)"
- >
- <i class="material-icons">visibility</i>
- </button>
- <button
- mat-icon-button
- color="accent"
- [matTooltip]="'Edit dashboard' | translate"
- *ngIf="hasDataExplorerWritePrivileges"
- [attr.data-cy]="'edit-dashboard-' + element.name"
- (click)="editDashboard(element)"
- >
- <i class="material-icons">edit</i>
- </button>
- <button
- mat-icon-button
- color="accent"
- [matTooltip]="'Kiosk mode' | translate"
- (click)="openDashboardInKioskMode(element)"
- >
- <i class="material-icons">open_in_new</i>
- </button>
- <button
- mat-icon-button
- color="accent"
- [matTooltip]="'Dashboard settings' | translate"
- *ngIf="hasDataExplorerWritePrivileges"
- (click)="openEditDashboardDialog(element)"
- >
- <i class="material-icons">settings</i>
- </button>
- <button
- mat-icon-button
- color="accent"
- [matTooltip]="'Manage permissions' | translate"
- *ngIf="isAdmin"
- (click)="showPermissionsDialog(element)"
- >
- <i class="material-icons">share</i>
- </button>
- <button
- mat-icon-button
- color="accent"
- [matTooltip]="'Delete chart' | translate"
- *ngIf="hasDataExplorerWritePrivileges"
- [attr.data-cy]="'delete-dashboard-' + element.name"
- (click)="openDeleteDashboardDialog(element)"
- >
- <i class="material-icons">delete</i>
- </button>
- </div>
- </td>
- </ng-container>
+ <mat-icon>visibility</mat-icon>
+ <span>{{ 'Show' | translate }}</span>
+ </button>
+ @if (hasDataExplorerWritePrivileges) {
+ <button
+ mat-menu-item
+ [attr.data-cy]="'edit-dashboard-' + element.name"
+ (click)="editDashboard(element)"
+ >
+ <mat-icon>edit</mat-icon>
+ <span>{{ 'Edit' | translate }}</span>
+ </button>
+ <button
+ mat-menu-item
+ [attr.data-cy]="'clone-dashboard-' + element.name"
+ (click)="openCloneDialog(element)"
+ >
+ <mat-icon>flip_to_front</mat-icon>
+ <span>{{ 'Clone' | translate }}</span>
+ </button>
+ }
+ <button
+ mat-menu-item
+ [attr.data-cy]="'kiosk-mode-dashboard-' + element.name"
+ (click)="openDashboardInKioskMode(element)"
+ >
+ <mat-icon>open_in_new</mat-icon>
+ <span>{{ 'Kiosk mode' | translate }}</span>
+ </button>
+ @if (hasDataExplorerWritePrivileges) {
+ <button
+ mat-menu-item
+ [attr.data-cy]="
+ 'edit-dashboard-settings-' + element.name
+ "
+ (click)="openEditDashboardDialog(element)"
+ >
+ <mat-icon>settings</mat-icon>
+ <span>{{ 'Settings' | translate }}</span>
+ </button>
+ }
+ <button
+ mat-menu-item
+ [attr.data-cy]="
+ 'manage-dashboard-permissions-' + element.name
+ "
+ (click)="showPermissionsDialog(element)"
+ >
+ <mat-icon>share</mat-icon>
+ <span>{{ 'Manage permissions' | translate }}</span>
+ </button>
+ @if (hasDataExplorerWritePrivileges) {
+ <button
+ mat-menu-item
+ [attr.data-cy]="'delete-dashboard-' + element.name"
+ (click)="openDeleteDashboardDialog(element)"
+ >
+ <mat-icon>delete</mat-icon>
+ <span>{{ 'Delete' | translate }}</span>
+ </button>
+ }
+ </ng-template>
</sp-table>
</div>
</div>
diff --git a/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.ts b/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.ts
index 43053f6..dd65b25 100644
--- a/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.ts
+++ b/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.ts
@@ -22,6 +22,7 @@
import {
ConfirmDialogComponent,
DateFormatService,
+ PanelType,
} from '@streampipes/shared-ui';
import { SpDataExplorerOverviewDirective } from '../../../../data-explorer/components/overview/data-explorer-overview.directive';
import { MatDialog } from '@angular/material/dialog';
@@ -29,6 +30,8 @@
import { DataExplorerSharedService } from '../../../../data-explorer-shared/services/data-explorer-shared.service';
import { TranslateService } from '@ngx-translate/core';
import { Router } from '@angular/router';
+import { CloneDashboardDialogComponent } from '../../../dialogs/clone-dashboard/clone-dashboard-dialog.component';
+import { EditDashboardDialogComponent } from '../../../dialogs/edit-dashboard/edit-dashboard-dialog.component';
@Component({
selector: 'sp-dashboard-overview-table',
@@ -158,4 +161,27 @@
makeDashboardKioskUrl(dashboardId: string): string {
return `${window.location.protocol}//${window.location.host}/#/dashboard-kiosk/${dashboardId}`;
}
+
+ openCloneDialog(dashboard: Dashboard): void {
+ const dialogRef = this.dialogService.open(
+ CloneDashboardDialogComponent,
+ {
+ panelType: PanelType.SLIDE_IN_PANEL,
+ title: this.translateService.instant('Clone dashboard'),
+ width: '50vw',
+ data: {
+ dashboard: dashboard,
+ },
+ },
+ );
+ dialogRef.afterClosed().subscribe(result => {
+ if (result) {
+ this.getDashboards();
+ }
+ });
+ }
+
+ onRowClicked(dashboard: Dashboard) {
+ this.showDashboard(dashboard);
+ }
}
diff --git a/ui/src/app/dashboard/components/overview/dashboard-overview.component.ts b/ui/src/app/dashboard/components/overview/dashboard-overview.component.ts
index 4d6bfc3..577d98f 100644
--- a/ui/src/app/dashboard/components/overview/dashboard-overview.component.ts
+++ b/ui/src/app/dashboard/components/overview/dashboard-overview.component.ts
@@ -30,7 +30,6 @@
import { DataExplorerDashboardService } from '../../../dashboard-shared/services/dashboard.service';
import { DashboardOverviewTableComponent } from './dashboard-overview-table/dashboard-overview-table.component';
import { TranslateService } from '@ngx-translate/core';
-import { DataExplorerSharedService } from '../../../data-explorer-shared/services/data-explorer-shared.service';
@Component({
selector: 'sp-dashboard-overview',
@@ -50,7 +49,6 @@
public dialog = inject(MatDialog);
private dataExplorerDashboardService = inject(DataExplorerDashboardService);
- private dataExplorerSharedService = inject(DataExplorerSharedService);
private authService = inject(AuthService);
private currentUserService = inject(CurrentUserService);
private breadcrumbService = inject(SpBreadcrumbService);
@@ -83,6 +81,7 @@
createdAtEpochMs: Date.now(),
lastModifiedEpochMs: Date.now(),
},
+ gridColumns: 12,
};
this.openDashboardModificationDialog(true, dataViewDashboard);
diff --git a/ui/src/app/dashboard/components/panel/dashboard-panel.component.scss b/ui/src/app/dashboard/components/panel/dashboard-panel.component.scss
index 285b384..ad4770c 100644
--- a/ui/src/app/dashboard/components/panel/dashboard-panel.component.scss
+++ b/ui/src/app/dashboard/components/panel/dashboard-panel.component.scss
@@ -41,7 +41,6 @@
.designer-panel {
width: 450px;
- border: 1px solid var(--color-tab-border);
}
.edit-menu-btn {
diff --git a/ui/src/app/dashboard/dashboard.module.ts b/ui/src/app/dashboard/dashboard.module.ts
index e5a2994..7e480fd 100644
--- a/ui/src/app/dashboard/dashboard.module.ts
+++ b/ui/src/app/dashboard/dashboard.module.ts
@@ -69,6 +69,8 @@
import { DashboardOverviewTableComponent } from './components/overview/dashboard-overview-table/dashboard-overview-table.component';
import { TranslateModule } from '@ngx-translate/core';
import { DashboardSharedModule } from '../dashboard-shared/dashboard-shared.module';
+import { CloneDashboardDialogComponent } from './dialogs/clone-dashboard/clone-dashboard-dialog.component';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@NgModule({
imports: [
@@ -114,6 +116,7 @@
DataExplorerSharedModule,
DashboardSharedModule,
TranslateModule.forChild(),
+ MatProgressSpinnerModule,
RouterModule.forChild([
{
path: '',
@@ -145,6 +148,7 @@
ChartSelectionComponent,
EditDashboardDialogComponent,
DashboardOverviewTableComponent,
+ CloneDashboardDialogComponent,
],
providers: [],
exports: [],
diff --git a/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.html b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.html
new file mode 100644
index 0000000..7fa58ea
--- /dev/null
+++ b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.html
@@ -0,0 +1,158 @@
+<!--
+ ~ 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.
+ ~
+ -->
+
+<div class="sp-dialog-container" fxLayout="column">
+ @if (compositeDashboard) {
+ <div class="sp-dialog-content p-15">
+ <div fxFlex="100">
+ <div
+ fxFlex="100"
+ fxLayout="column"
+ style="margin: 5px; width: 100%"
+ >
+ <!-- Name -->
+ <mat-form-field
+ class="w-100"
+ floatLabel="auto"
+ color="accent"
+ >
+ <mat-label
+ >{{ 'New dashboard title' | translate }}
+ </mat-label>
+ <input
+ id="dvname"
+ #dvname="ngModel"
+ required
+ matInput
+ data-cy="clone-data-view-name"
+ [(ngModel)]="form.name"
+ />
+ <mat-error
+ >{{ 'Title must not be empty' | translate }}
+ </mat-error>
+ </mat-form-field>
+
+ <!-- Description (optional) -->
+ <mat-form-field class="w-100" color="accent">
+ <mat-label>{{ 'Description' | translate }}</mat-label>
+ <input
+ matInput
+ [(ngModel)]="form.description"
+ data-cy="clone-data-view-description"
+ />
+ </mat-form-field>
+
+ <!-- Deep clone -->
+ <div class="mt-10" fxLayout="column">
+ <label>{{ 'Clone options' | translate }}</label>
+ <mat-checkbox
+ class="mt-5"
+ [(ngModel)]="form.deepClone"
+ data-cy="clone-deep"
+ >{{ 'Deep clone (also clone widgets)' | translate }}
+ </mat-checkbox>
+ </div>
+
+ <!-- Allow widget edits (only if deepClone) -->
+ @if (form.deepClone) {
+ <div class="mt-10 mb-10" fxLayout="column">
+ <mat-checkbox
+ [(ngModel)]="form.allowWidgetEdits"
+ data-cy="clone-allow-widget-edits"
+ >{{ 'Modify chart configurations' | translate }}
+ </mat-checkbox>
+ </div>
+ }
+
+ <!-- Widget edit grid -->
+ @if (form.deepClone && form.allowWidgetEdits) {
+ <mat-accordion class="mt-10">
+ @for (
+ w of widgetConfigs;
+ track w.current.elementId
+ ) {
+ <mat-expansion-panel
+ class="mat-elevation-z0 border-1"
+ >
+ <mat-expansion-panel-header>
+ <mat-panel-title>
+ {{
+ w.current.baseAppearanceConfig
+ .widgetTitle
+ }}
+ </mat-panel-title>
+ </mat-expansion-panel-header>
+
+ <div
+ fxLayout="column"
+ class="widget-edit p-10"
+ >
+ <mat-form-field
+ class="w-100"
+ color="accent"
+ >
+ <mat-label
+ >{{ 'Chart Name' | translate }}
+ </mat-label>
+ <input
+ matInput
+ [(ngModel)]="
+ w.cloned
+ .baseAppearanceConfig
+ .widgetTitle
+ "
+ />
+ </mat-form-field>
+ </div>
+ </mat-expansion-panel>
+ }
+ </mat-accordion>
+ }
+ </div>
+ </div>
+ </div>
+
+ <mat-divider></mat-divider>
+
+ <div class="sp-dialog-actions actions-align-left" fxLayoutGap="10px">
+ <button
+ [disabled]="dvname.invalid"
+ mat-button
+ mat-flat-button
+ color="accent"
+ data-cy="clone-save"
+ (click)="onSave()"
+ >
+ {{ 'Clone' | translate }}
+ </button>
+ <button
+ mat-button
+ mat-flat-button
+ class="mat-basic mr-10"
+ (click)="onCancel()"
+ >
+ {{ 'Close' | translate }}
+ </button>
+ </div>
+ } @else {
+ <div fxFlex="100" fxLayoutAlign="center center">
+ <mat-spinner [diameter]="20"></mat-spinner>
+ <h5 class="mt-10">Loading</h5>
+ </div>
+ }
+</div>
diff --git a/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.scss b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.scss
new file mode 100644
index 0000000..0c040d8
--- /dev/null
+++ b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.scss
@@ -0,0 +1,21 @@
+/*!
+ * 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.
+ *
+ */
+
+.border-1 {
+ border: 1px solid var(--color-bg-2);
+}
diff --git a/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.ts b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.ts
new file mode 100644
index 0000000..c9fb21b
--- /dev/null
+++ b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.ts
@@ -0,0 +1,130 @@
+/*
+ * 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.
+ *
+ */
+
+import { Component, inject, Input, OnInit } from '@angular/core';
+import {
+ ChartService,
+ CompositeDashboard,
+ Dashboard,
+ DashboardService,
+ DataExplorerWidgetModel,
+} from '@streampipes/platform-services';
+import { DialogRef } from '@streampipes/shared-ui';
+import { IdGeneratorService } from '../../../core-services/id-generator/id-generator.service';
+import { Observable, zip } from 'rxjs';
+import { TranslateService } from '@ngx-translate/core';
+
+export interface WidgetClone {
+ current: DataExplorerWidgetModel;
+ cloned: DataExplorerWidgetModel;
+}
+
+@Component({
+ selector: 'sp-clone-dashboard-dialog-component',
+ templateUrl: './clone-dashboard-dialog.component.html',
+ styleUrls: ['./clone-dashboard-dialog.component.scss'],
+ standalone: false,
+})
+export class CloneDashboardDialogComponent implements OnInit {
+ private dialogRef = inject(DialogRef<CloneDashboardDialogComponent>);
+ private dashboardService = inject(DashboardService);
+ private chartService = inject(ChartService);
+ private idGeneratorService = inject(IdGeneratorService);
+ private translate = inject(TranslateService);
+
+ static readonly DashboardPrefix = 'sp:dashboardmodel:';
+ static readonly ChartPrefix = 'sp:dataexplorerwidgetmodel:';
+
+ @Input()
+ dashboard: Dashboard;
+
+ compositeDashboard: CompositeDashboard;
+ widgetConfigs: WidgetClone[] = [];
+
+ form;
+
+ ngOnInit() {
+ this.dashboardService
+ .getCompositeDashboard(this.dashboard.elementId)
+ .subscribe(res => {
+ this.compositeDashboard = res.body as CompositeDashboard;
+ this.widgetConfigs = this.compositeDashboard.widgets.map(w => {
+ return {
+ current: w,
+ cloned: JSON.parse(JSON.stringify(w)),
+ };
+ });
+ });
+ this.form = {
+ name: `${this.dashboard.name} (${this.translate.instant('Copy')})`,
+ description: this.dashboard.description,
+ deepClone: false,
+ allowWidgetEdits: false,
+ };
+ }
+
+ onCancel(): void {
+ this.dialogRef.close();
+ }
+
+ onSave(): void {
+ let widget$: Observable<any>[] = [];
+ const clonedDashboard: Dashboard = JSON.parse(
+ JSON.stringify(this.dashboard),
+ );
+ const clonedWidgets = this.widgetConfigs.map(wc =>
+ this.form.allowWidgetEdits ? wc.cloned : wc.current,
+ );
+ clonedDashboard.elementId = this.idGeneratorService.generateWithPrefix(
+ CloneDashboardDialogComponent.DashboardPrefix,
+ 6,
+ );
+ clonedDashboard.rev = undefined;
+ clonedDashboard.metadata.createdAtEpochMs = Date.now();
+ clonedDashboard.metadata.lastModifiedEpochMs = Date.now();
+ clonedDashboard.name = this.form.name;
+ clonedDashboard.description = this.form.description;
+ if (this.form.deepClone) {
+ clonedDashboard.widgets.forEach((widget, index) => {
+ const widgetElementId =
+ this.idGeneratorService.generateWithPrefix(
+ CloneDashboardDialogComponent.ChartPrefix,
+ 6,
+ );
+ const clonedWidget = clonedWidgets.find(
+ w => w.elementId === widget.id,
+ );
+ if (clonedWidget !== undefined) {
+ clonedWidgets[index].elementId = widgetElementId;
+ clonedWidgets[index].metadata.createdAtEpochMs = Date.now();
+ clonedWidgets[index].metadata.lastModifiedEpochMs =
+ Date.now();
+ clonedWidgets[index].rev = undefined;
+ }
+ widget.id = widgetElementId;
+ });
+ widget$ = clonedWidgets.map(w => this.chartService.saveChart(w));
+ }
+ zip([
+ ...widget$,
+ this.dashboardService.saveDashboard(clonedDashboard),
+ ]).subscribe(() => {
+ this.dialogRef.close(true);
+ });
+ }
+}
diff --git a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.html b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.html
index 52e1dd5..69d5054 100644
--- a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.html
+++ b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.html
@@ -64,6 +64,28 @@
</mat-radio-group>
</div>
<div class="mt-10" fxLayout="column">
+ <label>{{ 'Grid' | translate }}</label>
+ <mat-form-field
+ class="w-100 mt-10"
+ floatLabel="auto"
+ color="accent"
+ >
+ <mat-label>{{ 'Grid columns' | translate }}</mat-label>
+ <input
+ id="dvname"
+ #dvname="ngModel"
+ required
+ matInput
+ type="number"
+ data-cy="grid-columns"
+ [(ngModel)]="dashboard.gridColumns"
+ />
+ <mat-error>{{
+ 'Title must not be empty' | translate
+ }}</mat-error>
+ </mat-form-field>
+ </div>
+ <div class="mt-10" fxLayout="column">
<label>{{ 'Time settings' | translate }}</label>
<mat-checkbox
[(ngModel)]="
diff --git a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
index 01d4221..112e70c 100644
--- a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
+++ b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
@@ -16,7 +16,7 @@
*
*/
-import { Component, Input, OnInit } from '@angular/core';
+import { Component, inject, Input, OnInit } from '@angular/core';
import { Dashboard, DashboardService } from '@streampipes/platform-services';
import { DialogRef } from '@streampipes/shared-ui';
@@ -30,10 +30,8 @@
@Input() createMode: boolean;
@Input() dashboard: Dashboard;
- constructor(
- private dialogRef: DialogRef<EditDashboardDialogComponent>,
- private dashboardService: DashboardService,
- ) {}
+ private dialogRef = inject(DialogRef<EditDashboardDialogComponent>);
+ private dashboardService = inject(DashboardService);
ngOnInit() {
if (!this.dashboard.dashboardGeneralSettings.defaultViewMode) {
diff --git a/ui/src/app/data-explorer-shared/components/charts/status/status-widget.component.ts b/ui/src/app/data-explorer-shared/components/charts/status/status-widget.component.ts
index 0d2c162..b62fcc1 100644
--- a/ui/src/app/data-explorer-shared/components/charts/status/status-widget.component.ts
+++ b/ui/src/app/data-explorer-shared/components/charts/status/status-widget.component.ts
@@ -135,7 +135,7 @@
}
onResize(width: number, heigth: number): void {
- this.containerHeight = heigth * 0.3;
+ this.containerHeight = heigth * 0.7;
this.containerWidth = this.containerHeight;
this.lightWidth = this.containerHeight;
this.lightHeight = this.lightWidth;
diff --git a/ui/src/scss/sp/_variables.scss b/ui/src/scss/sp/_variables.scss
index 663001e..9f77125 100644
--- a/ui/src/scss/sp/_variables.scss
+++ b/ui/src/scss/sp/_variables.scss
@@ -80,6 +80,7 @@
--mat-menu-container-color: var(--color-bg-0);
--mat-select-panel-background-color: var(--color-bg-0);
--mat-sidenav-container-shape: 0;
+ --mat-sidenav-container-divider-color: var(--color-bg-3);
}
.dark-mode {
diff --git a/ui/src/scss/sp/layout.scss b/ui/src/scss/sp/layout.scss
index b4654d7..6b73688 100644
--- a/ui/src/scss/sp/layout.scss
+++ b/ui/src/scss/sp/layout.scss
@@ -60,6 +60,10 @@
margin-top: 0;
}
+.mt-5 {
+ margin-top: 5px;
+}
+
.mt-10 {
margin-top: 10px;
}
@@ -72,6 +76,10 @@
margin-top: 30px;
}
+.mb-5 {
+ margin-bottom: 5px;
+}
+
.mb-10 {
margin-bottom: 10px;
}
diff --git a/ui/src/scss/sp/sp-theme.scss b/ui/src/scss/sp/sp-theme.scss
index 63cc186..3a824e3 100644
--- a/ui/src/scss/sp/sp-theme.scss
+++ b/ui/src/scss/sp/sp-theme.scss
@@ -56,7 +56,8 @@
surface-container-high: light-dark(#eeeeee, #1c1c1c),
on-surface: light-dark(#1a1a1a, #e6e6e6),
on-surface-variant: light-dark(#5e5e5e, #b5b5b5),
- surface-tint: transparent
+ surface-tint: transparent,
+ background: light-dark(#fafafa, #121212)
)
);