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)
         )
     );