diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index 61240d304e..1f7a3ab5f6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.settings.UserSettings; import org.thingsboard.server.common.data.settings.UserSettingsType; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.mobile.QrCodeSettingService; import org.thingsboard.server.dao.trendz.TrendzSettingsService; @@ -52,6 +53,7 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; @Hidden @@ -76,6 +78,11 @@ public class SystemInfoController extends BaseController { @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; + @Value("${sql.entity_data_query_nulls_order_strategy:default}") + private String nullsOrderStrategy; + + private static final Set ACCEPTED_NULLS_ORDER_STRATEGIES = Set.of("default", "nulls_first", "nulls_last"); + @Autowired(required = false) private BuildProperties buildProperties; @@ -91,6 +98,9 @@ public class SystemInfoController extends BaseController { @Autowired private TrendzSettingsService trendzSettingsService; + @Autowired + private EdqsService edqsService; + @PostConstruct public void init() { JsonNode info = buildInfoObject(); @@ -150,6 +160,8 @@ public class SystemInfoController extends BaseController { } systemParams.setUserSettings(userSettingsNode); systemParams.setMaxDatapointsLimit(maxDatapointsLimit); + systemParams.setNullsOrderStrategy(ACCEPTED_NULLS_ORDER_STRATEGIES.contains(nullsOrderStrategy) ? nullsOrderStrategy : "default"); + systemParams.setEdqsEnabled(edqsService.isApiEnabled()); if (!currentUser.isSystemAdmin()) { DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration(); systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize()); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index c31b49325e..527ee2f18d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -39,4 +39,6 @@ public class SystemParams { long maxArgumentsPerCF; long maxDataPointsPerRollingArg; TrendzSettings trendzSettings; + String nullsOrderStrategy; + boolean edqsEnabled; } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index 63f2247aba..82b7981f8f 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -27,7 +27,6 @@ import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.queue.util.TbTransportComponent; -import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -165,7 +164,6 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis static class CertificateWatcher { private final List paths; private final Runnable reloadCallback; - private final Map lastModifiedMap; private final Map lastChecksumMap; private int consecutiveFailures; private String failedCombinedChecksum; @@ -173,60 +171,23 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis CertificateWatcher(List paths, Runnable reloadCallback) { this.paths = paths; this.reloadCallback = reloadCallback; - this.lastModifiedMap = new HashMap<>(); this.lastChecksumMap = new HashMap<>(); for (Path path : paths) { - lastModifiedMap.put(path, getLastModifiedTime(path)); lastChecksumMap.put(path, calculateChecksum(path)); } this.consecutiveFailures = 0; } synchronized void checkAndReload(String name) { - boolean anyModifiedChanged = false; - for (Path path : paths) { - long currentModified = getLastModifiedTime(path); - Long lastModified = lastModifiedMap.getOrDefault(path, 0L); - if (currentModified != lastModified) { - anyModifiedChanged = true; - break; - } - } - if (!anyModifiedChanged) { - return; - } - - // Capture mtimes and checksums together before the callback runs. - // Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll. - Map currentModifiedTimes = new HashMap<>(); Map currentChecksums = new HashMap<>(); - StringBuilder combined = new StringBuilder(); for (Path path : paths) { - currentModifiedTimes.put(path, getLastModifiedTime(path)); - String checksum = calculateChecksum(path); - currentChecksums.put(path, checksum); - if (!combined.isEmpty()) { - combined.append("|"); - } - combined.append(path).append("=").append(checksum); + currentChecksums.put(path, calculateChecksum(path)); } - String combinedChecksum = combined.toString(); - - // Build old combined checksum for comparison - StringBuilder oldCombined = new StringBuilder(); - for (Path path : paths) { - if (!oldCombined.isEmpty()) { - oldCombined.append("|"); - } - oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, "")); - } - String oldCombinedChecksum = oldCombined.toString(); + String combinedChecksum = combinedChecksum(currentChecksums); + String oldCombinedChecksum = combinedChecksum(lastChecksumMap); if (combinedChecksum.equals(oldCombinedChecksum)) { - // Content unchanged, just update modification times - for (Path path : paths) { - lastModifiedMap.put(path, currentModifiedTimes.get(path)); - } + // Content unchanged return; } @@ -237,41 +198,34 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis } if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { - // Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle - for (Path path : paths) { - lastModifiedMap.put(path, currentModifiedTimes.get(path)); - } return; } try { log.info("Certificate change detected for: {}. Triggering reload...", name); reloadCallback.run(); - for (Path path : paths) { - lastModifiedMap.put(path, currentModifiedTimes.get(path)); - lastChecksumMap.put(path, currentChecksums.get(path)); - } + lastChecksumMap.putAll(currentChecksums); consecutiveFailures = 0; failedCombinedChecksum = null; } catch (Exception e) { consecutiveFailures++; failedCombinedChecksum = combinedChecksum; - // Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries - // (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum). + // Deliberately NOT updating lastChecksumMap here, so the next poll cycle still sees a differing + // checksum, re-enters this method, and retries the same content. log.error("Failed to reload certificate for {} (attempt {}/{}): {}", name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); } } - private long getLastModifiedTime(Path path) { - try { - if (!Files.exists(path)) { - return 0; + private String combinedChecksum(Map checksums) { + StringBuilder combined = new StringBuilder(); + for (Path path : paths) { + if (!combined.isEmpty()) { + combined.append("|"); } - return Files.getLastModifiedTime(path).toMillis(); - } catch (IOException e) { - return 0; + combined.append(path).append("=").append(checksums.getOrDefault(path, "")); } + return combined.toString(); } private String calculateChecksum(Path path) { diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java index 6f78eaefae..8894937177 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -31,10 +31,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; public class CertificateReloadManagerTest { @@ -59,11 +56,10 @@ public class CertificateReloadManagerTest { } } - private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException { + private void writeFileAndBumpMtime(Path path, String content, long baselineMtime) throws IOException { Files.writeString(path, content); - await().atMost(2, SECONDS) - .pollInterval(10, MILLISECONDS) - .until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime); + // Force a strictly newer mtime: back-to-back writes can share a millisecond, hiding the change from the watcher. + Files.setLastModifiedTime(path, FileTime.fromMillis(baselineMtime + 1000)); } private long mtime(Path path) throws IOException { @@ -77,7 +73,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -85,7 +81,7 @@ public class CertificateReloadManagerTest { } @Test - public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() { AtomicInteger reloadCount = new AtomicInteger(0); certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); @@ -147,7 +143,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet); long baseline = mtime(keyFile); - writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); + writeFileAndBumpMtime(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -168,8 +164,8 @@ public class CertificateReloadManagerTest { long baseline1 = mtime(certFile); long baseline2 = mtime(cert2File); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); - writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndBumpMtime(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -191,8 +187,8 @@ public class CertificateReloadManagerTest { long baseline1 = mtime(certFile); long baseline2 = mtime(cert2File); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); - writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndBumpMtime(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -224,9 +220,7 @@ public class CertificateReloadManagerTest { for (int i = 0; i < 5; i++) { Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); } - await().atMost(2, SECONDS) - .pollInterval(10, MILLISECONDS) - .until(() -> mtime(certFile) != baseline); + Files.setLastModifiedTime(certFile, FileTime.fromMillis(baseline + 1000)); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -242,7 +236,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); for (int i = 0; i < 5; i++) { new Thread(() -> { @@ -272,7 +266,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, originalContent, baseline); + writeFileAndBumpMtime(certFile, originalContent, baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -289,7 +283,7 @@ public class CertificateReloadManagerTest { }); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); for (int i = 0; i < 15; i++) { ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -311,19 +305,41 @@ public class CertificateReloadManagerTest { }); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(1); shouldFail.set(0); long baseline2 = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(2); } + @Test + public void givenContentChangedButMtimeUnchanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + // Bug fingerprint: a cert-manager rotation that lands in the same wall-clock millisecond as the + // watcher's recorded baseline mtime. Files.getLastModifiedTime().toMillis() truncates to the ms, + // so the rotated content shares the baseline mtime and an mtime-only gate would never re-hash it. + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nROTATED_SAME_MS\n-----END CERTIFICATE-----\n"); + // Force the mtime back to the exact baseline millisecond — content changed, timestamp did not. + Files.setLastModifiedTime(certFile, FileTime.fromMillis(baseline)); + + // Sanity guard: the watcher observes a timestamp identical to its recorded baseline. + assertThat(mtime(certFile)).isEqualTo(baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(1); + } + @Test public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception { AtomicInteger reloadAttempts = new AtomicInteger(0); @@ -337,7 +353,7 @@ public class CertificateReloadManagerTest { }); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); for (int i = 0; i < 15; i++) { ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -346,7 +362,7 @@ public class CertificateReloadManagerTest { shouldFail.set(0); long baseline2 = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(11); diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 344c44f6c7..2485783853 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -17,6 +17,7 @@ import { AuthUser, User } from '@shared/models/user.model'; import { UserSettings } from '@shared/models/user-settings.models'; import { TrendzSettings } from '@shared/models/trendz-settings.models'; +import { NullsOrderStrategy } from '@shared/models/page/page-link'; export interface SysParamsState { userTokenAccessEnabled: boolean; @@ -34,6 +35,8 @@ export interface SysParamsState { ruleChainDebugPerTenantLimitsConfiguration?: string; calculatedFieldDebugPerTenantLimitsConfiguration?: string; trendzSettings: TrendzSettings; + nullsOrderStrategy: NullsOrderStrategy; + edqsEnabled: boolean; } export interface SysParams extends SysParamsState { diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index ca46344bbb..263b182cb9 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -36,7 +36,9 @@ const emptyUserAuthState: AuthPayload = { maxDataPointsPerRollingArg: 0, maxDebugModeDurationMinutes: 0, userSettings: initialUserSettings, - trendzSettings: initialTrendzSettings + trendzSettings: initialTrendzSettings, + nullsOrderStrategy: 'default', + edqsEnabled: false }; export const initialState: AuthState = { diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index 1c6ac90f3b..744cb92534 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -20,6 +20,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable, of, ReplaySubject, throwError } from 'rxjs'; import { catchError, map, mergeMap, tap } from 'rxjs/operators'; +import { setEdqsEnabled, setNullsOrderStrategy } from '@shared/models/page/page-link'; import { LoginRequest, LoginResponse, PublicLoginRequest } from '@shared/models/login.models'; import { Router, UrlTree } from '@angular/router'; @@ -441,6 +442,8 @@ export class AuthService { return this.http.get('/api/system/params', defaultHttpOptions()).pipe( map((sysParams) => { this.timeService.setMaxDatapointsLimit(sysParams.maxDatapointsLimit); + setNullsOrderStrategy(sysParams.nullsOrderStrategy); + setEdqsEnabled(sysParams.edqsEnabled); return sysParams; }), catchError(() => of({} as SysParamsState)) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts index 31927e0cb3..711107895f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts @@ -47,7 +47,7 @@ import { isUndefined } from '@core/utils'; import cssjs from '@core/css/css'; -import { sortItems } from '@shared/models/page/page-link'; +import { SortColumnType, sortItems } from '@shared/models/page/page-link'; import { Direction } from '@shared/models/page/sort-order'; import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections'; import { BehaviorSubject, forkJoin, fromEvent, merge, Observable, of, Subject, Subscription } from 'rxjs'; @@ -118,6 +118,7 @@ import { dataKeyToEntityKey, dataKeyTypeToEntityKeyType, entityDataPageLinkSortDirection, + EntityKeyType, KeyFilter } from '@app/shared/models/query/query.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; @@ -730,8 +731,12 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, this.pageLink.sortOrder = null; } const sortOrderLabel = fromEntityColumnDef(this.sort.active, this.columns); + const sortColumnType: SortColumnType = key + ? (key.type === EntityKeyType.ENTITY_FIELD || key.type === EntityKeyType.ALARM_FIELD ? 'entityField' + : key.type === EntityKeyType.TIME_SERIES ? 'timeseries' : 'attribute') + : 'entityField'; const keyFilters: KeyFilter[] = null; // TODO: - this.alarmsDatasource.loadAlarms(this.pageLink, sortOrderLabel, keyFilters); + this.alarmsDatasource.loadAlarms(this.pageLink, sortOrderLabel, sortColumnType, keyFilters); this.ctx.detectChanges(); } @@ -1256,6 +1261,7 @@ class AlarmsDatasource implements DataSource { private appliedPageLink: AlarmDataPageLink; private appliedSortOrderLabel: string; + private appliedSortColumnType: SortColumnType = 'entityField'; private reserveSpaceForHiddenAction = true; private cellButtonActions: TableCellButtonActionDescriptor[]; @@ -1294,11 +1300,13 @@ class AlarmsDatasource implements DataSource { this.pageDataSubject.complete(); } - loadAlarms(pageLink: AlarmDataPageLink, sortOrderLabel: string, keyFilters: KeyFilter[]) { + loadAlarms(pageLink: AlarmDataPageLink, sortOrderLabel: string, + sortColumnType: SortColumnType, keyFilters: KeyFilter[]) { this.dataLoading = true; // this.clear(); this.appliedPageLink = pageLink; this.appliedSortOrderLabel = sortOrderLabel; + this.appliedSortColumnType = sortColumnType; this.subscription.subscribeForAlarms(pageLink, keyFilters); } @@ -1330,7 +1338,7 @@ class AlarmsDatasource implements DataSource { } if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) { const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC; - alarms = alarms.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc)); + alarms = alarms.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc, this.appliedSortColumnType)); } if (this.selection.hasValue()) { const alarmIds = alarms.map((alarm) => alarm.id.id); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index b99ba01212..8bd5f07896 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -100,7 +100,7 @@ import { EntityKeyType, KeyFilter } from '@shared/models/query/query.models'; -import { sortItems } from '@shared/models/page/page-link'; +import { SortColumnType, sortItems } from '@shared/models/page/page-link'; import { entityFields } from '@shared/models/entity.models'; import { DatePipe } from '@angular/common'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; @@ -617,8 +617,12 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni this.pageLink.sortOrder = null; } const sortOrderLabel = fromEntityColumnDef(this.sort.active, this.columns); + const sortColumnType: SortColumnType = key + ? (key.type === EntityKeyType.ENTITY_FIELD ? 'entityField' + : key.type === EntityKeyType.TIME_SERIES ? 'timeseries' : 'attribute') + : 'entityField'; const keyFilters: KeyFilter[] = null; // TODO: - this.entityDatasource.loadEntities(this.pageLink, sortOrderLabel, keyFilters); + this.entityDatasource.loadEntities(this.pageLink, sortOrderLabel, sortColumnType, keyFilters); this.ctx.detectChanges(); } @@ -865,6 +869,7 @@ class EntityDatasource implements DataSource { private appliedPageLink: EntityDataPageLink; private appliedSortOrderLabel: string; + private appliedSortColumnType: SortColumnType = 'entityField'; private reserveSpaceForHiddenAction = true; private cellButtonActions: TableCellButtonActionDescriptor[]; @@ -905,11 +910,13 @@ class EntityDatasource implements DataSource { this.pageDataSubject.complete(); } - loadEntities(pageLink: EntityDataPageLink, sortOrderLabel: string, keyFilters: KeyFilter[]) { + loadEntities(pageLink: EntityDataPageLink, sortOrderLabel: string, + sortColumnType: SortColumnType, keyFilters: KeyFilter[]) { this.dataLoading = true; // this.clear(); this.appliedPageLink = pageLink; this.appliedSortOrderLabel = sortOrderLabel; + this.appliedSortColumnType = sortColumnType; this.subscription.subscribeForPaginatedData(0, pageLink, keyFilters); } @@ -934,7 +941,7 @@ class EntityDatasource implements DataSource { }); if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) { const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC; - entities = entities.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc)); + entities = entities.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc, this.appliedSortColumnType)); } if (!dynamicWidthCellButtonActions && this.cellButtonActions.length && entities.length) { maxCellButtonAction = entities[0].actionCellButtons.length; diff --git a/ui-ngx/src/app/shared/models/page/page-link.ts b/ui-ngx/src/app/shared/models/page/page-link.ts index eb2c059c2a..f3837a5cc5 100644 --- a/ui-ngx/src/app/shared/models/page/page-link.ts +++ b/ui-ngx/src/app/shared/models/page/page-link.ts @@ -22,6 +22,19 @@ import { EntitiesTableAction } from '@home/models/entity/entity-table-component. export const MAX_SAFE_PAGE_SIZE = 2147483647; +export type NullsOrderStrategy = 'default' | 'nulls_first' | 'nulls_last'; + +let nullsOrderStrategy: NullsOrderStrategy = 'default'; +let edqsEnabled = false; + +export function setNullsOrderStrategy(value: NullsOrderStrategy): void { + nullsOrderStrategy = value ?? 'default'; +} + +export function setEdqsEnabled(value: boolean): void { + edqsEnabled = !!value; +} + export type PageLinkSearchFunction = (entity: T, textSearch: string, searchProperty?: string) => boolean; export interface PageQueryParam extends Partial{ @@ -73,9 +86,37 @@ const defaultPageLinkSearch: PageLinkSearchFunction = return false; }; -export function sortItems(item1: any, item2: any, property: string, asc: boolean): number { +export type SortColumnType = 'entityField' | 'attribute' | 'timeseries'; + +export function sortItems(item1: any, item2: any, property: string, asc: boolean, + columnType: SortColumnType = 'entityField'): number { const item1Value = getDescendantProp(item1, property); const item2Value = getDescendantProp(item2, property); + const item1Empty = item1Value === null || item1Value === undefined || item1Value === ''; + const item2Empty = item2Value === null || item2Value === undefined || item2Value === ''; + // Mirror backend's nulls ordering. EDQS uses fixed NULLS FIRST regardless of strategy and + // naive compare below already matches it, so skip this branch when EDQS is on. + // For entityField columns the ORDER BY hits a real nullable DB column → strategy always applies. + // For attribute/timeseries the strategy only applies to numeric/boolean values; string/json + // are coalesced to '' on the backend, so naive compare below already matches its order. + if (!edqsEnabled && (item1Empty || item2Empty) && !(item1Empty && item2Empty)) { + let applyStrategy = columnType === 'entityField'; + if (!applyStrategy) { + const other = item1Empty ? item2Value : item1Value; + applyStrategy = + typeof other === 'boolean' || other === 'true' || other === 'false' || + (typeof other === 'number' && isFinite(other)) || + (typeof other === 'string' && other.trim() !== '' && !isNaN(Number(other))); + } + if (applyStrategy) { + const nullsFirst = nullsOrderStrategy === 'nulls_first' + || (nullsOrderStrategy === 'default' && !asc); + if (item1Empty) { + return nullsFirst ? -1 : 1; + } + return nullsFirst ? 1 : -1; + } + } let result = 0; if (item1Value !== item2Value) { const item1Type = typeof item1Value; diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index e9564f5279..6b5f48283b 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -987,14 +987,14 @@ "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-modules-systemjs@^7.27.1": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz#7439e592a92d7670dfcb95d0cbc04bd3e64801d2" - integrity sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew== + version "7.29.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20" + integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w== dependencies: - "@babel/helper-module-transforms" "^7.28.3" - "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" "@babel/helper-validator-identifier" "^7.28.5" - "@babel/traverse" "^7.28.5" + "@babel/traverse" "^7.29.0" "@babel/plugin-transform-modules-umd@^7.27.1": version "7.27.1" @@ -1298,7 +1298,7 @@ "@babel/parser" "^7.28.6" "@babel/types" "^7.28.6" -"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6": +"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== @@ -4349,17 +4349,17 @@ boolbase@^1.0.0: integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== brace-expansion@^1.1.7: - version "1.1.13" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" - integrity sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w== + version "1.1.14" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.14.tgz#d9de602370d91347cd9ddad1224d4fd701eb348b" + integrity sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^5.0.2: - version "5.0.5" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" - integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + version "5.0.6" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285" + integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== dependencies: balanced-match "^4.0.2" @@ -6113,9 +6113,9 @@ fast-levenshtein@^2.0.6: integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fast-uri@^3.0.1: - version "3.1.0" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" - integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== + version "3.1.2" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" + integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== fastq@^1.6.0: version "1.17.1"