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

SvenO3 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 0423651c77 feat(#4633): Move dataset count display into dataset 
overview (#4637)
0423651c77 is described below

commit 0423651c779ad36c835dbea47639e236fb2e3874
Author: Sven Oehler <[email protected]>
AuthorDate: Thu Jun 25 17:59:13 2026 +0200

    feat(#4633): Move dataset count display into dataset overview (#4637)
---
 .../impl/datalake/DataLakeMeasureResource.java     |  60 +++--
 .../rest/impl/datalake/DataLakeResource.java       |  52 +++++
 ui/cypress/support/utils/chart/ChartBtns.ts        |  36 ---
 ui/cypress/support/utils/chart/ChartUtils.ts       |  24 --
 ui/cypress/support/utils/dataset/DatasetBtns.ts    |  32 ++-
 ui/cypress/support/utils/dataset/DatasetUtils.ts   | 130 +++++++----
 ui/cypress/tests/chart/configuration.smoke.spec.ts |  40 ++--
 ui/cypress/tests/connect/editAdapter.smoke.spec.ts |  13 +-
 ui/cypress/tests/dataset/csvImport.spec.ts         |  18 +-
 .../restartStreamPipes/restartStreamPipes2.ts      |  11 +-
 ui/deployment/i18n/de.json                         |   8 +-
 ui/deployment/i18n/en.json                         |   8 +-
 ui/deployment/i18n/pl.json                         |   8 +-
 .../src/lib/apis/datalake-rest.service.ts          |  28 ++-
 .../last-updated-formatter.service.ts              | 101 +++++++++
 .../components/kiosk/dashboard-kiosk.component.ts  |  54 +----
 .../datalake-configuration-entry.ts                |   6 +-
 .../datalake-configuration.component.html          |  88 +-------
 .../datalake-configuration.component.ts            |  76 +++----
 .../daily-event-counts-chart.component.html        |  29 +++
 .../daily-event-counts-chart.component.scss}       |  23 +-
 .../daily-event-counts-chart.component.ts          |  92 ++++++++
 .../dataset-details-metrics.component.html         |  74 ++++++
 .../dataset-details-metrics.component.scss}        |  17 +-
 .../dataset-details-metrics.component.ts           | 248 +++++++++++++++++++++
 .../dataset-details/dataset-details-tabs.ts        |   5 +
 ui/src/app/dataset/dataset.routes.ts               |   5 +
 27 files changed, 879 insertions(+), 407 deletions(-)

diff --git 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeMeasureResource.java
 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeMeasureResource.java
index 57dfb7553e..12ff63a216 100644
--- 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeMeasureResource.java
+++ 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeMeasureResource.java
@@ -29,9 +29,9 @@ import 
org.apache.streampipes.storage.api.explorer.IChartStorage;
 
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Parameter;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PostFilter;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -44,7 +44,6 @@ import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
 
 @RestController
@@ -80,29 +79,54 @@ public class DataLakeMeasureResource extends 
AbstractDataLakeResource {
   }
 
   /**
-   * Handles HTTP GET requests to retrieve the entry counts of specified
-   * measurements.
+   * Handles HTTP GET requests to retrieve the entry count of a specified
+   * measurement.
    *
-   * @param measurementNames A list of measurement names to return the count.
-   * @return A ResponseEntity containing a map of measurement names and their
-   *         corresponding entry counts.
+   * @param elementId The measurement id to return the count for.
+   * @return The entry count of the measurement.
    */
-  @Operation(summary = "Retrieve measurement counts", description = "Retrieves 
the entry counts for the specified measurements from the data lake.")
-  @GetMapping(path = "/count", produces = MediaType.APPLICATION_JSON_VALUE)
-  @PreAuthorize("this.hasReadAuthority()")
-  @PostFilter("this.checkPermissionByName(filterObject.key, 'READ')")
-  public Map<String, Integer> getEntryCountsOfMeasurements(
-      @Parameter(description = "A list of measurement names to return the 
count.") @RequestParam(value = "measurementNames") List<String> 
measurementNames,
-      @Parameter(description = "The number of days from today where the count 
should start") @RequestParam(value = "daysBack", defaultValue = "-1") int 
daysBack) {
+  @Operation(
+      summary = "Retrieve measurement count",
+      description = "Retrieves the entry count for the specified measurement 
from the data lake."
+  )
+  @GetMapping(path = "{id}/count", produces = MediaType.APPLICATION_JSON_VALUE)
+  @PreAuthorize("this.hasReadAuthority() and hasPermission(#elementId, 
'READ')")
+  public ResponseEntity<?> getEntryCountOfMeasurement(
+      @Parameter(description = "The measurement id to return the count for.")
+      @PathVariable("id") String elementId,
+      @Parameter(description = "The number of days from today where the count 
should start")
+      @RequestParam(value = "daysBack", defaultValue = "-1") int daysBack) {
+    var measure = this.dataLakeMeasureManagement.getById(elementId);
+    if (Objects.isNull(measure)) {
+      return notFound();
+    }
+
     var allMeasurements = this.dataLakeMeasureManagement.getAllMeasurements();
-    var result = new DataExplorerDispatcher()
+    return ok(new DataExplorerDispatcher()
         .getDataExplorerManager()
         .getMeasurementCounter(
             allMeasurements,
-            measurementNames,
+            List.of(measure.getMeasureName()),
             daysBack)
-        .countMeasurementSizes();
-    return result;
+        .countMeasurementSizes()
+        .getOrDefault(measure.getMeasureName(), 0));
+  }
+
+  @Operation(
+      summary = "Deprecated measurement count endpoint",
+      description = "Use /api/v4/datalake/measure/{id}/count instead.",
+      deprecated = true
+  )
+  @GetMapping(path = "/count", produces = MediaType.APPLICATION_JSON_VALUE)
+  @PreAuthorize("this.hasReadAuthority()")
+  @Deprecated(since = "0.99.0", forRemoval = true)
+  public ResponseEntity<?> getDeprecatedEntryCountOfMeasurement() {
+    return ResponseEntity
+        .status(HttpStatus.GONE)
+        .body(SpLogMessage.warn(
+            "Deprecated endpoint",
+            "Use /api/v4/datalake/measure/{id}/count instead."
+        ));
   }
 
   @GetMapping(path = "{id}", produces = MediaType.APPLICATION_JSON_VALUE)
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 aede5118d5..2f05b40687 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
@@ -241,6 +241,39 @@ public class DataLakeResource extends 
AbstractDataLakeResource {
     return ok(results);
   }
 
+  @PostMapping(
+      path = "/measurements/latest-events",
+      produces = MediaType.APPLICATION_JSON_VALUE,
+      consumes = MediaType.APPLICATION_JSON_VALUE)
+  @PreAuthorize("this.hasReadAuthority()")
+  @Operation(summary = "Get the latest event timestamp for measurement 
series", tags = { "Data Lake" })
+  public ResponseEntity<?> getLatestEvents(@RequestBody List<String> 
measurementNames) {
+    if (measurementNames == null) {
+      return badRequest();
+    }
+
+    var distinctMeasurementNames = measurementNames.stream()
+        .distinct()
+        .toList();
+
+    var unauthorizedMeasureName = distinctMeasurementNames.stream()
+        .filter(measureName -> !checkPermissionByName(measureName, "READ"))
+        .findFirst();
+    if (unauthorizedMeasureName.isPresent()) {
+      return badRequest(
+          String.format("No read permission for measurement %s", 
unauthorizedMeasureName.get())
+      );
+    }
+
+    Map<String, Long> latestEvents = distinctMeasurementNames.stream()
+        .collect(Collectors.toMap(
+            measurementName -> measurementName,
+            this::getLatestEvent
+        ));
+
+    return ok(latestEvents);
+  }
+
   @GetMapping(path = "/measurements/{measurementID}/download", produces = 
MediaType.APPLICATION_OCTET_STREAM_VALUE)
   @PreAuthorize("this.hasReadAuthority() and 
this.checkPermissionByName(#measurementID, 'READ')")
   @Operation(summary = "Download data from a single measurement series by a 
given id", tags = {
@@ -393,6 +426,25 @@ public class DataLakeResource extends 
AbstractDataLakeResource {
     return new ProvidedRestQueryParams(measurementId, queryParamMap);
   }
 
+  private Long getLatestEvent(String measurementName) {
+    Map<String, String> queryParams = Map.of(
+        QP_START_DATE, "0",
+        QP_END_DATE, String.valueOf(System.currentTimeMillis()),
+        QP_LIMIT, "1",
+        QP_ORDER, "DESC",
+        QP_MISSING_VALUE_BEHAVIOUR, "empty"
+    );
+
+    try {
+      return this.dataExplorerQueryManagement
+          .getData(new ProvidedRestQueryParams(measurementName, queryParams), 
true)
+          .getLastTimestamp();
+    } catch (RuntimeException e) {
+      LOG.warn("Could not get latest event for measurement {}", 
measurementName, e);
+      return 0L;
+    }
+  }
+
   // Checks if the parameter for missing value behaviour is set
   private boolean isIgnoreMissingValues(String missingValueBehaviour) {
     return "ignore".equals(missingValueBehaviour);
diff --git a/ui/cypress/support/utils/chart/ChartBtns.ts 
b/ui/cypress/support/utils/chart/ChartBtns.ts
index 8e9d43cdf7..ff71cc1555 100644
--- a/ui/cypress/support/utils/chart/ChartBtns.ts
+++ b/ui/cypress/support/utils/chart/ChartBtns.ts
@@ -330,42 +330,6 @@ export class ChartBtns {
         return cy.dataCy('add-new-widget', { timeout: 10000 });
     }
 
-    public static dataLakeTruncateBtn() {
-        return cy.dataCy('datalake-truncate-btn');
-    }
-
-    public static dataLakeDeleteBtn() {
-        return cy.dataCy('datalake-delete-btn');
-    }
-
-    public static confirmDataLakeTruncateBtn() {
-        return cy.dataCy('confirm-truncate-data-btn', { timeout: 10000 });
-    }
-
-    public static confirmDataLakeDeleteBtn() {
-        return cy.dataCy('confirm-delete-data-btn', { timeout: 10000 });
-    }
-
-    public static datalakeNumberEvents() {
-        return cy.dataCy('datalake-number-of-events', { timeout: 30000 });
-    }
-
-    public static datalakeTotalCountBtn() {
-        return cy.dataCy('datalake-total-count-button', { timeout: 10000 });
-    }
-
-    public static datalakeTotalCountValue() {
-        return cy.dataCy('datalake-total-count-value', { timeout: 30000 });
-    }
-
-    public static datalakeTotalCountControl() {
-        return cy.get(
-            '[data-cy="datalake-total-count-button"], ' +
-                '[data-cy="datalake-total-count-value"]',
-            { timeout: 30000 },
-        );
-    }
-
     public static dashboardAssetCheckboxBtn() {
         return cy.dataCy('sp-show-dashboard-asset-checkbox');
     }
diff --git a/ui/cypress/support/utils/chart/ChartUtils.ts 
b/ui/cypress/support/utils/chart/ChartUtils.ts
index 3b889949e0..1790c8b637 100644
--- a/ui/cypress/support/utils/chart/ChartUtils.ts
+++ b/ui/cypress/support/utils/chart/ChartUtils.ts
@@ -679,10 +679,6 @@ export class ChartUtils {
         cy.dataCy('data-explorer-select-data-set-create-btn').click();
     }
 
-    public static goToDatalakeConfiguration() {
-        cy.visit('#/datasets');
-    }
-
     public static checkResults(
         measurementName: string,
         fileRoute: string,
@@ -817,26 +813,6 @@ export class ChartUtils {
         return currentDate;
     }
 
-    public static getDatalakeNumberOfEvents(
-        greaterThan?: number,
-    ): Cypress.Chainable<number> {
-        return ChartBtns.datalakeTotalCountControl()
-            .should('be.visible')
-            .click()
-            .then(() => ChartBtns.datalakeTotalCountValue())
-            .should('be.visible')
-            .should($value => {
-                const count = Number($value.text().replaceAll(',', '').trim());
-
-                expect(Number.isNaN(count)).to.equal(false);
-                if (greaterThan !== undefined) {
-                    expect(count).to.be.greaterThan(greaterThan);
-                }
-            })
-            .invoke('text')
-            .then(text => Number(text.replaceAll(',', '').trim()));
-    }
-
     public static checkRowsDashboardTable(amount: number) {
         cy.dataCy('dashboard-table-overview', {
             timeout: 10000,
diff --git a/ui/cypress/support/utils/dataset/DatasetBtns.ts 
b/ui/cypress/support/utils/dataset/DatasetBtns.ts
index 4cd6a5b4d6..16bc827b81 100644
--- a/ui/cypress/support/utils/dataset/DatasetBtns.ts
+++ b/ui/cypress/support/utils/dataset/DatasetBtns.ts
@@ -21,6 +21,10 @@ export class DatasetBtns {
         return cy.dataCy('datalake-settings', { timeout: 10000 });
     }
 
+    public static refreshDataLakeMeasures() {
+        return cy.dataCy('refresh-data-lake-measures', { timeout: 10000 });
+    }
+
     public static openCsvImportDialog() {
         return cy.dataCy('open-csv-import-dialog', { timeout: 10000 });
     }
@@ -145,15 +149,27 @@ export class DatasetBtns {
         return cy.dataCy('dataset-details-create-chart', { timeout: 10000 });
     }
 
-    public static datasetTotalCountCell(name: string) {
-        return this.datasetRow(name).find(
-            '[data-cy="datalake-number-of-events"]',
-        );
+    public static datasetLastEventCell(name: string) {
+        return this.datasetRow(name).find('[data-cy="datalake-last-event"]');
+    }
+
+    public static datalakeLastEvent() {
+        return cy.dataCy('datalake-last-event', { timeout: 30000 });
+    }
+
+    public static dataLakeTruncateBtn() {
+        return cy.dataCy('datalake-truncate-btn');
+    }
+
+    public static dataLakeDeleteBtn() {
+        return cy.dataCy('datalake-delete-btn');
+    }
+
+    public static confirmDataLakeTruncateBtn() {
+        return cy.dataCy('confirm-truncate-data-btn', { timeout: 10000 });
     }
 
-    public static datasetTotalCountButton(name: string) {
-        return this.datasetTotalCountCell(name).find(
-            '[data-cy="datalake-total-count-button"]',
-        );
+    public static confirmDataLakeDeleteBtn() {
+        return cy.dataCy('confirm-delete-data-btn', { timeout: 10000 });
     }
 }
diff --git a/ui/cypress/support/utils/dataset/DatasetUtils.ts 
b/ui/cypress/support/utils/dataset/DatasetUtils.ts
index e5ef33302c..482c79eda0 100644
--- a/ui/cypress/support/utils/dataset/DatasetUtils.ts
+++ b/ui/cypress/support/utils/dataset/DatasetUtils.ts
@@ -27,6 +27,15 @@ export class DatasetUtils {
         cy.visit('#/datasets');
     }
 
+    public static goToDatalakeConfiguration() {
+        this.goToDatasets();
+    }
+
+    public static refreshDataLakeMeasures() {
+        DatasetBtns.refreshDataLakeMeasures().should('be.visible').click();
+        DatasetBtns.datasetTable().should('be.visible');
+    }
+
     public static checkAmountOfDatasets(amount: number) {
         DatasetUtils.goToDatasets();
 
@@ -118,31 +127,6 @@ export class DatasetUtils {
         }
     }
 
-    public static expectDatasetTotalEventCount(
-        datasetName: string,
-        expectedCount: string,
-    ) {
-        this.loadDatasetTotalEventCount(datasetName);
-        DatasetBtns.datasetTotalCountCell(datasetName).should($element => {
-            const text = $element.text().trim();
-            expect(text).to.equal(expectedCount);
-        });
-    }
-
-    public static loadDatasetTotalEventCount(datasetName: string) {
-        DatasetBtns.datasetRow(datasetName).should('be.visible');
-        DatasetBtns.datasetTotalCountButton(datasetName).then($button => {
-            if ($button.length > 0) {
-                cy.wrap($button[0]).click({ force: true });
-            }
-        });
-
-        DatasetBtns.datasetTotalCountCell(datasetName).should($element => {
-            const text = $element.text().trim();
-            expect(text).not.to.equal('Click to load');
-        });
-    }
-
     public static openDatasetPreview(datasetName: string) {
         DatasetBtns.datasetRow(datasetName)
             .find('mat-icon')
@@ -153,34 +137,96 @@ export class DatasetUtils {
 
     public static openDatasetDetails(datasetName: string) {
         DatasetUtils.goToDatasets();
-        DatasetUtils.waitForTotalEvents(datasetName);
+        DatasetUtils.waitForDatasetNotEmpty(datasetName);
         DatasetBtns.datasetRow(datasetName).click();
         cy.url().should('include', '#/datasets/');
     }
 
-    public static waitForTotalEvents(datasetName: string, attempts = 30) {
-        DatasetBtns.datasetTotalCountButton(datasetName).click({
-            force: true,
-        });
-        DatasetBtns.datasetTotalCountCell(datasetName)
-            .find('[data-cy="datalake-number-of-events-spinner"]')
-            .should('not.exist');
-        DatasetBtns.datasetTotalCountCell(datasetName).then($cell => {
-            const eventCount = DatasetUtils.parseEventCount($cell.text());
-
-            if (eventCount > 0) {
-                expect(eventCount).to.be.greaterThan(0);
+    public static waitForDatasetNotEmpty(
+        datasetName?: string,
+        attempts = 30,
+    ): Cypress.Chainable<string> {
+        this.refreshDataLakeMeasures();
+        return this.getDatasetLastEventCell(datasetName).then($cell => {
+            const lastEvent = $cell.text().trim();
+
+            if (this.isDatasetNotEmptyValue(lastEvent)) {
+                return lastEvent;
             } else if (attempts > 0) {
                 cy.wait(1000);
-                DatasetUtils.waitForTotalEvents(datasetName, attempts - 1);
+                return DatasetUtils.waitForDatasetNotEmpty(
+                    datasetName,
+                    attempts - 1,
+                );
             } else {
-                expect(eventCount).to.be.greaterThan(0);
+                expect(this.isDatasetNotEmptyValue(lastEvent)).to.equal(true);
+                return lastEvent;
             }
         });
     }
 
-    private static parseEventCount(text: string) {
-        return Number(text.trim().replace(/[^\d.-]/g, ''));
+    public static expectDatasetEmpty(datasetName?: string) {
+        this.refreshDataLakeMeasures();
+        this.getDatasetLastEventCell(datasetName)
+            .should('be.visible')
+            .should($element => {
+                expect(
+                    DatasetUtils.isDatasetEmptyValue($element.text()),
+                ).to.equal(true);
+            });
+    }
+
+    public static expectDatasetNotEmpty(datasetName?: string) {
+        this.refreshDataLakeMeasures();
+        this.getDatasetLastEventCell(datasetName)
+            .should('be.visible')
+            .should($element => {
+                expect(
+                    DatasetUtils.isDatasetNotEmptyValue($element.text()),
+                ).to.equal(true);
+            });
+    }
+
+    public static expectDatasetDeleted(datasetName?: string) {
+        this.refreshDataLakeMeasures();
+        if (datasetName) {
+            DatasetBtns.datasetRow(datasetName).should('not.exist');
+            return;
+        }
+
+        this.getDatasetLastEventCell(datasetName).should('not.exist');
+    }
+
+    public static expectDatasetLastEventChanged(
+        previousLastEvent: string,
+        datasetName?: string,
+    ) {
+        this.waitForDatasetNotEmpty(datasetName).should(lastEvent => {
+            expect(this.getComparableLastEventValue(lastEvent)).not.to.equal(
+                this.getComparableLastEventValue(previousLastEvent),
+            );
+        });
+    }
+
+    private static getDatasetLastEventCell(datasetName?: string) {
+        return datasetName
+            ? DatasetBtns.datasetLastEventCell(datasetName)
+            : DatasetBtns.datalakeLastEvent();
+    }
+
+    private static isDatasetEmptyValue(value: string) {
+        return value.trim() === 'n/a';
+    }
+
+    private static isDatasetNotEmptyValue(value: string) {
+        const normalizedValue = value.trim();
+        return normalizedValue.length > 0 && !this.isDatasetEmptyValue(value);
+    }
+
+    private static getComparableLastEventValue(value: string) {
+        const trimmedValue = value.trim();
+        const exactTimeMatch = trimmedValue.match(/\(([^()]*)\)$/);
+        return exactTimeMatch?.[1] ?? trimmedValue;
     }
 
     public static openLatestEventsTab() {
diff --git a/ui/cypress/tests/chart/configuration.smoke.spec.ts 
b/ui/cypress/tests/chart/configuration.smoke.spec.ts
index a09a831321..05c75dd885 100644
--- a/ui/cypress/tests/chart/configuration.smoke.spec.ts
+++ b/ui/cypress/tests/chart/configuration.smoke.spec.ts
@@ -17,9 +17,10 @@
  */
 
 import { ChartUtils } from '../../support/utils/chart/ChartUtils';
-import { ChartBtns } from '../../support/utils/chart/ChartBtns';
 import { GeneralUtils } from '../../support/utils/GeneralUtils';
 import { PrepareTestDataUtils } from 
'../../support/utils/PrepareTestDataUtils';
+import { DatasetUtils } from '../../support/utils/dataset/DatasetUtils';
+import { DatasetBtns } from '../../support/utils/dataset/DatasetBtns';
 
 describe('Test Truncate data in datalake', () => {
     beforeEach('Setup Test', () => {
@@ -28,26 +29,18 @@ describe('Test Truncate data in datalake', () => {
     });
 
     it('Perform Test', () => {
-        ChartUtils.goToDatalakeConfiguration();
-        cy.dataCy('datalake-total-count-button').click();
+        DatasetUtils.goToDatalakeConfiguration();
 
-        // Check if amount of events is correct
-        ChartBtns.datalakeNumberEvents().should('be.visible').contains('10');
+        // Check if the last event is shown
+        DatasetUtils.expectDatasetNotEmpty(PrepareTestDataUtils.dataName);
 
         // Truncate data
         GeneralUtils.openMenuForRow(PrepareTestDataUtils.dataName);
-        ChartBtns.dataLakeTruncateBtn().should('be.visible').click();
-        ChartBtns.confirmDataLakeTruncateBtn().should('be.visible').click();
+        DatasetBtns.dataLakeTruncateBtn().should('be.visible').click();
+        DatasetBtns.confirmDataLakeTruncateBtn().should('be.visible').click();
 
-        cy.dataCy('datalake-total-count-button').click();
-
-        // Check if amount of events is zero. The should('have.text, '0') is 
required to check for text equality
-        ChartBtns.datalakeNumberEvents()
-            .should('be.visible')
-            .should($element => {
-                const text = $element.text().trim();
-                expect(text).to.equal('0');
-            });
+        // Check if there are no events left
+        DatasetUtils.expectDatasetEmpty(PrepareTestDataUtils.dataName);
     });
 });
 
@@ -58,18 +51,17 @@ describe('Delete data in datalake', () => {
     });
 
     it('Perform Test', () => {
-        ChartUtils.goToDatalakeConfiguration();
-        cy.dataCy('datalake-total-count-button').click();
+        DatasetUtils.goToDatalakeConfiguration();
 
-        // Check if amount of events is correct
-        ChartBtns.datalakeNumberEvents().should('be.visible').contains('10');
+        // Check if the last event is shown
+        DatasetUtils.expectDatasetNotEmpty(PrepareTestDataUtils.dataName);
 
         // Delete data
         GeneralUtils.openMenuForRow(PrepareTestDataUtils.dataName);
-        ChartBtns.dataLakeDeleteBtn().should('be.visible').click();
-        ChartBtns.confirmDataLakeDeleteBtn().should('be.visible').click();
+        DatasetBtns.dataLakeDeleteBtn().should('be.visible').click();
+        DatasetBtns.confirmDataLakeDeleteBtn().should('be.visible').click();
 
-        // Check if amount of events is zero
-        ChartBtns.datalakeNumberEvents().should('have.length', 0);
+        // Check if the dataset row is gone
+        DatasetUtils.expectDatasetDeleted(PrepareTestDataUtils.dataName);
     });
 });
diff --git a/ui/cypress/tests/connect/editAdapter.smoke.spec.ts 
b/ui/cypress/tests/connect/editAdapter.smoke.spec.ts
index 19618503ff..7bdf1ee878 100644
--- a/ui/cypress/tests/connect/editAdapter.smoke.spec.ts
+++ b/ui/cypress/tests/connect/editAdapter.smoke.spec.ts
@@ -19,10 +19,10 @@
 import { ConnectUtils } from '../../support/utils/connect/ConnectUtils';
 import { ConnectBtns } from '../../support/utils/connect/ConnectBtns';
 import { AdapterBuilder } from '../../support/builder/AdapterBuilder';
-import { ChartUtils } from '../../support/utils/chart/ChartUtils';
 import { SharedUtils } from '../../support/utils/shared/SharedUtils';
 import { SharedBtns } from '../../support/utils/shared/SharedBtns';
 import { ConnectEventSchemaUtils } from 
'../../support/utils/connect/ConnectEventSchemaUtils';
+import { DatasetUtils } from '../../support/utils/dataset/DatasetUtils';
 
 describe('Test Edit Adapter', () => {
     beforeEach('Setup Test', () => {
@@ -109,13 +109,12 @@ describe('Test Edit Adapter', () => {
 
         storeAndStartEditedAdapter();
 
-        // Validate that the data is further persisted in the database by 
checking if the amount of events in the data lake changes
-        ChartUtils.goToDatalakeConfiguration();
-        ChartUtils.getDatalakeNumberOfEvents().then(initialValue => {
+        // Validate that the data is further persisted in the database by 
checking if the last event changes in the data lake
+        DatasetUtils.goToDatalakeConfiguration();
+        DatasetUtils.waitForDatasetNotEmpty().then(initialLastEvent => {
             cy.wait(3000);
-            ChartUtils.goToDatalakeConfiguration();
-
-            ChartUtils.getDatalakeNumberOfEvents(initialValue);
+            DatasetUtils.goToDatalakeConfiguration();
+            DatasetUtils.expectDatasetLastEventChanged(initialLastEvent);
         });
     });
 
diff --git a/ui/cypress/tests/dataset/csvImport.spec.ts 
b/ui/cypress/tests/dataset/csvImport.spec.ts
index 7ca9cb4198..3249e22c05 100644
--- a/ui/cypress/tests/dataset/csvImport.spec.ts
+++ b/ui/cypress/tests/dataset/csvImport.spec.ts
@@ -28,7 +28,7 @@ describe('CSV import happy path', () => {
         cy.initStreamPipesTest();
     });
 
-    it('Uploads a CSV file into a new dataset and shows the imported event 
count', () => {
+    it('Uploads a CSV file into a new dataset and shows the imported events', 
() => {
         DatasetUtils.openCsvImportDialog();
         DatasetUtils.uploadCsvImportFile(
             'datalake/machine-data-simulator-import.csv',
@@ -38,7 +38,7 @@ describe('CSV import happy path', () => {
         DatasetUtils.selectCsvImportDelimiterComma();
         DatasetUtils.selectCsvImportTimestampColumn(0);
         DatasetUtils.uploadCsvImport();
-        DatasetUtils.expectDatasetTotalEventCount(datasetName, '7');
+        DatasetUtils.expectDatasetNotEmpty(datasetName);
         DatasetUtils.openDatasetPreview(datasetName);
         DatasetUtils.expectDatasetPreviewDoesNotContainKey('Timestamp');
     });
@@ -54,10 +54,7 @@ describe('CSV import happy path', () => {
         DatasetUtils.selectCsvImportTimestampColumn(0);
         DatasetUtils.setCsvImportTimestampFormat('yyyy-MM-dd HH:mm:ss');
         DatasetUtils.uploadCsvImport();
-        DatasetUtils.expectDatasetTotalEventCount(
-            stringTimestampDatasetName,
-            '7',
-        );
+        DatasetUtils.expectDatasetNotEmpty(stringTimestampDatasetName);
     });
 
     it('Uploads a CSV file with missing values and still imports all rows', () 
=> {
@@ -70,10 +67,7 @@ describe('CSV import happy path', () => {
         DatasetUtils.selectCsvImportDelimiterComma();
         DatasetUtils.selectCsvImportTimestampColumn(0);
         DatasetUtils.uploadCsvImport();
-        DatasetUtils.expectDatasetTotalEventCount(
-            missingValuesDatasetName,
-            '7',
-        );
+        DatasetUtils.expectDatasetNotEmpty(missingValuesDatasetName);
     });
 
     it('Appends matching data to an existing dataset and warns on mismatched 
timestamp schema', () => {
@@ -86,7 +80,7 @@ describe('CSV import happy path', () => {
         DatasetUtils.selectCsvImportDelimiterComma();
         DatasetUtils.selectCsvImportTimestampColumn(0);
         DatasetUtils.uploadCsvImport();
-        DatasetUtils.expectDatasetTotalEventCount(existingDatasetName, '7');
+        DatasetUtils.expectDatasetNotEmpty(existingDatasetName);
 
         DatasetUtils.openCsvImportDialog();
         DatasetUtils.uploadCsvImportFile(
@@ -96,7 +90,7 @@ describe('CSV import happy path', () => {
         DatasetUtils.continueCsvImportToPreview();
         DatasetUtils.selectCsvImportDelimiterComma();
         DatasetUtils.uploadCsvImport();
-        DatasetUtils.expectDatasetTotalEventCount(existingDatasetName, '14');
+        DatasetUtils.expectDatasetNotEmpty(existingDatasetName);
 
         DatasetUtils.openCsvImportDialog();
         DatasetUtils.uploadCsvImportFile(
diff --git 
a/ui/cypress/tests/experimental/restartStreamPipes/restartStreamPipes2.ts 
b/ui/cypress/tests/experimental/restartStreamPipes/restartStreamPipes2.ts
index 2bed28d3f4..e7d54b3f74 100644
--- a/ui/cypress/tests/experimental/restartStreamPipes/restartStreamPipes2.ts
+++ b/ui/cypress/tests/experimental/restartStreamPipes/restartStreamPipes2.ts
@@ -17,7 +17,8 @@
  */
 
 import { DashboardUtils } from '../../../support/utils/DashboardUtils';
-import { ChartUtils } from '../../../support/utils/chart/ChartUtils';
+import { DatasetBtns } from '../../../support/utils/dataset/DatasetBtns';
+import { DatasetUtils } from '../../../support/utils/dataset/DatasetUtils';
 
 describe('Validate StreamPipes after restart', () => {
     beforeEach('Setup Test', () => {
@@ -26,11 +27,9 @@ describe('Validate StreamPipes after restart', () => {
 
     it('Perform Test', () => {
         // Truncate data in db
-        ChartUtils.goToDatalakeConfiguration();
-        cy.dataCy('datalake-truncate-btn').should('be.visible').click();
-        cy.dataCy('confirm-truncate-data-btn', { timeout: 10000 })
-            .should('be.visible')
-            .click();
+        DatasetUtils.goToDatalakeConfiguration();
+        DatasetBtns.dataLakeTruncateBtn().should('be.visible').click();
+        DatasetBtns.confirmDataLakeTruncateBtn().should('be.visible').click();
 
         // open dashboard
         DashboardUtils.goToDashboard();
diff --git a/ui/deployment/i18n/de.json b/ui/deployment/i18n/de.json
index 569efd5889..fcb8e4df5d 100644
--- a/ui/deployment/i18n/de.json
+++ b/ui/deployment/i18n/de.json
@@ -1,8 +1,6 @@
 {
   " items ": " Einträge",
   " of ": " von ",
-  "# Events (7d)": "# Daten (7d)",
-  "# Events (total)": "# Daten (gesamt)",
   "# Provide OPC UA Node IDs below, one per line.\n# Format: 
ns=<namespace>;s=<node_id> (e.g., ns=3;s=SampleNodeId)\n": "# Geben Sie unten 
OPC UA Node-IDs ein, eine pro Zeile.\n# Format: ns=<namespace>;s=<node_id> (z. 
B. ns=3;s=SampleNodeId)\n",
   "(This might crash the browser)": "(Dies kann zum Absturz des Browsers 
führen)",
   "(dynamic options cannot be saved and are hidden)": "(dynamische Optionen 
können nicht gespeichert werden und sind ausgeblendet)",
@@ -168,7 +166,6 @@
   "Car": "Auto",
   "Card settings": "Karteneinstellungen",
   "Caution when changing data types": "Vorsicht bei Änderung des Datentyps",
-  "Caution when loading total count": "Vorsicht beim Laden der Gesamtanzahl",
   "Certificate Details": "Zertifikat-Details",
   "Certificate details": "Zertifikat-Details",
   "Certificates": "Zertifikate",
@@ -200,7 +197,6 @@
   "Clear search": "Suche löschen",
   "Clear selection & reload": "Auswahl löschen & neu laden",
   "Click to add label": "Klicken, um Label hinzuzufügen",
-  "Click to load": "Klicken zum Laden",
   "Client Secret": "Client-Schlüssel",
   "Clockwise": "Im Uhrzeigersinn",
   "Clone": "Klonen",
@@ -427,6 +423,7 @@
   "Event Aggregation": "Aggregation",
   "Event Transformation Configuration has changed": "Die Transformation von 
Events wurde geändert",
   "Events": "Ereignisse",
+  "Events in the last 7 days (UTC)": "Events in den letzten 7 Tagen (UTC)",
   "Everything older than": "Älter als",
   "Exact location of the site": "Genaue Lage des Standorts",
   "Excel template": "Excel Vorlage",
@@ -602,11 +599,11 @@
   "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",
   "Last seen:": "Zuletzt gesehen:",
+  "Last updated": "Zuletzt aktualisiert",
   "Latitude": "Breitengrad",
   "Layer type": "Kartentyp",
   "Leave entry empty": "Leer lassen",
@@ -1163,6 +1160,7 @@
   "Top N": "Top N",
   "Topics": "Themen",
   "Total Memory": "Gesamtspeicher",
+  "Total events": "Events insgesamt",
   "Trace": "Spur",
   "Track color": "Hintergrundfarbe",
   "Traffic Light": "Ampel",
diff --git a/ui/deployment/i18n/en.json b/ui/deployment/i18n/en.json
index 8a2a543fce..7b36992eac 100644
--- a/ui/deployment/i18n/en.json
+++ b/ui/deployment/i18n/en.json
@@ -1,8 +1,6 @@
 {
   " items ": null,
   " of ": null,
-  "# Events (7d)": null,
-  "# Events (total)": null,
   "# Provide OPC UA Node IDs below, one per line.\n# Format: 
ns=<namespace>;s=<node_id> (e.g., ns=3;s=SampleNodeId)\n": null,
   "(This might crash the browser)": null,
   "(dynamic options cannot be saved and are hidden)": null,
@@ -168,7 +166,6 @@
   "Car": null,
   "Card settings": null,
   "Caution when changing data types": null,
-  "Caution when loading total count": null,
   "Certificate Details": null,
   "Certificate details": null,
   "Certificates": null,
@@ -200,7 +197,6 @@
   "Clear search": null,
   "Clear selection & reload": null,
   "Click to add label": null,
-  "Click to load": null,
   "Client Secret": null,
   "Clockwise": null,
   "Clone": null,
@@ -427,6 +423,7 @@
   "Event Aggregation": null,
   "Event Transformation Configuration has changed": null,
   "Events": null,
+  "Events in the last 7 days (UTC)": null,
   "Everything older than": null,
   "Exact location of the site": null,
   "Excel template": null,
@@ -602,11 +599,11 @@
   "Last 15 min": null,
   "Last Login": null,
   "Last event": null,
-  "Last updated": null,
   "Last message": null,
   "Last modified": null,
   "Last published message": null,
   "Last seen:": null,
+  "Last updated": null,
   "Latitude": null,
   "Layer type": null,
   "Leave entry empty": null,
@@ -1163,6 +1160,7 @@
   "Top N": null,
   "Topics": null,
   "Total Memory": null,
+  "Total events": null,
   "Trace": null,
   "Track color": null,
   "Traffic Light": null,
diff --git a/ui/deployment/i18n/pl.json b/ui/deployment/i18n/pl.json
index b869395523..c04a0d71b1 100644
--- a/ui/deployment/i18n/pl.json
+++ b/ui/deployment/i18n/pl.json
@@ -1,8 +1,6 @@
 {
   " items ": " elementów ",
   " of ": " z ",
-  "# Events (7d)": "Liczba zdarzeń (7 dni)",
-  "# Events (total)": "Liczba zdarzeń (łącznie)",
   "# Provide OPC UA Node IDs below, one per line.\n# Format: 
ns=<namespace>;s=<node_id> (e.g., ns=3;s=SampleNodeId)\n": "# Podaj poniżej 
identyfikatory węzłów OPC UA, po jednym w wierszu.\n# Format: 
ns=<namespace>;s=<node_id> (np. ns=3;s=SampleNodeId)\n",
   "(This might crash the browser)": "(Może to spowodować awarię przeglądarki)",
   "(dynamic options cannot be saved and are hidden)": "(opcje dynamiczne nie 
mogą być zapisane i są ukryte)",
@@ -168,7 +166,6 @@
   "Car": "Samochód",
   "Card settings": "Ustawienia karty",
   "Caution when changing data types": "Uwaga przy zmianie typów danych",
-  "Caution when loading total count": "Uwaga przy wczytywaniu łącznej liczby",
   "Certificate Details": "Szczegóły certyfikatu",
   "Certificate details": "Szczegóły certyfikatu",
   "Certificates": "Certyfikaty",
@@ -200,7 +197,6 @@
   "Clear search": "Wyczyść wyszukiwanie",
   "Clear selection & reload": "Wyczyść wybór i przeładuj",
   "Click to add label": "Kliknij, aby dodać etykietę",
-  "Click to load": "Kliknij, aby wczytać",
   "Client Secret": "Tajny klucz klienta",
   "Clockwise": "Zgodnie z ruchem wskazówek zegara",
   "Clone": "Sklonuj",
@@ -427,6 +423,7 @@
   "Event Aggregation": "Agregacja",
   "Event Transformation Configuration has changed": "Konfiguracja 
transformacji zdarzeń została zmieniona",
   "Events": "Zdarzenia",
+  "Events in the last 7 days (UTC)": "Zdarzenia z ostatnich 7 dni (UTC)",
   "Everything older than": "Wszystko starsze niż",
   "Exact location of the site": "Dokładne położenie lokalizacji",
   "Excel template": "Szablon Excela",
@@ -602,11 +599,11 @@
   "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ść",
   "Last seen:": "Ostatnio widziano:",
+  "Last updated": "Ostatnia aktualizacja",
   "Latitude": "Szerokość geograficzna",
   "Layer type": "Typ warstwy",
   "Leave entry empty": "Pozostaw puste",
@@ -1163,6 +1160,7 @@
   "Top N": "Top N",
   "Topics": "Tematy",
   "Total Memory": "Calkowita pamiec",
+  "Total events": "Łączna liczba zdarzeń",
   "Trace": "Ślad",
   "Track color": "Kolor tła paska",
   "Traffic Light": "Sygnalizator",
diff --git 
a/ui/projects/streampipes/platform-services/src/lib/apis/datalake-rest.service.ts
 
b/ui/projects/streampipes/platform-services/src/lib/apis/datalake-rest.service.ts
index fb2ccafeff..ff7769a800 100644
--- 
a/ui/projects/streampipes/platform-services/src/lib/apis/datalake-rest.service.ts
+++ 
b/ui/projects/streampipes/platform-services/src/lib/apis/datalake-rest.service.ts
@@ -64,18 +64,18 @@ export class DatalakeRestService {
         return this.baseUrl + '/api/v4/datalake/import';
     }
 
-    getMeasurementEntryCounts(
-        measurementNames: string[],
+    getMeasurementEntryCount(
+        measurementId: string,
         daysBack = -1,
-    ): Observable<Record<string, number>> {
-        return this.http
-            .get(`${this.dataLakeMeasureUrl}/count`, {
+    ): Observable<number> {
+        return this.http.get<number>(
+            
`${this.dataLakeMeasureUrl}/${encodeURIComponent(measurementId)}/count`,
+            {
                 params: {
-                    measurementNames,
                     daysBack,
                 },
-            })
-            .pipe(map(r => r as Record<string, number>));
+            },
+        );
     }
 
     getAllMeasurementSeries(): Observable<DataLakeMeasure[]> {
@@ -119,6 +119,18 @@ export class DatalakeRestService {
             .pipe(map(response => response as SpQueryResult[]));
     }
 
+    getLatestMeasurementEvents(
+        measurementNames: string[],
+    ): Observable<Record<string, number>> {
+        return this.http.post<Record<string, number>>(
+            `${this.dataLakeUrl}/measurements/latest-events`,
+            measurementNames,
+            {
+                context: new HttpContext().set(NGX_LOADING_BAR_IGNORED, true),
+            },
+        );
+    }
+
     getData(
         index: string,
         queryParams: DatalakeQueryParameters,
diff --git 
a/ui/src/app/core-services/time-formatting/last-updated-formatter.service.ts 
b/ui/src/app/core-services/time-formatting/last-updated-formatter.service.ts
new file mode 100644
index 0000000000..4f906933b7
--- /dev/null
+++ b/ui/src/app/core-services/time-formatting/last-updated-formatter.service.ts
@@ -0,0 +1,101 @@
+/*
+ * 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 { TranslateService } from '@ngx-translate/core';
+
+@Injectable({ providedIn: 'root' })
+export class LastUpdatedFormatterService {
+    private translateService = inject(TranslateService);
+
+    private exactTimeFormatter: Intl.DateTimeFormat;
+    private relativeTimeFormatter: Intl.RelativeTimeFormat;
+    private formatterLocale: string;
+
+    public formatLastUpdatedAt(
+        updatedAt: number | null | undefined,
+        currentTime = Date.now(),
+        emptyLabel = 'n/a',
+    ): string {
+        if (!updatedAt) {
+            return emptyLabel;
+        }
+
+        const exactTime = this.formatExactTime(updatedAt);
+        const relativeTime = this.formatRelativeTime(updatedAt, currentTime);
+        return relativeTime ? `${relativeTime} (${exactTime})` : exactTime;
+    }
+
+    private formatExactTime(updatedAt: number): string {
+        this.updateFormatters();
+        return this.exactTimeFormatter.format(new Date(updatedAt));
+    }
+
+    private formatRelativeTime(
+        updatedAt: number,
+        currentTime: number,
+    ): string | undefined {
+        const ageInSeconds = Math.max(
+            0,
+            Math.round((currentTime - updatedAt) / 1000),
+        );
+
+        if (ageInSeconds < 60) {
+            this.updateFormatters();
+            return this.relativeTimeFormatter.format(-ageInSeconds, 'second');
+        } else if (ageInSeconds < 3600) {
+            this.updateFormatters();
+            return this.relativeTimeFormatter.format(
+                -Math.floor(ageInSeconds / 60),
+                'minute',
+            );
+        } else if (ageInSeconds < 86400) {
+            this.updateFormatters();
+            return this.relativeTimeFormatter.format(
+                -Math.floor(ageInSeconds / 3600),
+                'hour',
+            );
+        }
+
+        return undefined;
+    }
+
+    private updateFormatters(): void {
+        const locale = this.currentLocale;
+        if (this.formatterLocale === locale) {
+            return;
+        }
+
+        this.exactTimeFormatter = new Intl.DateTimeFormat(locale, {
+            dateStyle: 'medium',
+            timeStyle: 'medium',
+        });
+        this.relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, {
+            numeric: 'auto',
+        });
+        this.formatterLocale = locale;
+    }
+
+    private get currentLocale(): string {
+        return (
+            this.translateService.getCurrentLang() ||
+            this.translateService.getFallbackLang() ||
+            'en'
+        );
+    }
+}
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 8686067d34..e8b62b8e3c 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
@@ -45,7 +45,8 @@ import {
     LayoutAlignDirective,
     LayoutDirective,
 } from '@ngbracket/ngx-layout/flex';
-import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+import { TranslatePipe } from '@ngx-translate/core';
+import { LastUpdatedFormatterService } from 
'../../../core-services/time-formatting/last-updated-formatter.service';
 
 @Component({
     selector: 'sp-dashboard-kiosk',
@@ -66,9 +67,9 @@ 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);
+    private lastUpdatedFormatterService = inject(LastUpdatedFormatterService);
 
     observableGenerator: ObservableGenerator;
     dashboard: Dashboard;
@@ -188,55 +189,12 @@ export class DashboardKioskComponent implements OnInit, 
OnDestroy {
     }
 
     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'
+        return this.lastUpdatedFormatterService.formatLastUpdatedAt(
+            this.lastUpdatedAt,
+            this.currentTime,
         );
     }
 
-    private get relativeTimeFormatter(): Intl.RelativeTimeFormat {
-        return new Intl.RelativeTimeFormat(this.currentLocale, {
-            numeric: 'auto',
-        });
-    }
-
     ngOnDestroy() {
         this.refresh$?.unsubscribe();
         this.dashboardRefresh$?.unsubscribe();
diff --git 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
 
b/ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
index a6b3aadef5..c6f9a7f1e9 100644
--- 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
+++ 
b/ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
@@ -20,10 +20,8 @@ export class DataLakeConfigurationEntry {
     public name: string;
     public measureName: string;
     public pipelines: string[] = [];
-    public eventsTotal = 0;
-    public eventsLatest = 0;
-    public eventsTotalLoading = false;
-    public eventsLatestLoading = false;
+    public lastEvent: number | null = null;
+    public lastEventLoading = false;
     public remove = true;
     public elementId: string;
     public retentionConfigured = false;
diff --git 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration.component.html
 
b/ui/src/app/dataset/components/datalake-configuration/datalake-configuration.component.html
index 3b3e882896..0c00c5679f 100644
--- 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration.component.html
+++ 
b/ui/src/app/dataset/components/datalake-configuration/datalake-configuration.component.html
@@ -49,14 +49,6 @@
             </button>
         </div>
         <div fxLayout="column" class="w-100" fxLayoutGap="10px">
-            <sp-alert-banner
-                type="info"
-                [title]="'Caution when loading total count' | translate"
-                [description]="
-                    'For large datasets, computing the total number of events 
can take a long time.'
-                "
-            >
-            </sp-alert-banner>
             <sp-table
                 featureCardId="measurement"
                 [dataSource]="dataSource"
@@ -90,16 +82,16 @@
                     </td>
                 </ng-container>
 
-                <ng-container matColumnDef="eventsLatest">
+                <ng-container matColumnDef="lastEvent">
                     <th mat-header-cell mat-sort-header *matHeaderCellDef>
-                        {{ '# Events (7d)' | translate }}
+                        {{ 'Last event' | translate }}
                     </th>
                     <td
                         mat-cell
-                        data-cy="datalake-number-of-events-latest"
+                        data-cy="datalake-last-event"
                         *matCellDef="let configurationEntry"
                     >
-                        @if (configurationEntry.eventsLatestLoading) {
+                        @if (configurationEntry.lastEventLoading) {
                             <mat-spinner
                                 [diameter]="20"
                                 fxLayoutAlign="center"
@@ -107,81 +99,11 @@
                                 >{{ 'Loading' | translate }}
                             </mat-spinner>
                         } @else {
-                            <sp-label
-                                tone="neutral"
-                                minWidth="100px"
-                                [labelText]="
-                                    configurationEntry.eventsLatest | number
-                                "
-                                size="small"
-                            >
-                            </sp-label>
+                            {{ formatLastEvent(configurationEntry.lastEvent) }}
                         }
                     </td>
                 </ng-container>
 
-                <ng-container matColumnDef="eventsTotal">
-                    <th mat-header-cell mat-sort-header *matHeaderCellDef>
-                        {{ '# Events (total)' | translate }}
-                    </th>
-                    <td
-                        mat-cell
-                        data-cy="datalake-number-of-events"
-                        *matCellDef="let configurationEntry"
-                    >
-                        <div fxLayout="row" fxLayoutAlign="start center">
-                            @if (configurationEntry.eventsTotalLoading) {
-                                <mat-spinner
-                                    [diameter]="20"
-                                    fxLayoutAlign="center"
-                                    color="accent"
-                                    data-cy="datalake-number-of-events-spinner"
-                                    >{{ 'Loading' | translate }}
-                                </mat-spinner>
-                            } @else {
-                                @if (configurationEntry.eventsTotal > -1) {
-                                    <sp-label
-                                        tone="neutral"
-                                        class="cursor-pointer"
-                                        data-cy="datalake-total-count-value"
-                                        minWidth="100px"
-                                        [labelText]="
-                                            configurationEntry.eventsTotal
-                                                | number
-                                        "
-                                        size="small"
-                                        (click)="
-                                            receiveTotalMeasurementSize(
-                                                configurationEntry
-                                            );
-                                            $event.stopPropagation()
-                                        "
-                                    >
-                                    </sp-label>
-                                } @else {
-                                    <sp-label
-                                        tone="neutral"
-                                        class="cursor-pointer"
-                                        data-cy="datalake-total-count-button"
-                                        minWidth="100px"
-                                        [labelText]="
-                                            'Click to load' | translate
-                                        "
-                                        size="small"
-                                        (click)="
-                                            receiveTotalMeasurementSize(
-                                                configurationEntry
-                                            );
-                                            $event.stopPropagation()
-                                        "
-                                    >
-                                    </sp-label>
-                                }
-                            }
-                        </div>
-                    </td>
-                </ng-container>
-
                 <ng-container matColumnDef="retention">
                     <th mat-header-cell *matHeaderCellDef>
                         {{ 'Retention' | translate }}
diff --git 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration.component.ts
 
b/ui/src/app/dataset/components/datalake-configuration/datalake-configuration.component.ts
index cf53cd6441..f6d042fd70 100644
--- 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration.component.ts
+++ 
b/ui/src/app/dataset/components/datalake-configuration/datalake-configuration.component.ts
@@ -57,11 +57,9 @@ import {
     ObjectPermissionDialogComponent,
     PanelType,
     SpAssetBrowserService,
-    SpAlertBannerComponent,
     SpBasicHeaderTitleComponent,
     SpBasicViewComponent,
     SpBreadcrumbService,
-    SpLabelComponent,
     SpTableAssetContextConfig,
     SpTableActionsDirective,
     SpTableComponent,
@@ -88,10 +86,11 @@ import { MatButton, MatIconButton } from 
'@angular/material/button';
 import { MatTooltip } from '@angular/material/tooltip';
 import { MatIcon } from '@angular/material/icon';
 import { MatProgressSpinner } from '@angular/material/progress-spinner';
-import { DatePipe, DecimalPipe, NgStyle } from '@angular/common';
+import { DatePipe, NgStyle } from '@angular/common';
 import { StyleDirective } from '@ngbracket/ngx-layout/extended';
 import { MatMenuItem } from '@angular/material/menu';
-import { Subscription } from 'rxjs';
+import { catchError, of, Subscription } from 'rxjs';
+import { LastUpdatedFormatterService } from 
'../../../core-services/time-formatting/last-updated-formatter.service';
 
 @Component({
     selector: 'sp-datalake-configuration',
@@ -123,14 +122,11 @@ import { Subscription } from 'rxjs';
         MatHeaderRow,
         MatRowDef,
         MatRow,
-        DecimalPipe,
         DatePipe,
         TranslatePipe,
-        SpLabelComponent,
         SpTableComponent,
         SpBasicHeaderTitleComponent,
         SpBasicViewComponent,
-        SpAlertBannerComponent,
         SpTableActionsDirective,
     ],
 })
@@ -150,6 +146,7 @@ export class DatalakeConfigurationComponent
     private currentUserService = inject(CurrentUserService);
     private assetFilterService = inject(SpAssetBrowserService);
     private router = inject(Router);
+    private lastUpdatedFormatterService = inject(LastUpdatedFormatterService);
 
     dataSource: MatTableDataSource<DataLakeConfigurationEntry> =
         new MatTableDataSource([]);
@@ -168,8 +165,7 @@ export class DatalakeConfigurationComponent
         'name',
         'assetContext',
         'pipeline',
-        'eventsLatest',
-        'eventsTotal',
+        'lastEvent',
         'retention',
         'actions',
     ];
@@ -190,6 +186,7 @@ export class DatalakeConfigurationComponent
     isAdmin = false;
     writeAccess = false;
     assetFilter$: Subscription;
+    currentTime = Date.now();
     currentFilterIds: Set<string> = new Set<string>();
 
     ngOnInit(): void {
@@ -219,7 +216,7 @@ export class DatalakeConfigurationComponent
         this.spTable.paginator.page.subscribe(event => {
             this.pageIndex = event.pageIndex;
             this.pageSize = event.pageSize;
-            this.receiveMeasurementSizes(this.pageIndex);
+            this.receiveLastEventTimes(this.pageIndex);
         });
     }
 
@@ -267,7 +264,7 @@ export class DatalakeConfigurationComponent
 
         this.dataSource.data = this.filteredMeasurements;
         this.updatePaginatorAfterFiltering();
-        this.receiveMeasurementSizes(this.pageIndex);
+        this.receiveLastEventTimes(this.pageIndex);
 
         setTimeout(() => {
             this.dataSource.paginator = this.paginator;
@@ -432,22 +429,16 @@ export class DatalakeConfigurationComponent
     onPageChange(event: any): void {
         this.pageIndex = event.pageIndex;
         this.pageSize = event.pageSize;
-        //this.receiveMeasurementSizes(this.pageIndex);
     }
 
-    receiveTotalMeasurementSize(entry: DataLakeConfigurationEntry): void {
-        this.queryEntryCounts([entry.name], 'eventsTotal');
-    }
-
-    receiveMeasurementSizes(pageIndex: number): void {
+    receiveLastEventTimes(pageIndex: number): void {
         const start = pageIndex * this.pageSize;
         const end = start + this.pageSize;
         const measurements = this.filteredMeasurements
             .slice(start, end)
-            .filter(m => m.eventsLatest === -1)
-            .map(m => m.name);
+            .filter(m => m.lastEvent === null);
         if (measurements.length > 0) {
-            this.queryEntryCounts(measurements, 'eventsLatest', 7);
+            this.queryLastEventTimes(measurements);
         }
     }
     showPermissionsDialog(element: DataLakeConfigurationEntry): void {
@@ -488,37 +479,37 @@ export class DatalakeConfigurationComponent
         });
     }
 
-    queryEntryCounts(
-        measurements: string[],
-        targetField: string,
-        daysBack = -1,
-    ): void {
-        this.applyLoadingStatus(measurements, targetField, true);
+    queryLastEventTimes(measurements: DataLakeConfigurationEntry[]): void {
+        this.applyLastEventLoadingStatus(measurements, true);
         this.datalakeRestService
-            .getMeasurementEntryCounts(measurements, daysBack)
-            .subscribe(res => {
-                this.applyLoadingStatus(measurements, targetField, false);
-                this.availableMeasurements.forEach(m => {
-                    if (res[m.name] !== undefined) {
-                        m[targetField] = res[m.name];
-                    }
+            .getLatestMeasurementEvents(
+                measurements.map(measurement => measurement.name),
+            )
+            .pipe(catchError(() => of({} as Record<string, number>)))
+            .subscribe(latestEvents => {
+                this.applyLastEventLoadingStatus(measurements, false);
+                measurements.forEach(measurement => {
+                    measurement.lastEvent = latestEvents[measurement.name] ?? 
0;
                 });
             });
     }
 
-    applyLoadingStatus(
-        measurements: string[],
-        targetField: string,
+    applyLastEventLoadingStatus(
+        measurements: DataLakeConfigurationEntry[],
         status: boolean,
     ): void {
-        const loadingField = targetField + 'Loading';
-        this.availableMeasurements.forEach(m => {
-            if (measurements.includes(m.name)) {
-                m[loadingField] = status;
-            }
+        measurements.forEach(measurement => {
+            measurement.lastEventLoading = status;
         });
     }
 
+    formatLastEvent(lastEventAt: number | null): string {
+        return this.lastUpdatedFormatterService.formatLastUpdatedAt(
+            lastEventAt,
+            this.currentTime,
+        );
+    }
+
     private toConfigurationEntry(
         measurement: DatasetSummaryDto,
     ): DataLakeConfigurationEntry {
@@ -531,8 +522,7 @@ export class DatalakeConfigurationComponent
         entry.lastExport = measurement.lastExport;
         entry.lastRetentionStatus = measurement.lastRetentionStatus;
         entry.remove = measurement.removable;
-        entry.eventsLatest = -1;
-        entry.eventsTotal = -1;
+        entry.lastEvent = null;
         return entry;
     }
 
diff --git 
a/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/daily-event-counts-chart/daily-event-counts-chart.component.html
 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/daily-event-counts-chart/daily-event-counts-chart.component.html
new file mode 100644
index 0000000000..2261573d00
--- /dev/null
+++ 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/daily-event-counts-chart/daily-event-counts-chart.component.html
@@ -0,0 +1,29 @@
+<!--
+~ 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 fxLayout="column" fxLayoutGap="0.5rem">
+    <h3 class="chart-title">
+        {{ 'Events in the last 7 days (UTC)' | translate }}
+    </h3>
+    <div
+        echarts
+        class="daily-counts-chart"
+        data-cy="dataset-details-metrics-daily-counts-chart"
+        [options]="chartOptions"
+    ></div>
+</div>
diff --git 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/daily-event-counts-chart/daily-event-counts-chart.component.scss
similarity index 62%
copy from 
ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
copy to 
ui/src/app/dataset/components/dataset-details/dataset-details-metrics/daily-event-counts-chart/daily-event-counts-chart.component.scss
index a6b3aadef5..2b29309aaa 100644
--- 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
+++ 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/daily-event-counts-chart/daily-event-counts-chart.component.scss
@@ -16,19 +16,14 @@
  *
  */
 
-export class DataLakeConfigurationEntry {
-    public name: string;
-    public measureName: string;
-    public pipelines: string[] = [];
-    public eventsTotal = 0;
-    public eventsLatest = 0;
-    public eventsTotalLoading = false;
-    public eventsLatestLoading = false;
-    public remove = true;
-    public elementId: string;
-    public retentionConfigured = false;
-    public lastExport: string | null = null;
-    public lastRetentionStatus: boolean | null = null;
+.chart-title {
+    margin: 0;
+    font-size: 1.35rem;
+    font-weight: 600;
+    text-align: center;
+}
 
-    constructor() {}
+.daily-counts-chart {
+    height: 320px;
+    width: 100%;
 }
diff --git 
a/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/daily-event-counts-chart/daily-event-counts-chart.component.ts
 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/daily-event-counts-chart/daily-event-counts-chart.component.ts
new file mode 100644
index 0000000000..8f42281c7e
--- /dev/null
+++ 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/daily-event-counts-chart/daily-event-counts-chart.component.ts
@@ -0,0 +1,92 @@
+/*
+ * 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, Input } from '@angular/core';
+import { EChartsOption } from 'echarts';
+import { NgxEchartsDirective } from 'ngx-echarts';
+import { TranslatePipe } from '@ngx-translate/core';
+import {
+    LayoutDirective,
+    LayoutGapDirective,
+} from '@ngbracket/ngx-layout/flex';
+
+export interface DailyEventCount {
+    label: string;
+    count: number;
+}
+
+@Component({
+    selector: 'sp-daily-event-counts-chart',
+    templateUrl: './daily-event-counts-chart.component.html',
+    styleUrls: ['./daily-event-counts-chart.component.scss'],
+    imports: [
+        LayoutDirective,
+        LayoutGapDirective,
+        NgxEchartsDirective,
+        TranslatePipe,
+    ],
+})
+export class DailyEventCountsChartComponent {
+    chartOptions: EChartsOption = this.makeChartOptions([]);
+
+    @Input()
+    set dailyEventCounts(dailyEventCounts: DailyEventCount[]) {
+        this.chartOptions = this.makeChartOptions(dailyEventCounts ?? []);
+    }
+
+    private makeChartOptions(
+        dailyEventCounts: DailyEventCount[],
+    ): EChartsOption {
+        return {
+            color: ['#0f7f8f'],
+            tooltip: {
+                trigger: 'axis',
+                valueFormatter: value => `${value} events`,
+            },
+            grid: {
+                top: 24,
+                right: 24,
+                bottom: 32,
+                left: 56,
+            },
+            xAxis: {
+                type: 'category',
+                data: dailyEventCounts.map(bucket => bucket.label),
+                axisTick: {
+                    alignWithLabel: true,
+                },
+            },
+            yAxis: {
+                type: 'value',
+                name: 'Events',
+                minInterval: 1,
+            },
+            series: [
+                {
+                    name: 'Events',
+                    type: 'bar',
+                    barMaxWidth: 100,
+                    data: dailyEventCounts.map(bucket => bucket.count),
+                    itemStyle: {
+                        borderRadius: [4, 4, 0, 0],
+                    },
+                },
+            ],
+        };
+    }
+}
diff --git 
a/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/dataset-details-metrics.component.html
 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/dataset-details-metrics.component.html
new file mode 100644
index 0000000000..d86080fef6
--- /dev/null
+++ 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/dataset-details-metrics.component.html
@@ -0,0 +1,74 @@
+<!--
+~ 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.
+~
+-->
+
+<sp-basic-nav-tabs
+    [spNavigationItems]="tabs"
+    [activeLink]="'metrics'"
+    [showBackLink]="true"
+    [backLinkTarget]="['datasets']"
+>
+    <div nav fxLayout="row" fxLayoutAlign="end center">
+        <button
+            mat-icon-button
+            color="accent"
+            class="mr-10"
+            [matTooltip]="'Refresh' | translate"
+            (click)="triggerUpdate()"
+        >
+            <i class="material-icons">refresh</i>
+        </button>
+    </div>
+
+    @if (datasetNotFound) {
+        <div class="text-xl" fxFlex="100" fxLayoutAlign="center center">
+            {{ 'The desired dataset was not found!' | translate }}
+        </div>
+    } @else if (dataset) {
+        <div
+            fxFlex="100"
+            fxLayout="column"
+            fxLayoutGap="1rem"
+            data-cy="dataset-details-metrics"
+        >
+            @if (loadingMetrics) {
+                <div
+                    class="loading-state"
+                    fxLayout="row"
+                    fxLayoutAlign="center center"
+                >
+                    <mat-spinner [diameter]="32" color="accent">
+                        {{ 'Loading' | translate }}
+                    </mat-spinner>
+                </div>
+            }
+
+            <sp-simple-metrics
+                [elementName]="dataset.measureName"
+                [lastPublishedLabel]="'Last event' | translate"
+                [statusValueLabel]="'Total events' | translate"
+                [lastTimestamp]="lastEventTimestamp"
+                [statusValue]="totalEventCount"
+            >
+            </sp-simple-metrics>
+
+            <sp-daily-event-counts-chart
+                [dailyEventCounts]="dailyEventCounts"
+            ></sp-daily-event-counts-chart>
+        </div>
+    }
+</sp-basic-nav-tabs>
diff --git 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/dataset-details-metrics.component.scss
similarity index 62%
copy from 
ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
copy to 
ui/src/app/dataset/components/dataset-details/dataset-details-metrics/dataset-details-metrics.component.scss
index a6b3aadef5..cf54b55ee6 100644
--- 
a/ui/src/app/dataset/components/datalake-configuration/datalake-configuration-entry.ts
+++ 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/dataset-details-metrics.component.scss
@@ -16,19 +16,6 @@
  *
  */
 
-export class DataLakeConfigurationEntry {
-    public name: string;
-    public measureName: string;
-    public pipelines: string[] = [];
-    public eventsTotal = 0;
-    public eventsLatest = 0;
-    public eventsTotalLoading = false;
-    public eventsLatestLoading = false;
-    public remove = true;
-    public elementId: string;
-    public retentionConfigured = false;
-    public lastExport: string | null = null;
-    public lastRetentionStatus: boolean | null = null;
-
-    constructor() {}
+.loading-state {
+    min-height: 3rem;
 }
diff --git 
a/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/dataset-details-metrics.component.ts
 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/dataset-details-metrics.component.ts
new file mode 100644
index 0000000000..bac1c7dc2d
--- /dev/null
+++ 
b/ui/src/app/dataset/components/dataset-details/dataset-details-metrics/dataset-details-metrics.component.ts
@@ -0,0 +1,248 @@
+/*
+ * 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, OnInit } from '@angular/core';
+import { SpAbstractDatasetDetailsDirective } from 
'../abstract-dataset-details.directive';
+import { SpQueryResult } from '@streampipes/platform-services';
+import { SpBasicNavTabsComponent } from '@streampipes/shared-ui';
+import {
+    FlexDirective,
+    LayoutAlignDirective,
+    LayoutDirective,
+    LayoutGapDirective,
+} from '@ngbracket/ngx-layout/flex';
+import { MatIconButton } from '@angular/material/button';
+import { MatTooltip } from '@angular/material/tooltip';
+import { MatProgressSpinner } from '@angular/material/progress-spinner';
+import { TranslatePipe } from '@ngx-translate/core';
+import { SpSimpleMetricsComponent } from 
'../../../../core-ui/monitoring/simple-metrics/simple-metrics.component';
+import { SpConfigurationRoutes } from 
'../../../../configuration/configuration.breadcrumb';
+import { catchError, finalize, forkJoin, map, Observable, of } from 'rxjs';
+import {
+    DailyEventCount,
+    DailyEventCountsChartComponent,
+} from './daily-event-counts-chart/daily-event-counts-chart.component';
+
+interface DayBucket extends DailyEventCount {
+    timestamp: number;
+    key: string;
+}
+
+@Component({
+    selector: 'sp-dataset-details-metrics',
+    templateUrl: './dataset-details-metrics.component.html',
+    styleUrls: ['./dataset-details-metrics.component.scss'],
+    imports: [
+        SpBasicNavTabsComponent,
+        LayoutDirective,
+        LayoutAlignDirective,
+        LayoutGapDirective,
+        FlexDirective,
+        MatIconButton,
+        MatTooltip,
+        MatProgressSpinner,
+        TranslatePipe,
+        SpSimpleMetricsComponent,
+        DailyEventCountsChartComponent,
+    ],
+})
+export class DatasetDetailsMetricsComponent
+    extends SpAbstractDatasetDetailsDirective
+    implements OnInit
+{
+    totalEventCount = 0;
+    lastEventTimestamp = 0;
+    loadingMetrics = false;
+    dailyEventCounts: DailyEventCount[] = [];
+
+    ngOnInit(): void {
+        super.onInit();
+    }
+
+    onDatasetLoaded(): void {
+        this.breadcrumbService.updateBreadcrumb([
+            SpConfigurationRoutes.BASE,
+            { label: 'Datasets', link: ['datasets'] },
+            { label: this.dataset.measureName },
+            { label: 'Metrics' },
+        ]);
+        this.triggerUpdate();
+    }
+
+    triggerUpdate(): void {
+        if (!this.dataset) {
+            return;
+        }
+
+        this.loadingMetrics = true;
+        const now = new Date();
+        const dayBuckets = this.makeLastSevenDayBuckets(now);
+
+        forkJoin({
+            totalEventCount: this.loadTotalEventCount(),
+            latestEventTimestamp: this.loadLatestEventTimestamp(),
+            dailyEventCounts: this.loadDailyEventCounts(dayBuckets, now),
+        })
+            .pipe(
+                finalize(() => {
+                    this.loadingMetrics = false;
+                }),
+            )
+            .subscribe(result => {
+                this.totalEventCount = result.totalEventCount;
+                this.lastEventTimestamp = result.latestEventTimestamp;
+                this.dailyEventCounts = result.dailyEventCounts;
+            });
+    }
+
+    private loadTotalEventCount(): Observable<number> {
+        return this.datalakeRestService
+            .getMeasurementEntryCount(this.dataset.elementId)
+            .pipe(catchError(() => of(0)));
+    }
+
+    private loadLatestEventTimestamp(): Observable<number> {
+        return this.datalakeRestService
+            .getLatestMeasurementEvents([this.dataset.measureName])
+            .pipe(
+                map(result => result[this.dataset.measureName] ?? 0),
+                catchError(() => of(0)),
+            );
+    }
+
+    private loadDailyEventCounts(
+        dayBuckets: DayBucket[],
+        now: Date,
+    ): Observable<DayBucket[]> {
+        const firstRuntimeName = this.getRuntimeNames()[0];
+        if (!firstRuntimeName) {
+            return of(dayBuckets);
+        }
+
+        return this.datalakeRestService
+            .getData(this.dataset.measureName, {
+                endDate: now.getTime(),
+                startDate: dayBuckets[0].timestamp,
+                order: 'ASC',
+                missingValueBehaviour: 'empty',
+                columns: firstRuntimeName,
+                aggregationFunction: 'COUNT',
+                timeInterval: '1d',
+                fill: 0,
+            })
+            .pipe(
+                map(result => this.normalizeDailyCounts(result, dayBuckets)),
+                catchError(() => of(dayBuckets)),
+            );
+    }
+
+    private normalizeDailyCounts(
+        result: SpQueryResult,
+        dayBuckets: DayBucket[],
+    ): DayBucket[] {
+        const countsByDay = new Map(
+            dayBuckets.map(bucket => [bucket.key, bucket.count]),
+        );
+
+        result?.allDataSeries?.forEach(series => {
+            const headers = result.headers?.length
+                ? result.headers
+                : series.headers;
+            const timestampIndex = this.getHeaderIndex(headers, 'time', 0);
+
+            series.rows?.forEach(row => {
+                const countIndex = this.getCountHeaderIndex(headers, row);
+                const key = this.toUtcDayKey(new Date(row[timestampIndex]));
+                if (countsByDay.has(key)) {
+                    countsByDay.set(
+                        key,
+                        (countsByDay.get(key) ?? 0) +
+                            this.toCount(row[countIndex]),
+                    );
+                }
+            });
+        });
+
+        return dayBuckets.map(bucket => ({
+            ...bucket,
+            count: countsByDay.get(bucket.key) ?? 0,
+        }));
+    }
+
+    private makeLastSevenDayBuckets(now: Date): DayBucket[] {
+        const startTimestamp = Date.UTC(
+            now.getUTCFullYear(),
+            now.getUTCMonth(),
+            now.getUTCDate() - 6,
+        );
+
+        return Array.from({ length: 7 }, (_value, index) => {
+            const timestamp = startTimestamp + index * 24 * 60 * 60 * 1000;
+            const date = new Date(timestamp);
+
+            return {
+                timestamp,
+                key: this.toUtcDayKey(date),
+                label: new Intl.DateTimeFormat(undefined, {
+                    month: 'short',
+                    day: 'numeric',
+                    timeZone: 'UTC',
+                }).format(date),
+                count: 0,
+            };
+        });
+    }
+
+    private getRuntimeNames(): string[] {
+        return (this.dataset.eventSchema?.eventProperties ?? []).map(
+            property => property.runtimeName,
+        );
+    }
+
+    private getHeaderIndex(
+        headers: string[] | undefined,
+        header: string,
+        fallback: number,
+    ): number {
+        const index = headers?.indexOf(header) ?? -1;
+        return index >= 0 ? index : fallback;
+    }
+
+    private getCountHeaderIndex(
+        headers: string[] | undefined,
+        row: unknown[],
+    ): number {
+        const countIndex =
+            headers?.findIndex(
+                header => header === 'count' || header.startsWith('count_'),
+            ) ?? -1;
+        return countIndex >= 0 ? countIndex : Math.min(row.length - 1, 1);
+    }
+
+    private toCount(value: unknown): number {
+        const count = Number(value);
+        return Number.isFinite(count) ? count : 0;
+    }
+
+    private toUtcDayKey(date: Date): string {
+        const year = date.getUTCFullYear();
+        const month = String(date.getUTCMonth() + 1).padStart(2, '0');
+        const day = String(date.getUTCDate()).padStart(2, '0');
+        return `${year}-${month}-${day}`;
+    }
+}
diff --git 
a/ui/src/app/dataset/components/dataset-details/dataset-details-tabs.ts 
b/ui/src/app/dataset/components/dataset-details/dataset-details-tabs.ts
index db4637ac90..92e29c31a8 100644
--- a/ui/src/app/dataset/components/dataset-details/dataset-details-tabs.ts
+++ b/ui/src/app/dataset/components/dataset-details/dataset-details-tabs.ts
@@ -26,6 +26,11 @@ export class SpDatasetDetailsTabs {
                 itemTitle: 'Event schema',
                 itemLink: ['datasets', elementId, 'schema'],
             },
+            {
+                itemId: 'metrics',
+                itemTitle: 'Metrics',
+                itemLink: ['datasets', elementId, 'metrics'],
+            },
             {
                 itemId: 'events',
                 itemTitle: 'Latest events',
diff --git a/ui/src/app/dataset/dataset.routes.ts 
b/ui/src/app/dataset/dataset.routes.ts
index b1d8417bb0..932cf1e26d 100644
--- a/ui/src/app/dataset/dataset.routes.ts
+++ b/ui/src/app/dataset/dataset.routes.ts
@@ -20,6 +20,7 @@ import { Routes } from '@angular/router';
 import { DatalakeConfigurationComponent } from 
'./components/datalake-configuration/datalake-configuration.component';
 import { DatasetDetailsSchemaComponent } from 
'./components/dataset-details/dataset-details-schema/dataset-details-schema.component';
 import { DatasetDetailsEventsComponent } from 
'./components/dataset-details/dataset-details-events/dataset-details-events.component';
+import { DatasetDetailsMetricsComponent } from 
'./components/dataset-details/dataset-details-metrics/dataset-details-metrics.component';
 
 export const DATASET_ROUTES: Routes = [
     {
@@ -42,6 +43,10 @@ export const DATASET_ROUTES: Routes = [
                         path: 'schema',
                         component: DatasetDetailsSchemaComponent,
                     },
+                    {
+                        path: 'metrics',
+                        component: DatasetDetailsMetricsComponent,
+                    },
                     {
                         path: 'events',
                         component: DatasetDetailsEventsComponent,

Reply via email to