UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

1 lines • 77.5 kB
{"version":3,"file":"c8y-ngx-components-widgets-implementations-datapoints-list.mjs","sources":["../../widgets/implementations/datapoints-list/datapoints-list.constants.ts","../../widgets/implementations/datapoints-list/datapoints-list-view/datapoints-list-view.service.ts","../../widgets/implementations/datapoints-list/datapoints-list-view/datapoints-list-fetch.service.ts","../../widgets/implementations/datapoints-list/datapoints-list-view/datapoints-list-view.component.ts","../../widgets/implementations/datapoints-list/datapoints-list-view/datapoints-list-view.component.html","../../widgets/implementations/datapoints-list/datapoints-list-config/datapoints-list-config.component.ts","../../widgets/implementations/datapoints-list/datapoints-list-config/datapoints-list-config.component.html","../../widgets/implementations/datapoints-list/c8y-ngx-components-widgets-implementations-datapoints-list.ts"],"sourcesContent":["import {\n GLOBAL_CONTEXT_DISPLAY_MODE,\n GlobalContextState,\n REFRESH_OPTION,\n TIME_INTERVAL\n} from '@c8y/ngx-components/global-context';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport { ColumnConfig } from './datapoints-list-widget.model';\n\n/**\n * Default column configuration for datapoints list widget\n */\nexport const DEFAULT_DATAPOINTS_LIST_COLUMNS: ColumnConfig[] = [\n { id: 'kpi', label: gettext('Label'), visible: true, order: 0 },\n { id: 'target', label: gettext('Target'), visible: true, order: 1 },\n { id: 'current', label: gettext('Current'), visible: true, order: 2 },\n { id: 'diff', label: gettext('Diff'), visible: true, order: 3 },\n { id: 'diffPercentage', label: gettext('Diff %'), visible: true, order: 4 },\n { id: 'asset', label: gettext('Asset'), visible: true, order: 5 }\n];\n\n/**\n * Defaults for legacy AngularJS datapoints list widgets that arrive without any\n * time-context fields. Mirrors the legacy \"show all data\" behavior. Factory so\n * `dateTo` is read at call time.\n */\nexport function getLegacyDatapointsListDefaults(): Partial<GlobalContextState> {\n return {\n displayMode: GLOBAL_CONTEXT_DISPLAY_MODE.CONFIG,\n dateTimeContext: {\n dateFrom: '1970-01-01T00:00:00.000Z',\n dateTo: new Date().toISOString(),\n interval: TIME_INTERVAL.CUSTOM\n },\n isAutoRefreshEnabled: true,\n refreshOption: REFRESH_OPTION.LIVE\n };\n}\n","import { Injectable } from '@angular/core';\nimport { IMeasurement } from '@c8y/client';\nimport { KPIDetails } from '@c8y/ngx-components/datapoint-selector';\nimport { DatapointWithMeasurement } from '../datapoints-list-widget.model';\n\n@Injectable({\n providedIn: 'root'\n})\nexport class DatapointsListService {\n /**\n * Calculate difference between current value and target\n * @param datapoint - Datapoint record\n * @returns Difference value or null if value/target is undefined\n */\n diff(datapoint: DatapointWithMeasurement): number | null {\n const { currentValue, target } = datapoint;\n // != checks both null and undefined\n if (currentValue != null && target != null) {\n return currentValue - target;\n }\n return null;\n }\n\n /**\n * Calculate percentage difference between current value and target\n * @param datapoint - Datapoint record\n * @returns Percentage difference or null if target is undefined\n */\n diffPercent(datapoint: DatapointWithMeasurement): number | null {\n const target = datapoint.target;\n\n if (target !== null && target !== undefined) {\n const _diff = this.diff(datapoint);\n if (_diff !== null) {\n // Intentionally allows division by zero to return Infinity\n // when target is 0, representing mathematically undefined percentage.\n // This follows the previous AngularJS implementation behavior.\n return (_diff / target) * 100;\n }\n }\n\n return null;\n }\n\n /**\n * Get fraction size format based on whether the value is an integer\n * @param value - Number to check\n * @param defaultFractionSize - Default fraction size format to use for non-integers\n * @returns Fraction size format ('1.0-0' for integers, defaultFractionSize for decimals)\n */\n getFractionSize(value: number | null | undefined, defaultFractionSize: string): string {\n if (value === null || value === undefined) {\n return defaultFractionSize;\n }\n return value % 1 === 0 ? '1.0-0' : defaultFractionSize;\n }\n\n /**\n * Extract current value and timestamp from a measurement\n * @param datapoint - Datapoint configuration (contains fragment and series)\n * @param measurement - Measurement to extract value from\n * @returns Object containing extracted value and timestamp (null if not found)\n */\n extractMeasurementValue(\n datapoint: KPIDetails,\n measurement: IMeasurement | undefined\n ): { value: number | null; timestamp: Date | null } {\n // Return null values if measurement is missing\n if (!measurement) {\n return { value: null, timestamp: null };\n }\n\n // Return null values if datapoint configuration is incomplete\n if (!datapoint.fragment || !datapoint.series) {\n return { value: null, timestamp: null };\n }\n\n // Access the fragment (e.g., \"c8y_Steam\")\n const fragmentData = measurement[datapoint.fragment];\n if (!fragmentData) {\n return { value: null, timestamp: null };\n }\n\n // Access the series within the fragment (e.g., \"Temperature\")\n const seriesData = fragmentData[datapoint.series];\n if (!seriesData || seriesData.value === undefined) {\n return { value: null, timestamp: null };\n }\n\n // Extract and return the value and timestamp\n return {\n value: seriesData.value,\n timestamp: new Date(measurement.time)\n };\n }\n}\n","import { HttpErrorResponse } from '@angular/common/http';\nimport { inject, Injectable } from '@angular/core';\nimport {\n IManagedObject,\n IMeasurement,\n IMeasurementFilter,\n InventoryService,\n MeasurementService\n} from '@c8y/client';\nimport { AlertService } from '@c8y/ngx-components';\nimport { KPIDetails } from '@c8y/ngx-components/datapoint-selector';\nimport { DatapointWithMeasurement } from '../datapoints-list-widget.model';\nimport { DatapointsListService } from './datapoints-list-view.service';\n\ninterface FetchConfig {\n fractionSize: string;\n dateFrom?: string;\n dateTo?: string;\n}\n\ninterface FetchResult {\n dataPoints: DatapointWithMeasurement[];\n seriesWithoutPermissionCount: number;\n targetManagedObjects: Map<string, IManagedObject>;\n}\n\ninterface SingleDatapointResult {\n datapoint: DatapointWithMeasurement;\n hasPermissionError: boolean;\n}\n\n/**\n * Service that handles all data-fetching operations for the datapoints list widget.\n * Encapsulates measurement fetching, data enrichment, and error handling.\n */\n@Injectable({\n providedIn: 'root'\n})\nexport class DatapointsListFetchService {\n private alertService = inject(AlertService);\n private measurementService = inject(MeasurementService);\n private inventoryService = inject(InventoryService);\n private datapointsListService = inject(DatapointsListService);\n\n /**\n * Fetch measurements for all active datapoints and enrich them with calculated values.\n */\n async fetchDatapointsWithMeasurements(\n datapoints: KPIDetails[],\n config: FetchConfig\n ): Promise<FetchResult> {\n const targetManagedObjects = new Map<string, IManagedObject>();\n\n const results = await this.fetchAllDatapoints(datapoints, config);\n\n const enrichedDataPoints = results.map(r => r.datapoint);\n const seriesWithoutPermissionCount = results.filter(r => r.hasPermissionError).length;\n\n await this.fetchTargetManagedObjects(enrichedDataPoints, targetManagedObjects);\n\n return {\n dataPoints: enrichedDataPoints,\n seriesWithoutPermissionCount,\n targetManagedObjects\n };\n }\n\n /**\n * Fetch and enrich all datapoints in parallel.\n * Each datapoint fetch is independent - one failure doesn't affect others.\n */\n private fetchAllDatapoints(\n datapoints: KPIDetails[],\n config: FetchConfig\n ): Promise<SingleDatapointResult[]> {\n return Promise.all(\n datapoints.map((datapoint, index) => this.fetchSingleDatapoint(datapoint, index, config))\n );\n }\n\n /**\n * Fetch measurement for a single datapoint and return enriched result.\n * Handles errors gracefully - returns empty datapoint on failure.\n */\n private async fetchSingleDatapoint(\n datapoint: KPIDetails,\n index: number,\n config: FetchConfig\n ): Promise<SingleDatapointResult> {\n try {\n const measurement = await this.getMeasurementForDatapoint(datapoint, config);\n const measurementValue = this.datapointsListService.extractMeasurementValue(\n datapoint,\n measurement\n );\n\n return {\n datapoint: this.createEnrichedDatapoint(datapoint, index, measurementValue, config),\n hasPermissionError: false\n };\n } catch (error) {\n return this.handleFetchError(error, datapoint, index, config);\n }\n }\n\n /**\n * Handle fetch error and return appropriate result.\n */\n private handleFetchError(\n error: unknown,\n datapoint: KPIDetails,\n index: number,\n config: FetchConfig\n ): SingleDatapointResult {\n const isPermissionError = (error as HttpErrorResponse)?.status === 403;\n\n if (!isPermissionError) {\n this.alertService.addServerFailure(error);\n }\n\n return {\n datapoint: this.createEnrichedDatapoint(\n datapoint,\n index,\n { value: null, timestamp: null },\n config\n ),\n hasPermissionError: isPermissionError\n };\n }\n\n /**\n * Create an enriched datapoint with measurement value and all calculated fields.\n */\n private createEnrichedDatapoint(\n datapoint: KPIDetails,\n index: number,\n measurementValue: { value: number | null; timestamp: Date | null },\n config: FetchConfig\n ): DatapointWithMeasurement {\n const enrichedDatapoint: DatapointWithMeasurement = {\n ...datapoint,\n id: this.getDatapointId(datapoint, index),\n currentValue: measurementValue.value,\n timestamp: measurementValue.timestamp\n };\n\n this.calculateDerivedFields(enrichedDatapoint, config.fractionSize);\n\n return enrichedDatapoint;\n }\n\n /**\n * Calculate and set all derived fields on a datapoint.\n * Includes diff, diffPercent, and fraction sizes for display formatting.\n */\n private calculateDerivedFields(datapoint: DatapointWithMeasurement, fractionSize: string): void {\n datapoint.diffValue = this.datapointsListService.diff(datapoint);\n datapoint.diffPercentValue = this.datapointsListService.diffPercent(datapoint);\n datapoint.currentFractionSize = this.datapointsListService.getFractionSize(\n datapoint.currentValue,\n fractionSize\n );\n datapoint.diffFractionSize = this.datapointsListService.getFractionSize(\n datapoint.diffValue,\n fractionSize\n );\n datapoint.diffPercentFractionSize = this.datapointsListService.getFractionSize(\n datapoint.diffPercentValue,\n fractionSize\n );\n }\n\n /**\n * Fetch managed objects for device status display.\n */\n private async fetchTargetManagedObjects(\n dataPoints: DatapointWithMeasurement[],\n targetManagedObjects: Map<string, IManagedObject>\n ): Promise<void> {\n const uniqueTargetIds = this.getUniqueTargetIds(dataPoints);\n\n if (uniqueTargetIds.size === 0) {\n return;\n }\n\n await Promise.all(\n Array.from(uniqueTargetIds).map(targetId =>\n this.fetchManagedObject(targetId, targetManagedObjects)\n )\n );\n }\n\n /**\n * Get unique target IDs from datapoints.\n */\n private getUniqueTargetIds(dataPoints: DatapointWithMeasurement[]): Set<string> {\n const uniqueTargetIds = new Set<string>();\n\n for (const dp of dataPoints) {\n if (dp.__target?.id) {\n uniqueTargetIds.add(dp.__target.id.toString());\n }\n }\n\n return uniqueTargetIds;\n }\n\n /**\n * Fetch a single managed object and add to the map.\n */\n private async fetchManagedObject(\n targetId: string,\n targetManagedObjects: Map<string, IManagedObject>\n ): Promise<void> {\n try {\n const { data } = await this.inventoryService.detail(targetId);\n targetManagedObjects.set(targetId, data);\n } catch (error) {\n this.alertService.addServerFailure(error);\n }\n }\n\n /**\n * Fetch the most recent measurement for a datapoint within the specified date range.\n */\n private async getMeasurementForDatapoint(\n datapoint: KPIDetails,\n config: FetchConfig\n ): Promise<IMeasurement | null> {\n const sourceId = datapoint.__target?.id;\n if (!sourceId || !datapoint.fragment || !datapoint.series) {\n return null;\n }\n\n const filter: IMeasurementFilter = {\n dateFrom: config.dateFrom || '1970-01-01',\n dateTo: config.dateTo || new Date().toISOString(),\n source: sourceId,\n valueFragmentSeries: datapoint.series,\n valueFragmentType: datapoint.fragment,\n pageSize: 1,\n revert: true\n };\n\n const { data } = await this.measurementService.list(filter);\n return data?.[0] ?? null;\n }\n\n /**\n * Get unique ID for a datapoint.\n * Uses target ID if available, otherwise generates fallback based on index.\n */\n private getDatapointId(datapoint: KPIDetails, fallbackIndex: number): string {\n return datapoint.__target?.id?.toString() || `dp-${fallbackIndex}`;\n }\n}\n","import { DecimalPipe, NgTemplateOutlet } from '@angular/common';\nimport {\n ChangeDetectionStrategy,\n Component,\n computed,\n inject,\n input,\n linkedSignal,\n OnInit,\n signal\n} from '@angular/core';\nimport { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';\nimport { Router } from '@angular/router';\nimport { IManagedObject, InventoryService } from '@c8y/client';\nimport {\n AlertService,\n ApplyRangeClassPipe,\n C8yTranslateDirective,\n C8yTranslatePipe,\n ColorRangeBoundaries,\n DashboardChildComponent,\n DatePipe,\n DeviceStatusComponent,\n DynamicComponentAlert,\n DynamicComponentAlertAggregator,\n DynamicComponentModule,\n EmptyStateComponent,\n ForOfDirective,\n GroupService,\n GuideDocsComponent,\n GuideHrefDirective,\n IconDirective,\n ListGroupModule,\n LoadingComponent,\n VirtualScrollListenerDirective,\n WidgetActionWrapperComponent\n} from '@c8y/ngx-components';\nimport { KPIDetails } from '@c8y/ngx-components/datapoint-selector';\nimport {\n DatapointsExportSelectorComponent,\n ExportConfig\n} from '@c8y/ngx-components/datapoints-export-selector';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport {\n CONTEXT_FEATURE,\n DisplayMode,\n GLOBAL_CONTEXT_DISPLAY_MODE,\n GlobalContextConnectorComponent,\n GlobalContextState,\n LocalControlsComponent,\n PRESET_NAME,\n PresetName,\n REFRESH_OPTION,\n WidgetConfigMigrationService\n} from '@c8y/ngx-components/global-context';\nimport { isEqual, merge } from 'lodash-es';\nimport { pairwise } from 'rxjs';\nimport { DatapointsListConfig, DatapointWithMeasurement } from '../datapoints-list-widget.model';\nimport {\n DEFAULT_DATAPOINTS_LIST_COLUMNS,\n getLegacyDatapointsListDefaults\n} from '../datapoints-list.constants';\nimport { DatapointsListFetchService } from './datapoints-list-fetch.service';\n\n@Component({\n selector: 'c8y-datapoints-list',\n templateUrl: './datapoints-list-view.component.html',\n host: { class: 'd-col fit-h' },\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n ApplyRangeClassPipe,\n C8yTranslateDirective,\n C8yTranslatePipe,\n DatapointsExportSelectorComponent,\n DatePipe,\n DecimalPipe,\n DeviceStatusComponent,\n DynamicComponentModule,\n EmptyStateComponent,\n ForOfDirective,\n GlobalContextConnectorComponent,\n GuideDocsComponent,\n GuideHrefDirective,\n IconDirective,\n ListGroupModule,\n LoadingComponent,\n LocalControlsComponent,\n NgTemplateOutlet,\n VirtualScrollListenerDirective,\n WidgetActionWrapperComponent\n ]\n})\nexport class DatapointsListViewComponent implements OnInit {\n config = input.required<DatapointsListConfig>();\n isInPreviewMode = input(false);\n\n private readonly alertService = inject(AlertService);\n private readonly dashboardChild = inject(DashboardChildComponent, { optional: true });\n private readonly defaultColumns = DEFAULT_DATAPOINTS_LIST_COLUMNS;\n private readonly fetchService = inject(DatapointsListFetchService);\n private readonly groupService = inject(GroupService);\n private readonly inventoryService = inject(InventoryService);\n private readonly router = inject(Router);\n private readonly widgetConfigMigrationService = inject(WidgetConfigMigrationService);\n\n readonly CONTEXT_FEATURE = CONTEXT_FEATURE;\n readonly GLOBAL_CONTEXT_DISPLAY_MODE = GLOBAL_CONTEXT_DISPLAY_MODE;\n missingAllPermissionsAlert = new DynamicComponentAlertAggregator();\n targetManagedObjects = new Map<string, IManagedObject>();\n\n configSignal = linkedSignal(() => this.config());\n contextConfig = signal<GlobalContextState>({});\n dataPoints = signal<DatapointWithMeasurement[]>([]);\n displayMode = signal<DisplayMode>(GLOBAL_CONTEXT_DISPLAY_MODE.DASHBOARD);\n hasLoadedOnce = signal(false);\n hasNoPermissionsToReadAnyMeasurement = signal(false);\n isLinkedToGlobal = signal<boolean | undefined>(undefined);\n isLoading = signal(false);\n widgetControls = signal<PresetName>(PRESET_NAME.DATA_POINTS_LIST);\n\n fractionSize = computed(() => {\n const decimalPlaces = this.configSignal()?.decimalPlaces;\n if (typeof decimalPlaces === 'number' && !Number.isNaN(decimalPlaces)) {\n return `1.${decimalPlaces}-${decimalPlaces}`;\n }\n return '1.2-2';\n });\n\n columns = computed(() => {\n const options = this.configSignal()?.options;\n\n if (options?.columns?.length > 0) {\n return [...options.columns].sort((a, b) => a.order - b.order);\n }\n\n return this.defaultColumns.map(defaultCol => ({\n ...defaultCol,\n visible: options?.[defaultCol.id] ?? defaultCol.visible\n }));\n });\n\n visibleColumns = computed(() => {\n return this.columns().filter(col => col.visible !== false);\n });\n\n exportConfig = computed<ExportConfig | null>(() => {\n const effectiveConfig = { ...this.configSignal(), ...this.contextConfig() };\n const dateFrom = effectiveConfig.dateTimeContext?.dateFrom;\n const dateTo = effectiveConfig.dateTimeContext?.dateTo;\n\n if (!dateFrom || !dateTo || (this.isLoading() && !this.hasLoadedOnce())) {\n return null;\n }\n\n return {\n exportType: 'latestWithDetails',\n datapointDetails:\n effectiveConfig.datapoints\n ?.filter(dp => dp.__active === true)\n .map(dp => ({\n deviceName: dp.__target?.name || '',\n source: dp.__target?.id || '',\n valueFragmentSeries: dp.series,\n valueFragmentType: dp.fragment,\n target: dp.target,\n label: dp.label\n })) || [],\n dateFrom: dateFrom instanceof Date ? dateFrom.toISOString() : dateFrom,\n dateTo: dateTo instanceof Date ? dateTo.toISOString() : dateTo,\n columns: this.columns()\n .filter(col => col.visible)\n .map(col => ({\n id: col.id,\n label: col.label,\n visible: col.visible,\n order: col.order\n }))\n };\n });\n\n activeDataPoints = computed(() => {\n return this.configSignal()?.datapoints?.filter(dp => dp.__active === true) ?? [];\n });\n\n private loadRequestId = 0;\n private readonly seriesWithoutPermissionToReadCount = signal(0);\n\n constructor() {\n toObservable(this.config)\n .pipe(pairwise(), takeUntilDestroyed())\n .subscribe(([prevConfig, currentConfig]) => {\n if (!this.isInPreviewMode()) {\n return;\n }\n\n const prevContext = this.extractContextState(prevConfig);\n const newContext = this.extractContextState(currentConfig);\n this.contextConfig.set(newContext);\n\n const datapointsChanged = !isEqual(prevConfig.datapoints, currentConfig.datapoints);\n const decimalPlacesChanged = prevConfig.decimalPlaces !== currentConfig.decimalPlaces;\n const contextChanged = !isEqual(prevContext, newContext);\n\n if (datapointsChanged || decimalPlacesChanged || contextChanged) {\n this.isLoading.set(true);\n this.loadDatapoints();\n }\n });\n }\n\n ngOnInit(): void {\n this.applyConfigMigration();\n\n const config = this.configSignal();\n\n const displayMode = config.displayMode || GLOBAL_CONTEXT_DISPLAY_MODE.DASHBOARD;\n this.displayMode.set(displayMode as DisplayMode);\n\n this.contextConfig.set(this.extractContextState(config));\n // First fetch is triggered by the global-context connector's initial configChange,\n // except in preview mode where the connector is not rendered (see template).\n if (this.isInPreviewMode()) {\n this.loadDatapoints();\n }\n }\n\n onContextChange(event: { context: GlobalContextState; diff: GlobalContextState }): void {\n const { diff, context } = event;\n this.contextConfig.set(context);\n\n if (\n diff.isAutoRefreshEnabled === false &&\n Object.keys(diff).length === 1 &&\n context.refreshOption === REFRESH_OPTION.LIVE\n ) {\n return;\n }\n\n this.isLoading.set(true);\n this.loadDatapoints();\n }\n\n onRefresh(): void {\n this.isLoading.set(true);\n this.loadDatapoints();\n }\n\n onExportModalOpen(isOpened: boolean): void {\n this.setAutoRefreshPaused(isOpened);\n }\n\n async redirectToAsset(assetId: string | number | undefined): Promise<void> {\n if (this.isInPreviewMode() || !assetId) {\n return;\n }\n\n try {\n const { data: mo } = await this.inventoryService.detail(assetId);\n\n if (mo) {\n const assetPath = this.groupService.getAssetPath(mo);\n this.router.navigateByUrl(`/${assetPath}/${mo.id}`);\n }\n } catch (error) {\n this.alertService.addServerFailure(error);\n }\n }\n\n getTargetManagedObject(targetId: string | number): IManagedObject | undefined {\n return this.targetManagedObjects.get(targetId.toString());\n }\n\n getDashboardChild(): DashboardChildComponent | null {\n return this.dashboardChild ?? null;\n }\n\n getRangeValues(dp: KPIDetails): ColorRangeBoundaries {\n return {\n yellowRangeMin: dp.yellowRangeMin,\n yellowRangeMax: dp.yellowRangeMax,\n redRangeMin: dp.redRangeMin,\n redRangeMax: dp.redRangeMax\n };\n }\n\n onListScrolled(): void {\n this.setAutoRefreshPaused(true);\n }\n\n onListScrolledToTop(): void {\n this.setAutoRefreshPaused(false);\n }\n\n /** Runs once on init; subsequent input changes are already in the new format. */\n private applyConfigMigration(): void {\n const raw = this.config();\n const options = this.widgetConfigMigrationService.hasNoTimeContextFields(raw)\n ? { legacyTimeContextDefaults: getLegacyDatapointsListDefaults() }\n : undefined;\n const migrated = this.widgetConfigMigrationService.migrateWidgetConfig(raw, options);\n if (migrated !== raw) {\n this.configSignal.set(merge({}, raw, migrated) as DatapointsListConfig);\n }\n }\n\n private extractContextState(config: Partial<GlobalContextState>): GlobalContextState {\n return {\n dateTimeContext: config.dateTimeContext,\n aggregation: config.aggregation,\n isAutoRefreshEnabled: config.isAutoRefreshEnabled,\n refreshInterval: config.refreshInterval,\n refreshOption: config.refreshOption\n };\n }\n\n private setAutoRefreshPaused(paused: boolean): void {\n if (this.isInPreviewMode()) {\n return;\n }\n\n const current = this.contextConfig();\n if (current.refreshOption === REFRESH_OPTION.HISTORY) {\n return;\n }\n\n this.contextConfig.set({\n ...current,\n isAutoRefreshEnabled: !paused\n });\n this.isLinkedToGlobal.set(!paused);\n }\n\n private loadDatapoints(): void {\n if (this.activeDataPoints().length > 0) {\n this.fetchMeasurements();\n } else {\n this.dataPoints.set([]);\n this.hasLoadedOnce.set(true);\n this.isLoading.set(false);\n }\n }\n\n private async fetchMeasurements(): Promise<void> {\n const requestId = ++this.loadRequestId;\n\n try {\n this.isLoading.set(true);\n\n const effectiveConfig = {\n ...this.configSignal(),\n ...this.contextConfig()\n };\n\n const dateFrom = effectiveConfig.dateTimeContext?.dateFrom;\n const dateTo = effectiveConfig.dateTimeContext?.dateTo;\n\n const dateFromStr = dateFrom instanceof Date ? dateFrom.toISOString() : dateFrom;\n const dateToStr = dateTo instanceof Date ? dateTo.toISOString() : dateTo;\n\n const result = await this.fetchService.fetchDatapointsWithMeasurements(\n this.activeDataPoints(),\n { fractionSize: this.fractionSize(), dateFrom: dateFromStr, dateTo: dateToStr }\n );\n\n if (requestId === this.loadRequestId) {\n this.dataPoints.set(result.dataPoints);\n this.targetManagedObjects = result.targetManagedObjects;\n this.hasLoadedOnce.set(true);\n this.seriesWithoutPermissionToReadCount.set(result.seriesWithoutPermissionCount);\n this.checkAndDisplayPermissionErrors();\n }\n } finally {\n if (requestId === this.loadRequestId) {\n this.isLoading.set(false);\n }\n }\n }\n\n private checkAndDisplayPermissionErrors(): void {\n if (this.seriesWithoutPermissionToReadCount()) {\n this.missingAllPermissionsAlert.clear();\n this.handleNoPermissionErrorMessage();\n }\n }\n\n private handleNoPermissionErrorMessage(): void {\n const noPermissions =\n this.seriesWithoutPermissionToReadCount() === this.activeDataPoints().length;\n this.hasNoPermissionsToReadAnyMeasurement.set(noPermissions);\n\n if (noPermissions) {\n this.showMessageForMissingPermissionsForAllSeries();\n }\n }\n\n private showMessageForMissingPermissionsForAllSeries(): void {\n this.missingAllPermissionsAlert.addAlerts(\n new DynamicComponentAlert({\n allowHtml: true,\n text: gettext(`<p>To view data, you must meet at least one of these criteria:</p>\n <ul>\n <li>\n Have\n <b>READ permission for \"Measurements\" permission type</b>\n (either as a global role or for the specific source)\n </li>\n <li>\n Be the\n <b>owner of the source</b>\n you want to export data from\n </li>\n </ul>\n <p>Don't meet these requirements? Contact your system administrator for assistance.</p>`),\n type: 'system'\n })\n );\n }\n}\n","@if (!isInPreviewMode()) {\n <div class=\"d-flex gap-16 p-r-16 inner-scroll h-auto min-width-0\">\n @if (displayMode() === GLOBAL_CONTEXT_DISPLAY_MODE.DASHBOARD) {\n <c8y-global-context-connector\n [controls]=\"widgetControls()\"\n [config]=\"contextConfig()\"\n [isLoading]=\"isLoading()\"\n [dashboardChild]=\"getDashboardChild()!\"\n [linked]=\"isLinkedToGlobal()\"\n (configChange)=\"onContextChange($event)\"\n (refresh)=\"onRefresh()\"\n ></c8y-global-context-connector>\n } @else {\n <c8y-local-controls\n [controls]=\"widgetControls()\"\n [displayMode]=\"displayMode()\"\n [config]=\"contextConfig()\"\n [isLoading]=\"isLoading()\"\n (configChange)=\"onContextChange($event)\"\n (refresh)=\"onRefresh()\"\n ></c8y-local-controls>\n }\n </div>\n}\n\n@if (!isInPreviewMode() && exportConfig(); as config) {\n <c8y-widget-action>\n <c8y-datapoints-export-selector\n [displayMode]=\"'icon-only'\"\n [exportConfig]=\"config\"\n [containerClass]=\"'d-contents'\"\n (isOpen)=\"onExportModalOpen($event)\"\n ></c8y-datapoints-export-selector>\n </c8y-widget-action>\n}\n\n@if (!hasNoPermissionsToReadAnyMeasurement()) {\n <!-- the .page-sticky-header -->\n @if (dataPoints().length > 0) {\n <div class=\"hidden-xs hidden-sm c8y-list__item\">\n <div class=\"c8y-list__item__block flex-grow min-width-0\">\n <div class=\"c8y-list__item__icon\">\n <i style=\"width: 22px\"></i>\n </div>\n <div class=\"c8y-list__item__body\">\n <div class=\"d-flex-md row\">\n @for (column of visibleColumns(); track column.id) {\n <div\n [class]=\"\n column.id === 'kpi' || column.id === 'asset'\n ? 'col-md-3 flex-grow min-width-0'\n : column.id == 'diff' || column.id == 'diffPercentage'\n ? 'col-md-1 flex-grow min-width-0'\n : 'col-md-2 flex-grow min-width-0'\n \"\n [class.text-right]=\"column.id !== 'asset' && column.id !== 'kpi'\"\n >\n <span class=\"text-medium text-truncate\">\n {{ column.label | translate }}\n </span>\n </div>\n }\n </div>\n </div>\n </div>\n </div>\n }\n <!-- The record list -->\n @if (isLoading() && !hasLoadedOnce()) {\n <!-- Initial load: full spinner -->\n <ng-container [ngTemplateOutlet]=\"loading\"></ng-container>\n } @else {\n @if (isLoading()) {\n <!-- Refresh: inline loading overlay -->\n <div class=\"p-absolute fit-w overflow-hidden p-b-4\">\n <c8y-loading [layout]=\"'page'\"></c8y-loading>\n </div>\n }\n @if (dataPoints().length) {\n <c8y-list-group\n class=\"flex-grow\"\n role=\"list\"\n c8yVirtualScrollListener\n (scrolled)=\"onListScrolled()\"\n (scrolledToTop)=\"onListScrolledToTop()\"\n >\n <c8y-li\n role=\"listitem\"\n *c8yFor=\"\n let dp of { data: dataPoints(), res: null! };\n enableVirtualScroll: true;\n virtualScrollElementSize: 48;\n virtualScrollStrategy: 'fixed'\n \"\n >\n <c8y-li-icon>\n <i\n c8yIcon=\"circle\"\n [style.color]=\"dp.color\"\n ></i>\n </c8y-li-icon>\n <c8y-li-body>\n <div class=\"d-flex-md row\">\n @for (column of visibleColumns(); track column.id) {\n @switch (column.id) {\n @case ('kpi') {\n <div\n class=\"col-md-3 flex-grow\"\n [attr.data-cy]=\"'datapointlist-' + column.id\"\n >\n <div class=\"d-flex a-i-center d-contents-md p-t-4 p-b-4 separator-bottom\">\n <small\n class=\"text-label-small flex-grow visible-xs-inline-block visible-sm-inline-block\"\n >{{ column.label | translate }}</small\n >\n {{ dp.label | translate }}\n @if (dp.unit) {\n <small class=\"text-muted\">{{ dp.unit }}</small>\n }\n </div>\n </div>\n }\n @case ('target') {\n <div\n class=\"col-md-2 text-right-md flex-grow\"\n [attr.data-cy]=\"'datapointlist-' + column.id\"\n >\n <div class=\"d-flex a-i-center d-contents-md p-t-4 p-b-4 separator-bottom\">\n <small\n class=\"text-label-small flex-grow visible-xs-inline-block visible-sm-inline-block\"\n >{{ column.label | translate }}</small\n >\n <span>{{ dp.target }}</span>\n </div>\n </div>\n }\n @case ('current') {\n <div\n class=\"col-md-2 text-right-md flex-grow\"\n [attr.data-cy]=\"'datapointlist-' + column.id\"\n >\n <div class=\"d-flex a-i-center d-contents-md p-t-4 p-b-4 separator-bottom\">\n <small\n class=\"text-label-small flex-grow visible-xs-inline-block visible-sm-inline-block\"\n >{{ column.label | translate }}</small\n >\n @let ariaLabel =\n 'Last updated: {{ date }}'\n | translate: { date: dp.timestamp | c8yDate: 'medium' };\n <span\n [class]=\"dp.currentValue | applyRangeClass: getRangeValues(dp)\"\n [title]=\"dp.timestamp | c8yDate: 'medium'\"\n [attr.aria-label]=\"ariaLabel\"\n >\n {{ dp.currentValue | number: dp.currentFractionSize }}\n </span>\n </div>\n </div>\n }\n @case ('diff') {\n <div\n class=\"col-md-1 text-right-md flex-grow\"\n [attr.data-cy]=\"'datapointlist-' + column.id\"\n >\n <div class=\"d-flex a-i-center d-contents-md p-t-4 p-b-4 separator-bottom\">\n <small\n class=\"text-label-small flex-grow visible-xs-inline-block visible-sm-inline-block\"\n >{{ column.label | translate }}</small\n >\n <span>\n {{ dp.diffValue | number: dp.diffFractionSize }}\n </span>\n </div>\n </div>\n }\n @case ('diffPercentage') {\n <div\n class=\"col-md-1 text-right-md flex-grow\"\n [attr.data-cy]=\"'datapointlist-' + column.id\"\n >\n <div class=\"d-flex a-i-center d-contents-md p-t-4 p-b-4 separator-bottom\">\n <small\n class=\"text-label-small flex-grow visible-xs-inline-block visible-sm-inline-block\"\n >{{ column.label | translate }}</small\n >\n <span>\n {{ dp.diffPercentValue | number: dp.diffPercentFractionSize }}\n </span>\n </div>\n </div>\n }\n @case ('asset') {\n <div\n class=\"col-md-3 flex-grow\"\n [attr.data-cy]=\"'datapointlist-' + column.id\"\n >\n <div class=\"d-flex a-i-center d-contents-md p-t-4 p-b-4 separator-bottom\">\n <small\n class=\"text-label-small flex-grow visible-xs-inline-block visible-sm-inline-block\"\n >{{ column.label | translate }}</small\n >\n @let ariaLabel =\n 'Navigate to asset: \"{{ name }}\"'\n | translate: { name: dp.__target?.name };\n <button\n class=\"btn-clean d-flex a-i-center gap-4 text-muted\"\n [attr.aria-label]=\"ariaLabel\"\n type=\"button\"\n (click)=\"redirectToAsset(dp.__target?.id)\"\n >\n @if (dp.__target?.id && getTargetManagedObject(dp.__target.id); as mo) {\n <c8y-device-status\n [mo]=\"mo\"\n [size]=\"16\"\n ></c8y-device-status>\n }\n <small\n class=\"text-truncate\"\n [title]=\"dp.__target?.name\"\n >{{ dp.__target?.name }}</small\n >\n </button>\n </div>\n </div>\n }\n }\n }\n </div>\n </c8y-li-body>\n </c8y-li>\n </c8y-list-group>\n } @else {\n <ng-container [ngTemplateOutlet]=\"emptyState\"></ng-container>\n }\n }\n} @else {\n <div class=\"p-t-24 p-r-16 p-l-16 p-b-16 d-flex\">\n <div class=\"center-block\">\n <c8y-dynamic-component-alerts\n [alerts]=\"missingAllPermissionsAlert\"\n ></c8y-dynamic-component-alerts>\n </div>\n </div>\n}\n\n<ng-template #loading>\n <c8y-loading></c8y-loading>\n</ng-template>\n\n<ng-template #emptyState>\n <div class=\"p-relative p-l-24\">\n <c8y-ui-empty-state\n [icon]=\"'c8y-alert-idle'\"\n [title]=\"'No data to display.' | translate\"\n [horizontal]=\"true\"\n data-cy=\"datapoints-list--empty-state\"\n >\n <p c8y-guide-docs>\n <small\n translate\n ngNonBindable\n >\n Find out more in the\n <a c8y-guide-href=\"/docs/cockpit/widgets-collection/#data-point-list\"\n >user documentation</a\n >.\n </small>\n </p>\n </c8y-ui-empty-state>\n </div>\n</ng-template>\n","import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop';\nimport { AsyncPipe } from '@angular/common';\nimport {\n AfterViewInit,\n ChangeDetectionStrategy,\n Component,\n computed,\n DestroyRef,\n inject,\n input,\n OnInit,\n signal,\n TemplateRef,\n viewChild\n} from '@angular/core';\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\nimport {\n AbstractControl,\n FormArray,\n FormBuilder,\n FormGroup,\n NgForm,\n ReactiveFormsModule,\n ValidationErrors,\n ValidatorFn,\n Validators\n} from '@angular/forms';\nimport {\n C8yTranslatePipe,\n DynamicComponentModule,\n FormGroupComponent,\n IconDirective,\n ListGroupModule\n} from '@c8y/ngx-components';\nimport { WidgetConfigService } from '@c8y/ngx-components/context-dashboard';\nimport {\n LocalControlsComponent,\n PRESET_NAME,\n PresetName,\n WidgetConfigMigrationService\n} from '@c8y/ngx-components/global-context';\nimport { isEqual } from 'lodash-es';\nimport { PopoverModule } from 'ngx-bootstrap/popover';\nimport { debounceTime, distinctUntilChanged, filter, Observable, take } from 'rxjs';\nimport { DatapointsListViewComponent } from '../datapoints-list-view/datapoints-list-view.component';\nimport { ColumnConfig, DatapointsListConfig } from '../datapoints-list-widget.model';\nimport {\n DEFAULT_DATAPOINTS_LIST_COLUMNS,\n getLegacyDatapointsListDefaults\n} from '../datapoints-list.constants';\n\nconst DEFAULT_DECIMAL_PLACES = 2;\nconst MIN_DECIMAL_PLACES = 0;\nconst MAX_DECIMAL_PLACES = 10;\n\n@Component({\n selector: 'c8y-datapoints-list-view-config',\n templateUrl: './datapoints-list-config.component.html',\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n AsyncPipe,\n C8yTranslatePipe,\n DatapointsListViewComponent,\n DragDropModule,\n DynamicComponentModule,\n FormGroupComponent,\n IconDirective,\n ListGroupModule,\n LocalControlsComponent,\n ReactiveFormsModule,\n PopoverModule\n ]\n})\nexport class DatapointsListConfigComponent implements OnInit, AfterViewInit {\n config = input.required<DatapointsListConfig>();\n private readonly previewTemplate = viewChild<TemplateRef<unknown>>('dataPointsListPreview');\n\n private readonly defaultColumns = DEFAULT_DATAPOINTS_LIST_COLUMNS;\n private readonly destroyRef = inject(DestroyRef);\n private readonly form = inject(NgForm);\n private readonly formBuilder = inject(FormBuilder);\n private readonly widgetConfigMigrationService = inject(WidgetConfigMigrationService);\n private readonly widgetConfigService = inject(WidgetConfigService);\n\n readonly controls: PresetName = PRESET_NAME.DATA_POINTS_LIST;\n readonly minDecimalPlaces = MIN_DECIMAL_PLACES;\n readonly maxDecimalPlaces = MAX_DECIMAL_PLACES;\n configForm = signal<ReturnType<DatapointsListConfigComponent['createForm']> | undefined>(\n undefined\n );\n readonly columnsFormArray = computed(() => this.configForm()?.get('columns') as FormArray);\n\n /**\n * Debounced config for preview to prevent multiple series requests on initial load.\n * Uses debounceTime to batch rapid emissions (e.g., from initConfig + GlobalContext processing).\n */\n previewConfig$ = this.widgetConfigService.currentConfig$.pipe(\n filter(config => !!config?.dateTimeContext),\n debounceTime(300)\n ) as Observable<DatapointsListConfig>;\n\n ngOnInit(): void {\n this.applyLegacyEpochDefaults();\n\n this.widgetConfigService.currentConfig$\n .pipe(\n filter(config => !!config?.dateTimeContext),\n take(1),\n takeUntilDestroyed(this.destroyRef)\n )\n .subscribe(() => this.initForm());\n }\n\n ngAfterViewInit(): void {\n this.widgetConfigService.setPreview(this.previewTemplate() ?? null);\n }\n\n onColumnDrop(event: CdkDragDrop<ColumnConfig[]>): void {\n const columnsArray = this.columnsFormArray();\n const item = columnsArray.at(event.previousIndex);\n columnsArray.removeAt(event.previousIndex);\n columnsArray.insert(event.currentIndex, item);\n columnsArray.controls.forEach((control, index) => control.patchValue({ order: index }));\n }\n\n private initForm(): void {\n const form = this.createForm();\n this.form.form.addControl('config', form);\n form.patchValue(this.config(), { emitEvent: false });\n\n this.pushFormToService(form.value);\n\n form.valueChanges\n .pipe(\n distinctUntilChanged((prev, curr) => isEqual(prev, curr)),\n debounceTime(300),\n takeUntilDestroyed(this.destroyRef)\n )\n .subscribe(formValue => {\n if (form.invalid) {\n return;\n }\n\n const { decimalPlaces } = formValue;\n if (\n typeof decimalPlaces === 'number' &&\n (decimalPlaces < MIN_DECIMAL_PLACES || decimalPlaces > MAX_DECIMAL_PLACES)\n ) {\n return;\n }\n\n this.pushFormToService(formValue);\n });\n\n this.configForm.set(form);\n }\n\n private pushFormToService(formValue: unknown): void {\n const formData = formValue as { columns?: ColumnConfig[]; [key: string]: unknown };\n const { columns, ...formValueWithoutColumns } = formData;\n const currentOptions = this.widgetConfigService.currentConfig?.options;\n this.widgetConfigService.updateConfig({\n ...formValueWithoutColumns,\n options: {\n ...currentOptions,\n columns: columns || []\n }\n });\n }\n\n private createForm() {\n const currentConfig = this.config();\n const columns = this.migrateColumnsConfig(currentConfig);\n\n return this.formBuilder.group({\n decimalPlaces: [\n currentConfig?.decimalPlaces ?? DEFAULT_DECIMAL_PLACES,\n [\n Validators.required,\n Validators.min(MIN_DECIMAL_PLACES),\n Validators.max(MAX_DECIMAL_PLACES),\n Validators.pattern('^[0-9]+$')\n ]\n ],\n columns: this.formBuilder.array(\n columns.map(col => this.createColumnFormGroup(col)),\n [Validators.required, Validators.minLength(1), this.minOneColumnVisible()]\n )\n });\n }\n\n private createColumnFormGroup(column: ColumnConfig): FormGroup {\n return this.formBuilder.group({\n id: [column.id, Validators.required],\n label: [column.label, Validators.required],\n visible: [column.visible],\n order: [column.order]\n });\n }\n\n private migrateColumnsConfig(config: DatapointsListConfig): ColumnConfig[] {\n if (config?.options?.columns?.length > 0) {\n return [...config.options.columns].sort((a, b) => a.order - b.order);\n }\n\n return this.defaultColumns.map(defaultCol => ({\n ...defaultCol,\n visible: config?.options?.[defaultCol.id] ?? defaultCol.visible\n }));\n }\n\n private minOneColumnVisible(): ValidatorFn {\n return (control: AbstractControl): ValidationErrors | null => {\n const columns: ColumnConfig[] = control.value;\n\n if (!columns?.length) {\n return null;\n }\n\n const visibleColumns = columns.filter(column => column.visible);\n return visibleColumns.length >= 1 ? null : { atLeastOneColumnMustBeVisible: true };\n };\n }\n\n /** Stamp epoch defaults so the editor opens matching the AngularJS predecessor. */\n private applyLegacyEpochDefaults(): void {\n const currentConfig = this.widgetConfigService.currentConfig;\n if (!currentConfig) {\n return;\n }\n\n if (this.widgetConfigMigrationService.hasNoTimeContextFields(currentConfig)) {\n this.widgetConfigService.updateConfig(getLegacyDatapointsListDefaults());\n }\n }\n}\n","@if (configForm(); as form) {\n <form\n class=\"no-card-context\"\n [formGroup]=\"form\"\n >\n <fieldset class=\"c8y-fieldset\">\n <legend>{{ 'Columns (drag to reorder)' | translate }}</legend>\n <c8y-list-group\n formArrayName=\"columns\"\n cdkDropList\n (cdkDropListDropped)=\"onColumnDrop($event)\"\n >\n @if (columnsFormArray().errors?.atLeastOneColumnMustBeVisible) {\n <div\n class=\"alert alert-warning m-t-8\"\n role=\"alert\"\n >\n {{ 'At least 1 column must be visible.' | translate }}\n </div>\n }\n\n @for (column of columnsFormArray().controls; track column.value.id; let i = $index) {\n <c8y-li\n class=\"c8y-list__item__collapse--container-small\"\n [formGroupName]=\"i\"\n cdkDrag\n >\n <c8y-li-drag-handle\n [title]=\"'Click and drag to reorder' | translate\"\n cdkDragHandle\n >\n <i c8yIcon=\"drag-reorder\"></i>\n </c8y-li-drag-handle>\n <c8y-li-checkbox\n class=\"a-s-center p-r-0\"\n [displayAsSwitch]=\"true\"\n formControlName=\"visible\"\n (click)=\"$event.stopPropagation()\"\n ></c8y-li-checkbox>\n <c8y-li-body>\n <div class=\"d-flex a-i-center\">\n <span class=\"text-truncate\">{{ column.value.label | translate }}</span>\n @switch (column.value.label) {\n @case ('Target') {\n <button\n class=\"btn-help\"\n [attr.aria-label]=\"'Help content' | translate\"\n [popover]=\"\n 'The Target column shows the value set on the target field of the data point'\n | translate\n \"\n placement=\"right\"\n triggers=\"focus\"\n container=\"body\"\n type=\"button\"\n ></button>\n }\n @case ('Diff') {\n <button\n class=\"btn-help\"\n [attr.aria-label]=\"'Help content' | translate\"\n [popover]=\"\n 'The Diff column shows the difference between the current value and the target value of the data point'\n | translate\n \"\n placement=\"right\"\n triggers=\"focus\"\n container=\"body\"\n type=\"button\"\n ></button>\n }\n }\n </div>\n </c8y-li-body>\n </c8y-li>\n }\n </c8y-list-group>\n </fieldset>\n\n <!-- decimal input -->\n <fieldset class=\"c8y-fieldset\">\n <legend>\n {{ 'Decimal places' | translate }}\n </legend>\n <c8y-form-group class=\"p-t-8\">\n <input\n class=\"form-control\"\n name=\"decimalPlaces\"\n type=\"number\"\n formControlName=\"decimalPlaces\"\n step=\"1\"\n [min]=\"minDecimalPlaces\"\n [max]=\"maxDecimalPlaces\"\n />\n </c8y-form-group>\n </fieldset>\n </form>\n}\n\n<ng-template #dataPointsListPreview>\n @let previewConfig = previewConfig$ | async;\n @if (previewConfig && previewConfig.displayMode !== 'dashboard') {\n <c8y-local-controls\n [controls]=\"controls\"\n [displayMode]=\"previewConfig.displayMode!\"\n [config]=\"previewConfig\"\n [disabled]=\"true\"\n ></c8y-local-controls>\n }\n\n @if (previewConfig) {\n <c8y-datapoints-list\n [config]=\"previewConfig\"\n [isInPreviewMode]=\"true\"\n data-cy=\"c8y-datapoints-list-widget-config--preview-datapoints-list\"\n ></c8y-datapoints-list>\n }\n</ng-template>\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":["i1","i2"],"mappings":";;;;;;;;;;;;;;;;;;;;;AASA;;AAEG;AACI,MAAM,+BAA+B,GAAmB;AAC7D,IAAA,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,EAAE