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 @@
> | {{ 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>;