This is an automated email from the ASF dual-hosted git repository.

dominikriemer pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/streampipes.git


The following commit(s) were added to refs/heads/dev by this push:
     new bd263a316a feat(#4577): Add last updated time to dashboard kiosk, 
improve performance (#4629)
bd263a316a is described below

commit bd263a316abe9dbefc65ef056ae5e4168e17a53d
Author: Dominik Riemer <[email protected]>
AuthorDate: Wed Jun 24 09:36:49 2026 +0200

    feat(#4577): Add last updated time to dashboard kiosk, improve performance 
(#4629)
---
 .../rest/impl/datalake/DataLakeResource.java       |  27 +++-
 .../datalake/KioskDashboardDataLakeResource.java   |  44 ++++++
 ui/deployment/i18n/de.json                         |   1 +
 ui/deployment/i18n/en.json                         |   1 +
 ui/deployment/i18n/pl.json                         |   1 +
 .../src/lib/apis/dashboard-kiosk.service.ts        |  16 +++
 .../dashboard-data-request-coordinator.service.ts  | 158 +++++++++++++++++++++
 .../lib/query/data-view-query-generator.service.ts |  52 +++++++
 .../platform-services/src/public-api.ts            |   1 +
 .../chart-shared/services/chart-shared.service.ts  |  21 ++-
 .../kiosk/dashboard-kiosk.component.html           |  13 ++
 .../kiosk/dashboard-kiosk.component.scss           |   7 +
 .../components/kiosk/dashboard-kiosk.component.ts  |  79 ++++++++++-
 .../components/panel/dashboard-panel.component.ts  |   2 +-
 14 files changed, 415 insertions(+), 8 deletions(-)

diff --git 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeResource.java
 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeResource.java
index 13f439cc42..aede5118d5 100644
--- 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeResource.java
+++ 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeResource.java
@@ -219,11 +219,22 @@ public class DataLakeResource extends 
AbstractDataLakeResource {
   }
 
   @PostMapping(path = "/query", produces = MediaType.APPLICATION_JSON_VALUE, 
consumes = MediaType.APPLICATION_JSON_VALUE)
-  public ResponseEntity<List<SpQueryResult>> getData(@RequestBody 
List<Map<String, String>> queryParams) {
-    //TODO
-    var results = queryParams
-        .stream()
-        .map(qp -> new ProvidedRestQueryParams(qp.get("measureName"), qp))
+  @PreAuthorize("this.hasReadAuthority()")
+  public ResponseEntity<?> getData(@RequestBody List<Map<String, String>> 
queryParams) {
+    if (queryParams.stream().anyMatch(params -> 
!checkProvidedBatchQueryParams(params))) {
+      return badRequest();
+    }
+
+    var unauthorizedMeasureName = queryParams.stream()
+        .map(params -> params.get("measureName"))
+        .filter(measureName -> !checkPermissionByName(measureName, "READ"))
+        .findFirst();
+    if (unauthorizedMeasureName.isPresent()) {
+      return badRequest(String.format("No read permission for measurement %s", 
unauthorizedMeasureName.get()));
+    }
+
+    var results = queryParams.stream()
+        .map(params -> new ProvidedRestQueryParams(params.get("measureName"), 
params))
         .map(params -> this.dataExplorerQueryManagement.getData(params, true))
         .collect(Collectors.toList());
 
@@ -319,6 +330,12 @@ public class DataLakeResource extends 
AbstractDataLakeResource {
     return SUPPORTED_PARAMS.containsAll(providedParams.keySet());
   }
 
+  private boolean checkProvidedBatchQueryParams(Map<String, String> 
providedParams) {
+    return providedParams.containsKey("measureName")
+        && providedParams.keySet().stream()
+            .allMatch(param -> param.equals("measureName") || 
SUPPORTED_PARAMS.contains(param));
+  }
+
   @PostMapping(path = "/{elementId}/cleanup", produces = 
MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
   @PreAuthorize(AuthConstants.IS_ADMIN_ROLE)
   @Operation(summary = "Sets the retention mechanism for a certain 
measurement", tags = { "Data Lake" }, responses = {
diff --git 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/KioskDashboardDataLakeResource.java
 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/KioskDashboardDataLakeResource.java
index 6fc47a78ea..b513405bff 100644
--- 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/KioskDashboardDataLakeResource.java
+++ 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/KioskDashboardDataLakeResource.java
@@ -98,6 +98,45 @@ public class KioskDashboardDataLakeResource extends 
AbstractAuthGuardedRestResou
     }
   }
 
+  @PostMapping(path = "/{dashboardId}/data",
+      consumes = MediaType.APPLICATION_JSON_VALUE,
+      produces = MediaType.APPLICATION_JSON_VALUE)
+  @PreAuthorize("this.hasReadAuthorityOrAnonymous(#dashboardId) and 
hasPermission(#dashboardId, 'READ')")
+  public ResponseEntity<?> getData(@PathVariable("dashboardId") String 
dashboardId,
+                                   @RequestBody List<KioskDashboardDataQuery> 
dataQueries) {
+    var dashboard = dashboardStorage.getElementById(dashboardId);
+    var dashboardWidgetIds = dashboard.getWidgets().stream()
+        .map(w -> w.getDataViewElementId())
+        .toList();
+
+    if (dataQueries.stream().anyMatch(query -> 
!dashboardWidgetIds.contains(query.widgetId()))) {
+      return badRequest("At least one widget was not found in dashboard");
+    }
+
+    try {
+      var results = dataQueries.stream()
+          .map(query -> executeKioskDataQuery(query.widgetId(), 
query.queryParams()))
+          .toList();
+      return ok(results);
+    } catch (IllegalArgumentException e) {
+      return badRequest(e.getMessage());
+    } catch (RuntimeException e) {
+      return badRequest(SpLogMessage.from(e));
+    }
+  }
+
+  private SpQueryResult executeKioskDataQuery(String widgetId,
+                                              Map<String, String> queryParams) 
{
+    var widget = dataExplorerWidgetStorage.getElementById(widgetId);
+    var measureName = queryParams.get("measureName");
+    if (!checkMeasureNameInWidget(widget, measureName)) {
+      throw new IllegalArgumentException("Measure name not found in widget 
configuration");
+    } else {
+      ProvidedRestQueryParams sanitizedParams = new 
ProvidedRestQueryParams(measureName, queryParams);
+      return this.dataExplorerQueryManagement.getData(sanitizedParams, true);
+    }
+  }
+
   private boolean checkMeasureNameInWidget(DataExplorerWidgetModel widget,
                                            String measureName) {
     var sourceConfigs = widget.getDataConfig().get("sourceConfigs");
@@ -116,6 +155,11 @@ public class KioskDashboardDataLakeResource extends 
AbstractAuthGuardedRestResou
     }
   }
 
+  public record KioskDashboardDataQuery(String widgetId,
+                                       Map<String, String> queryParams) {
+
+  }
+
   public boolean hasReadAuthorityOrAnonymous(String dashboardId) {
     return hasReadAuthority()
         || hasAnonymousAccessAuthority(dashboardId);
diff --git a/ui/deployment/i18n/de.json b/ui/deployment/i18n/de.json
index 0e6ac402a3..569efd5889 100644
--- a/ui/deployment/i18n/de.json
+++ b/ui/deployment/i18n/de.json
@@ -602,6 +602,7 @@
   "Last 15 min": "Letzten 15 Minuten",
   "Last Login": "Letzte Anmeldung",
   "Last event": "Letztes Event",
+  "Last updated": "Zuletzt aktualisiert",
   "Last message": "Letzte Nachricht",
   "Last modified": "Zuletzt geändert",
   "Last published message": "Zuletzt veröffentlichte Nachricht",
diff --git a/ui/deployment/i18n/en.json b/ui/deployment/i18n/en.json
index df7bb3ca73..8a2a543fce 100644
--- a/ui/deployment/i18n/en.json
+++ b/ui/deployment/i18n/en.json
@@ -602,6 +602,7 @@
   "Last 15 min": null,
   "Last Login": null,
   "Last event": null,
+  "Last updated": null,
   "Last message": null,
   "Last modified": null,
   "Last published message": null,
diff --git a/ui/deployment/i18n/pl.json b/ui/deployment/i18n/pl.json
index f9f62616af..b869395523 100644
--- a/ui/deployment/i18n/pl.json
+++ b/ui/deployment/i18n/pl.json
@@ -602,6 +602,7 @@
   "Last 15 min": "Ostatnie 15 minut",
   "Last Login": "Ostatnie logowanie",
   "Last event": "Ostatnie zdarzenie",
+  "Last updated": "Ostatnia aktualizacja",
   "Last message": "Ostatnia wiadomość",
   "Last modified": "Ostatnia modyfikacja",
   "Last published message": "Ostatnio opublikowana wiadomość",
diff --git 
a/ui/projects/streampipes/platform-services/src/lib/apis/dashboard-kiosk.service.ts
 
b/ui/projects/streampipes/platform-services/src/lib/apis/dashboard-kiosk.service.ts
index be79b32851..f1f2b3a0ef 100644
--- 
a/ui/projects/streampipes/platform-services/src/lib/apis/dashboard-kiosk.service.ts
+++ 
b/ui/projects/streampipes/platform-services/src/lib/apis/dashboard-kiosk.service.ts
@@ -24,6 +24,11 @@ import { NGX_LOADING_BAR_IGNORED } from 
'@ngx-loading-bar/http-client';
 import { PlatformServicesCommons } from './commons.service';
 import { DatalakeQueryParameters } from 
'../model/datalake/DatalakeQueryParameters';
 
+export interface DashboardKioskDataQuery {
+    widgetId: string;
+    queryParams: DatalakeQueryParameters;
+}
+
 @Injectable({
     providedIn: 'root',
 })
@@ -43,6 +48,17 @@ export class DashboardKioskRestService {
         });
     }
 
+    performMultiQuery(
+        dashboardId: string,
+        requests: DashboardKioskDataQuery[],
+    ): Observable<SpQueryResult[]> {
+        const context = new HttpContext().set(NGX_LOADING_BAR_IGNORED, true);
+        const url = `${this.dashboardKioskBasePath}/${dashboardId}/data`;
+        return this.http.post<SpQueryResult[]>(url, requests, {
+            context,
+        });
+    }
+
     private get dashboardKioskBasePath() {
         return (
             this.platformServicesCommons.basePath +
diff --git 
a/ui/projects/streampipes/platform-services/src/lib/query/dashboard-data-request-coordinator.service.ts
 
b/ui/projects/streampipes/platform-services/src/lib/query/dashboard-data-request-coordinator.service.ts
new file mode 100644
index 0000000000..9cf2efa1f0
--- /dev/null
+++ 
b/ui/projects/streampipes/platform-services/src/lib/query/dashboard-data-request-coordinator.service.ts
@@ -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.
+ *
+ */
+
+import { inject, Injectable } from '@angular/core';
+import { Observable, ReplaySubject, Subject, of } from 'rxjs';
+import { bufferTime, filter } from 'rxjs/operators';
+import { DatalakeRestService } from '../apis/datalake-rest.service';
+import { DashboardKioskRestService } from '../apis/dashboard-kiosk.service';
+import { DatalakeQueryParameters } from 
'../model/datalake/DatalakeQueryParameters';
+import { SpQueryResult } from '../model/gen/streampipes-model';
+
+interface PendingDataLakeRequest {
+    queryParams: DatalakeQueryParameters;
+    result$: ReplaySubject<SpQueryResult>;
+}
+
+interface PendingKioskRequest extends PendingDataLakeRequest {
+    dashboardId: string;
+    widgetId: string;
+}
+
+@Injectable({
+    providedIn: 'root',
+})
+export class DashboardDataRequestCoordinatorService {
+    private readonly batchWindowMs = 20;
+
+    private dataLakeRestService = inject(DatalakeRestService);
+    private dashboardKioskRestService = inject(DashboardKioskRestService);
+
+    private dataLakeQueue$ = new Subject<PendingDataLakeRequest>();
+    private kioskQueue$ = new Subject<PendingKioskRequest>();
+
+    constructor() {
+        this.dataLakeQueue$
+            .pipe(
+                bufferTime(this.batchWindowMs),
+                filter(requests => requests.length > 0),
+            )
+            .subscribe(requests => this.executeDataLakeBatch(requests));
+
+        this.kioskQueue$
+            .pipe(
+                bufferTime(this.batchWindowMs),
+                filter(requests => requests.length > 0),
+            )
+            .subscribe(requests => this.executeKioskBatches(requests));
+    }
+
+    queueDataLakeQuery(
+        queryParams: DatalakeQueryParameters,
+    ): Observable<SpQueryResult> {
+        if (queryParams.columns === '') {
+            return of(this.makeEmptyQueryResult());
+        }
+
+        const result$ = new ReplaySubject<SpQueryResult>(1);
+        this.dataLakeQueue$.next({ queryParams, result$ });
+        return result$.asObservable();
+    }
+
+    queueKioskQuery(
+        dashboardId: string,
+        widgetId: string,
+        queryParams: DatalakeQueryParameters,
+    ): Observable<SpQueryResult> {
+        if (queryParams.columns === '') {
+            return of(this.makeEmptyQueryResult());
+        }
+
+        const result$ = new ReplaySubject<SpQueryResult>(1);
+        this.kioskQueue$.next({
+            dashboardId,
+            widgetId,
+            queryParams,
+            result$,
+        });
+        return result$.asObservable();
+    }
+
+    private executeDataLakeBatch(requests: PendingDataLakeRequest[]): void {
+        this.dataLakeRestService
+            .performMultiQuery(requests.map(request => request.queryParams))
+            .subscribe({
+                next: results =>
+                    this.completeRequestsWithResults(requests, results),
+                error: error => this.errorRequests(requests, error),
+            });
+    }
+
+    private executeKioskBatches(requests: PendingKioskRequest[]): void {
+        const requestsByDashboard = new Map<string, PendingKioskRequest[]>();
+        requests.forEach(request => {
+            const dashboardRequests =
+                requestsByDashboard.get(request.dashboardId) ?? [];
+            dashboardRequests.push(request);
+            requestsByDashboard.set(request.dashboardId, dashboardRequests);
+        });
+
+        requestsByDashboard.forEach((dashboardRequests, dashboardId) => {
+            this.dashboardKioskRestService
+                .performMultiQuery(
+                    dashboardId,
+                    dashboardRequests.map(request => ({
+                        widgetId: request.widgetId,
+                        queryParams: request.queryParams,
+                    })),
+                )
+                .subscribe({
+                    next: results =>
+                        this.completeRequestsWithResults(
+                            dashboardRequests,
+                            results,
+                        ),
+                    error: error =>
+                        this.errorRequests(dashboardRequests, error),
+                });
+        });
+    }
+
+    private completeRequestsWithResults(
+        requests: PendingDataLakeRequest[],
+        results: SpQueryResult[],
+    ): void {
+        requests.forEach((request, index) => {
+            request.result$.next(results[index]);
+            request.result$.complete();
+        });
+    }
+
+    private errorRequests(
+        requests: PendingDataLakeRequest[],
+        error: unknown,
+    ): void {
+        requests.forEach(request => request.result$.error(error));
+    }
+
+    private makeEmptyQueryResult(): SpQueryResult {
+        const emptyQueryResult = new SpQueryResult();
+        emptyQueryResult.total = 0;
+        return emptyQueryResult;
+    }
+}
diff --git 
a/ui/projects/streampipes/platform-services/src/lib/query/data-view-query-generator.service.ts
 
b/ui/projects/streampipes/platform-services/src/lib/query/data-view-query-generator.service.ts
index ef1c5cf9f4..e68b52f966 100644
--- 
a/ui/projects/streampipes/platform-services/src/lib/query/data-view-query-generator.service.ts
+++ 
b/ui/projects/streampipes/platform-services/src/lib/query/data-view-query-generator.service.ts
@@ -27,6 +27,7 @@ import { SpQueryResult } from 
'../model/gen/streampipes-model';
 import { DatalakeQueryParameters } from 
'../model/datalake/DatalakeQueryParameters';
 import { DatalakeQueryParameterBuilder } from 
'./DatalakeQueryParameterBuilder';
 import { DashboardKioskRestService } from '../apis/dashboard-kiosk.service';
+import { DashboardDataRequestCoordinatorService } from 
'./dashboard-data-request-coordinator.service';
 
 @Injectable({
     providedIn: 'root',
@@ -34,6 +35,9 @@ import { DashboardKioskRestService } from 
'../apis/dashboard-kiosk.service';
 export class DataViewQueryGeneratorService {
     protected dataLakeRestService = inject(DatalakeRestService);
     protected dashboardKioskRestService = inject(DashboardKioskRestService);
+    protected dashboardDataRequestCoordinator = inject(
+        DashboardDataRequestCoordinatorService,
+    );
 
     generateObservables(
         startTime: number,
@@ -84,6 +88,54 @@ export class DataViewQueryGeneratorService {
         });
     }
 
+    generateBatchedObservables(
+        startTime: number,
+        endTime: number,
+        dataConfig: DataExplorerDataConfig,
+        maximumResultingEvents: number = -1,
+    ): Observable<SpQueryResult>[] {
+        return dataConfig.sourceConfigs.map(sourceConfig => {
+            const dataLakeConfiguration = this.generateQuery(
+                startTime,
+                endTime,
+                sourceConfig,
+                dataConfig.ignoreMissingValues,
+                maximumResultingEvents,
+                true,
+            );
+
+            return this.dashboardDataRequestCoordinator.queueDataLakeQuery(
+                dataLakeConfiguration,
+            );
+        });
+    }
+
+    generateBatchedObservablesForKioskMode(
+        startTime: number,
+        endTime: number,
+        dataConfig: DataExplorerDataConfig,
+        dashboardId: string,
+        widgetId: string,
+        maximumResultingEvents = -1,
+    ): Observable<SpQueryResult>[] {
+        return dataConfig.sourceConfigs.map(sourceConfig => {
+            const dataLakeConfiguration = this.generateQuery(
+                startTime,
+                endTime,
+                sourceConfig,
+                dataConfig.ignoreMissingValues,
+                maximumResultingEvents,
+                true,
+            );
+
+            return this.dashboardDataRequestCoordinator.queueKioskQuery(
+                dashboardId,
+                widgetId,
+                dataLakeConfiguration,
+            );
+        });
+    }
+
     generateQuery(
         startTime: number,
         endTime: number,
diff --git a/ui/projects/streampipes/platform-services/src/public-api.ts 
b/ui/projects/streampipes/platform-services/src/public-api.ts
index 5b0b9cb75e..d5afe4f015 100644
--- a/ui/projects/streampipes/platform-services/src/public-api.ts
+++ b/ui/projects/streampipes/platform-services/src/public-api.ts
@@ -74,6 +74,7 @@ export * from './lib/model/resource/resource-summary.model';
 export * from './lib/model/datalake/data-lake-query-config.model';
 export * from './lib/query/DatalakeQueryParameterBuilder';
 export * from './lib/query/data-view-query-generator.service';
+export * from './lib/query/dashboard-data-request-coordinator.service';
 export * from './lib/query/generic-storage-query-builder';
 export * from './lib/model/user/user.model';
 
diff --git a/ui/src/app/chart-shared/services/chart-shared.service.ts 
b/ui/src/app/chart-shared/services/chart-shared.service.ts
index 6efa306cad..0fe9075118 100644
--- a/ui/src/app/chart-shared/services/chart-shared.service.ts
+++ b/ui/src/app/chart-shared/services/chart-shared.service.ts
@@ -113,6 +113,25 @@ export class ChartSharedService {
         };
     }
 
+    dashboardObservableGenerator(): ObservableGenerator {
+        return {
+            generateObservables: (
+                startTime: number,
+                endTime: number,
+                dataConfig: DataExplorerDataConfig,
+                widgetId: string,
+                maxRowCountPerTag: number,
+            ) => {
+                return 
this.dataViewQueryGeneratorService.generateBatchedObservables(
+                    startTime,
+                    endTime,
+                    dataConfig,
+                    maxRowCountPerTag,
+                );
+            },
+        };
+    }
+
     kioskModeObservableGenerator(dashboardId: string): ObservableGenerator {
         return {
             generateObservables: (
@@ -122,7 +141,7 @@ export class ChartSharedService {
                 widgetId: string,
                 maxRowCountPerTag: number,
             ) => {
-                return 
this.dataViewQueryGeneratorService.generateObservablesForKioskMode(
+                return 
this.dataViewQueryGeneratorService.generateBatchedObservablesForKioskMode(
                     startTime,
                     endTime,
                     dataConfig,
diff --git 
a/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.html 
b/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.html
index 401e6e2b15..a0e65fcea6 100644
--- a/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.html
+++ b/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.html
@@ -51,6 +51,19 @@
                             >&nbsp;|&nbsp;{{ dashboard?.description }}</span
                         >
                     }
+                    @if (lastUpdatedAt) {
+                        <sp-label
+                            class="last-updated"
+                            icon="update"
+                            tone="info"
+                            size="small"
+                            variant="soft"
+                            shape="rounded"
+                        >
+                            {{ 'Last updated' | translate }}:
+                            {{ formatLastUpdatedAt() }}
+                        </sp-label>
+                    }
                 </div>
             </div>
         </div>
diff --git 
a/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.scss 
b/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.scss
index 76ab40d896..3f6e35da54 100644
--- a/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.scss
+++ b/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.scss
@@ -45,3 +45,10 @@
     font-size: 1.25rem;
     font-weight: normal;
 }
+
+.last-updated {
+    display: inline-flex;
+    margin-left: auto;
+    padding-left: 1rem;
+    white-space: nowrap;
+}
diff --git 
a/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.ts 
b/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.ts
index 3e7d1f9540..8686067d34 100644
--- a/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.ts
+++ b/ui/src/app/dashboard-kiosk/components/kiosk/dashboard-kiosk.component.ts
@@ -34,7 +34,7 @@ import {
 import { ActivatedRoute } from '@angular/router';
 import { EMPTY, Subscription, timer } from 'rxjs';
 import { catchError, exhaustMap, tap } from 'rxjs/operators';
-import { TimeSelectionService } from '@streampipes/shared-ui';
+import { SpLabelComponent, TimeSelectionService } from 
'@streampipes/shared-ui';
 import { DataExplorerDashboardService } from 
'../../../dashboard-shared/services/dashboard.service';
 import { ChartSharedService } from 
'../../../chart-shared/services/chart-shared.service';
 import { ObservableGenerator } from 
'../../../chart-shared/models/dataview-dashboard.model';
@@ -45,6 +45,7 @@ import {
     LayoutAlignDirective,
     LayoutDirective,
 } from '@ngbracket/ngx-layout/flex';
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
 
 @Component({
     selector: 'sp-dashboard-kiosk',
@@ -56,6 +57,8 @@ import {
         FlexDirective,
         LayoutAlignDirective,
         DashboardGridViewComponent,
+        SpLabelComponent,
+        TranslatePipe,
     ],
 })
 export class DashboardKioskComponent implements OnInit, OnDestroy {
@@ -63,6 +66,7 @@ export class DashboardKioskComponent implements OnInit, 
OnDestroy {
     private destroyRef = inject(DestroyRef);
     private dashboardService = inject(DashboardService);
     private timeSelectionService = inject(TimeSelectionService);
+    private translateService = inject(TranslateService);
     private dataExplorerDashboardService = 
inject(DataExplorerDashboardService);
     private dataExplorerSharedService = inject(ChartSharedService);
 
@@ -71,7 +75,10 @@ export class DashboardKioskComponent implements OnInit, 
OnDestroy {
     widgets: DataExplorerWidgetModel[] = [];
     refresh$: Subscription;
     dashboardRefresh$: Subscription;
+    lastUpdatedClock$: Subscription;
     eTag: string;
+    lastUpdatedAt: number;
+    currentTime: number;
 
     ngOnInit() {
         const dashboardId = this.route.snapshot.params.dashboardId;
@@ -159,10 +166,80 @@ export class DashboardKioskComponent implements OnInit, 
OnDestroy {
             ts = timeSettings;
         }
         this.timeSelectionService.notify(ts);
+        this.markDataUpdated();
+    }
+
+    markDataUpdated(): void {
+        this.lastUpdatedAt = Date.now();
+        this.currentTime = this.lastUpdatedAt;
+        this.startLastUpdatedClock();
+    }
+
+    startLastUpdatedClock(): void {
+        if (this.lastUpdatedClock$) {
+            return;
+        }
+
+        this.lastUpdatedClock$ = timer(1000, 1000)
+            .pipe(takeUntilDestroyed(this.destroyRef))
+            .subscribe(() => {
+                this.currentTime = Date.now();
+            });
+    }
+
+    formatLastUpdatedAt(): string {
+        const exactTime = this.formatExactLastUpdatedAt();
+        const relativeTime = this.formatRelativeLastUpdatedAt();
+        return relativeTime ? `${relativeTime} (${exactTime})` : exactTime;
+    }
+
+    private formatExactLastUpdatedAt(): string {
+        return new Intl.DateTimeFormat(this.currentLocale, {
+            dateStyle: 'medium',
+            timeStyle: 'medium',
+        }).format(new Date(this.lastUpdatedAt));
+    }
+
+    private formatRelativeLastUpdatedAt(): string | undefined {
+        const ageInSeconds = Math.max(
+            0,
+            Math.round((this.currentTime - this.lastUpdatedAt) / 1000),
+        );
+
+        if (ageInSeconds < 60) {
+            return this.relativeTimeFormatter.format(-ageInSeconds, 'second');
+        } else if (ageInSeconds < 3600) {
+            return this.relativeTimeFormatter.format(
+                -Math.floor(ageInSeconds / 60),
+                'minute',
+            );
+        } else if (ageInSeconds < 86400) {
+            return this.relativeTimeFormatter.format(
+                -Math.floor(ageInSeconds / 3600),
+                'hour',
+            );
+        }
+
+        return undefined;
+    }
+
+    private get currentLocale(): string {
+        return (
+            this.translateService.currentLang ||
+            this.translateService.defaultLang ||
+            'en'
+        );
+    }
+
+    private get relativeTimeFormatter(): Intl.RelativeTimeFormat {
+        return new Intl.RelativeTimeFormat(this.currentLocale, {
+            numeric: 'auto',
+        });
     }
 
     ngOnDestroy() {
         this.refresh$?.unsubscribe();
         this.dashboardRefresh$?.unsubscribe();
+        this.lastUpdatedClock$?.unsubscribe();
     }
 }
diff --git a/ui/src/app/dashboard/components/panel/dashboard-panel.component.ts 
b/ui/src/app/dashboard/components/panel/dashboard-panel.component.ts
index 81cf9629de..f7297d5f2d 100644
--- a/ui/src/app/dashboard/components/panel/dashboard-panel.component.ts
+++ b/ui/src/app/dashboard/components/panel/dashboard-panel.component.ts
@@ -185,7 +185,7 @@ export class DashboardPanelComponent
     private dataExplorerSharedService = inject(ChartSharedService);
 
     observableGenerator =
-        this.dataExplorerSharedService.defaultObservableGenerator();
+        this.dataExplorerSharedService.dashboardObservableGenerator();
 
     private pendingManageDashboardResult?: ObjectManageDialogResult<Dashboard>;
 

Reply via email to