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,