diff --git a/backend/shared/model/south-connector.model.ts b/backend/shared/model/south-connector.model.ts index 4a9ca51897..ecace81a6c 100644 --- a/backend/shared/model/south-connector.model.ts +++ b/backend/shared/model/south-connector.model.ts @@ -19,6 +19,7 @@ export const OIBUS_SOUTH_TYPES = [ 'opcua', 'oracle', 'osisoft-pi', + 'osisoft-pi-webapi', 'postgresql', 'sftp', 'sqlite' @@ -118,3 +119,17 @@ export interface SouthCache { itemId: string; maxInstant: Instant; } + +export interface AvailablePoint { + id: string; + name: string; + description?: string; + // PI Web API specific properties + webId?: string; + pointClass?: string; + pointType?: string; + path?: string; + engineeringUnits?: string; + // Generic index signature for connector-specific properties + [key: string]: string | number | boolean | undefined; +} diff --git a/backend/shared/model/south-settings.model.ts b/backend/shared/model/south-settings.model.ts index e5dad30a45..f9d56c607d 100644 --- a/backend/shared/model/south-settings.model.ts +++ b/backend/shared/model/south-settings.model.ts @@ -572,6 +572,7 @@ export type SouthSettings = | SouthOPCUASettings | SouthOracleSettings | SouthPISettings + | SouthPIWebAPISettings | SouthPostgreSQLSettings | SouthSFTPSettings | SouthSQLiteSettings; @@ -830,6 +831,28 @@ export interface SouthPIItemSettings { piQuery?: string; } +export interface SouthPIWebAPISettingsThrottling { + maxReadInterval: number; + readDelay: number; + overlap: number; + maxInstantPerItem: boolean; +} + +export interface SouthPIWebAPISettings { + throttling: SouthPIWebAPISettingsThrottling; + url: string; + dataServerWebId: string; + username: string; + password: string | null; + acceptUnauthorized: boolean; + timeout: number; + retryInterval: number; +} + +export interface SouthPIWebAPIItemSettings { + pointWebId: string; +} + export interface SouthPostgreSQLItemSettings { query: string; dateTimeFields: Array | null; @@ -864,6 +887,7 @@ export type SouthItemSettings = | SouthOPCUAItemSettings | SouthOracleItemSettings | SouthPIItemSettings + | SouthPIWebAPIItemSettings | SouthPostgreSQLItemSettings | SouthSFTPItemSettings | SouthSQLiteItemSettings; diff --git a/backend/src/service/south.service.ts b/backend/src/service/south.service.ts index 3d2305672b..2322e2bf3f 100644 --- a/backend/src/service/south.service.ts +++ b/backend/src/service/south.service.ts @@ -3,6 +3,7 @@ import pino from 'pino'; // South imports import { + AvailablePoint, SouthConnectorCommandDTO, SouthConnectorDTO, SouthConnectorItemCommandDTO, @@ -29,6 +30,7 @@ import sqliteManifest from '../south/south-sqlite/manifest'; import opcManifest from '../south/south-opc/manifest'; import oledbManifest from '../south/south-oledb/manifest'; import piManifest from '../south/south-pi/manifest'; +import piWebAPIManifest from '../south/south-pi-webapi/manifest'; import sftpManifest from '../south/south-sftp/manifest'; import ConnectionService from './connection.service'; import { OIBusContent } from '../../shared/model/engine.model'; @@ -75,6 +77,8 @@ import { SouthOracleSettings, SouthPIItemSettings, SouthPISettings, + SouthPIWebAPIItemSettings, + SouthPIWebAPISettings, SouthPostgreSQLItemSettings, SouthPostgreSQLSettings, SouthSettings, @@ -96,6 +100,7 @@ import SouthOPC from '../south/south-opc/south-opc'; import SouthOPCUA from '../south/south-opcua/south-opcua'; import SouthOracle from '../south/south-oracle/south-oracle'; import SouthPI from '../south/south-pi/south-pi'; +import SouthPIWebAPI from '../south/south-pi-webapi/south-pi-webapi'; import SouthPostgreSQL from '../south/south-postgresql/south-postgresql'; import SouthSFTP from '../south/south-sftp/south-sftp'; import SouthSQLite from '../south/south-sqlite/south-sqlite'; @@ -121,6 +126,7 @@ export const southManifestList: Array = [ modbusManifest, oianalyticsManifest, piManifest, + piWebAPIManifest, sftpManifest ]; @@ -295,6 +301,17 @@ export default class SouthService { logger, southBaseFolders ); + case 'osisoft-pi-webapi': + return new SouthPIWebAPI( + settings as SouthConnectorEntity, + addContent, + this.encryptionService, + this.southConnectorRepository, + this.southCacheRepository, + this.scanModeRepository, + logger, + southBaseFolders + ); case 'postgresql': return new SouthPostgreSQL( settings as SouthConnectorEntity, @@ -424,6 +441,53 @@ export default class SouthService { return await south.testItem(testItemToRun, testingSettings, callback); } + async browseItems( + id: string, + command: SouthConnectorCommandDTO, + logger: pino.Logger, + nameFilter?: string, + maxPoints?: number + ): Promise> { + let southConnector: SouthConnectorEntity | null = null; + if (id !== 'create') { + southConnector = this.southConnectorRepository.findSouthById(id); + if (!southConnector) { + throw new Error(`South connector ${id} not found`); + } + } + const manifest = this.getInstalledSouthManifests().find(southManifest => southManifest.id === command.type); + if (!manifest) { + throw new Error(`South manifest ${command.type} not found`); + } + + const testConnectorToRun: SouthConnectorEntity = { + id: southConnector?.id || 'test', + ...command, + settings: await this.encryptionService.encryptConnectorSecrets( + command.settings, + southConnector?.settings || null, + manifest.settings + ), + name: southConnector ? southConnector.name : `${command!.type}:browse-items`, + items: [] + }; + + /* istanbul ignore next */ + const mockedAddContent = async (_southId: string, _content: OIBusContent): Promise => Promise.resolve(); + const south = this.runSouth(testConnectorToRun, mockedAddContent, logger, { + cache: 'baseCacheFolder', + archive: 'baseArchiveFolder', + error: 'baseErrorFolder' + }); + + // Check if the connector has getAvailablePoints method + if ('getAvailablePoints' in south && typeof south.getAvailablePoints === 'function') { + return await south.getAvailablePoints(nameFilter, maxPoints); + } else { + throw new Error(`Connector type ${command.type} does not support browsing available items`); + } + } + findById(southId: string): SouthConnectorEntity | null { return this.southConnectorRepository.findSouthById(southId); } diff --git a/backend/src/south/south-pi-webapi/manifest.ts b/backend/src/south/south-pi-webapi/manifest.ts new file mode 100644 index 0000000000..cfeb8f257b --- /dev/null +++ b/backend/src/south/south-pi-webapi/manifest.ts @@ -0,0 +1,136 @@ +import { SouthConnectorManifest } from '../../../shared/model/south-connector.model'; + +const manifest: SouthConnectorManifest = { + id: 'osisoft-pi-webapi', + category: 'api', + modes: { + subscription: false, + lastPoint: false, + lastFile: false, + history: true + }, + settings: [ + { + key: 'throttling', + type: 'OibFormGroup', + translationKey: 'south.osisoft-pi-webapi.throttling.title', + class: 'col', + newRow: true, + displayInViewMode: false, + validators: [{ key: 'required' }], + content: [ + { + key: 'maxReadInterval', + type: 'OibNumber', + translationKey: 'south.osisoft-pi-webapi.throttling.max-read-interval', + validators: [{ key: 'required' }, { key: 'min', params: { min: 0 } }], + defaultValue: 3600, + unitLabel: 's', + displayInViewMode: true + }, + { + key: 'readDelay', + type: 'OibNumber', + translationKey: 'south.osisoft-pi-webapi.throttling.read-delay', + validators: [{ key: 'required' }, { key: 'min', params: { min: 0 } }], + defaultValue: 200, + unitLabel: 'ms', + displayInViewMode: true + }, + { + key: 'overlap', + type: 'OibNumber', + translationKey: 'south.osisoft-pi-webapi.throttling.overlap', + validators: [{ key: 'required' }, { key: 'min', params: { min: 0 } }], + defaultValue: 0, + unitLabel: 'ms', + displayInViewMode: true + }, + { + key: 'maxInstantPerItem', + type: 'OibCheckbox', + translationKey: 'south.osisoft-pi-webapi.throttling.max-instant-per-item', + defaultValue: false, + validators: [{ key: 'required' }], + displayInViewMode: true + } + ] + }, + { + key: 'url', + type: 'OibText', + translationKey: 'south.osisoft-pi-webapi.url', + defaultValue: 'https://pi.dev.metroscope.io/piwebapi/', + validators: [{ key: 'required' }], + newRow: true, + displayInViewMode: true + }, + { + key: 'dataServerWebId', + type: 'OibText', + translationKey: 'south.osisoft-pi-webapi.data-server-web-id', + defaultValue: 'F1DSC4n4q_2uRUWxuLuRsKCl8QVk0tUEktU0VSVkVSLVRF', + validators: [{ key: 'required' }], + newRow: true, + displayInViewMode: true + }, + { + key: 'username', + type: 'OibText', + translationKey: 'south.osisoft-pi-webapi.username', + validators: [{ key: 'required' }], + newRow: true, + displayInViewMode: true + }, + { + key: 'password', + type: 'OibSecret', + translationKey: 'south.osisoft-pi-webapi.password', + class: 'col-4', + displayInViewMode: false + }, + { + key: 'acceptUnauthorized', + type: 'OibCheckbox', + translationKey: 'south.osisoft-pi-webapi.accept-unauthorized', + defaultValue: false, + validators: [{ key: 'required' }], + class: 'col-4', + displayInViewMode: true + }, + { + key: 'timeout', + type: 'OibNumber', + translationKey: 'south.osisoft-pi-webapi.timeout', + defaultValue: 30, + unitLabel: 's', + class: 'col-3', + validators: [{ key: 'required' }, { key: 'min', params: { min: 1 } }, { key: 'max', params: { max: 300 } }], + displayInViewMode: true + }, + { + key: 'retryInterval', + type: 'OibNumber', + translationKey: 'south.osisoft-pi-webapi.retry-interval', + defaultValue: 10000, + unitLabel: 'ms', + class: 'col-3', + validators: [{ key: 'required' }, { key: 'min', params: { min: 100 } }, { key: 'max', params: { max: 30000 } }], + displayInViewMode: true + } + ], + items: { + scanMode: 'POLL', + settings: [ + { + key: 'pointWebId', + type: 'OibText', + translationKey: 'south.osisoft-pi-webapi.point-web-id', + validators: [{ key: 'required' }], + displayInViewMode: false, + class: 'col-12 d-none' // Hidden field - set programmatically + } + ] + } +}; +export default manifest; diff --git a/backend/src/south/south-pi-webapi/south-pi-webapi.spec.ts b/backend/src/south/south-pi-webapi/south-pi-webapi.spec.ts new file mode 100644 index 0000000000..9f1c130467 --- /dev/null +++ b/backend/src/south/south-pi-webapi/south-pi-webapi.spec.ts @@ -0,0 +1,103 @@ +import SouthPIWebAPI from './south-pi-webapi'; +import { SouthPIWebAPISettings, SouthPIWebAPIItemSettings } from '../../../shared/model/south-settings.model'; +import { SouthConnectorEntity } from '../../model/south-connector.model'; +import EncryptionService from '../../service/encryption.service'; +import EncryptionServiceMock from '../../tests/__mocks__/service/encryption-service.mock'; +import SouthConnectorRepository from '../../repository/config/south-connector.repository'; +import SouthCacheRepository from '../../repository/cache/south-cache.repository'; +import ScanModeRepository from '../../repository/config/scan-mode.repository'; +import pino from 'pino'; + +// Mock implementations for testing +const mockEncryptionService: EncryptionService = new EncryptionServiceMock('', ''); +const mockSouthConnectorRepository = {} as SouthConnectorRepository; +const mockSouthCacheRepository = {} as SouthCacheRepository; +const mockScanModeRepository = {} as ScanModeRepository; +const mockLogger = pino(); +const mockBaseFolders = { + cache: '/tmp/cache', + archive: '/tmp/archive', + error: '/tmp/error' +}; + +const mockSettings: SouthPIWebAPISettings = { + throttling: { + maxReadInterval: 3600, + readDelay: 200, + overlap: 0, + maxInstantPerItem: false + }, + url: 'https://pi.dev.metroscope.io/piwebapi/', + dataServerWebId: 'F1DSC4n4q_2uRUWxuLuRsKCl8QVk0tUEktU0VSVkVSLVRF', + username: 'testuser', + password: 'testpass', + acceptUnauthorized: false, + timeout: 30, + retryInterval: 10000 +}; + +const mockItemSettings: SouthPIWebAPIItemSettings = { + pointWebId: 'F1DPC4n4q_2uRUWxuLuRsKCl8QBAAAAAVk0tUEktU0VSVkVSLVRFXENISUxMRVJfT05fQ0hJTExFUl8xMDE' +}; + +const mockConnector: SouthConnectorEntity = { + id: 'test-pi-webapi', + name: 'Test PI Web API', + type: 'osisoft-pi-webapi', + description: 'Test PI Web API connector', + enabled: true, + settings: mockSettings, + items: [ + { + id: 'item1', + name: 'chiller_on_chiller_101', + enabled: true, + settings: mockItemSettings, + scanModeId: 'scanMode1' + } + ] +}; + +describe('SouthPIWebAPI', () => { + let connector: SouthPIWebAPI; + + beforeEach(() => { + connector = new SouthPIWebAPI( + mockConnector, + jest.fn(), + mockEncryptionService, + mockSouthConnectorRepository, + mockSouthCacheRepository, + mockScanModeRepository, + mockLogger, + mockBaseFolders + ); + }); + + it('should create an instance', () => { + expect(connector).toBeDefined(); + expect(connector).toBeInstanceOf(SouthPIWebAPI); + }); + + it('should have correct throttling settings', () => { + const throttling = connector.getThrottlingSettings(mockSettings); + expect(throttling.maxReadInterval).toBe(3600); + expect(throttling.readDelay).toBe(200); + }); + + it('should handle max instant per item setting', () => { + expect(connector.getMaxInstantPerItem(mockSettings)).toBe(false); + }); + + it('should handle overlap setting', () => { + expect(connector.getOverlap(mockSettings)).toBe(0); + }); + + it('should ensure URL ends with slash', () => { + const result = connector.ensureUrlEndsWithSlash('https://example.com'); + expect(result).toBe('https://example.com/'); + + const resultWithSlash = connector.ensureUrlEndsWithSlash('https://example.com/'); + expect(resultWithSlash).toBe('https://example.com/'); + }); +}); diff --git a/backend/src/south/south-pi-webapi/south-pi-webapi.ts b/backend/src/south/south-pi-webapi/south-pi-webapi.ts new file mode 100644 index 0000000000..e1da46bd44 --- /dev/null +++ b/backend/src/south/south-pi-webapi/south-pi-webapi.ts @@ -0,0 +1,373 @@ +import SouthConnector from '../south-connector'; +import EncryptionService from '../../service/encryption.service'; +import pino from 'pino'; +import { Instant } from '../../../shared/model/types'; +import { DateTime } from 'luxon'; +import { QueriesHistory } from '../south-interface'; +import { SouthPIWebAPIItemSettings, SouthPIWebAPISettings } from '../../../shared/model/south-settings.model'; +import { OIBusContent, OIBusTimeValue } from '../../../shared/model/engine.model'; +import { SouthConnectorEntity, SouthConnectorItemEntity, SouthThrottlingSettings } from '../../model/south-connector.model'; +import SouthConnectorRepository from '../../repository/config/south-connector.repository'; +import SouthCacheRepository from '../../repository/cache/south-cache.repository'; +import ScanModeRepository from '../../repository/config/scan-mode.repository'; +import { BaseFolders } from '../../model/types'; +import { SouthConnectorItemTestingSettings, AvailablePoint } from '../../../shared/model/south-connector.model'; +import { HTTPRequest, ReqAuthOptions, ReqOptions } from '../../service/http-request.utils'; + +interface PIWebAPIPoint { + WebId: string; + Id: number; + Name: string; + Path: string; + Descriptor: string; + PointClass: string; + PointType: string; + DigitalSetName: string; + EngineeringUnits: string; + Span: number; + Zero: number; + Step: boolean; + Future: boolean; + DisplayDigits: number; + Links: { + Self: string; + DataServer: string; + Attributes: string; + InterpolatedData: string; + RecordedData: string; + PlotData: string; + SummaryData: string; + Value: string; + EndValue: string; + }; +} + +interface PIWebAPIPointsResponse { + Links: Record; + Items: Array; +} + +interface PIWebAPIRecordedValue { + Timestamp: string; + UnitsAbbreviation: string; + Good: boolean; + Questionable: boolean; + Substituted: boolean; + Annotated: boolean; + Value: number | string | boolean; + Annotations?: Array<{ + Id: string; + Name: string; + Description: string; + Value: string; + Creator: string; + CreationDate: string; + Modifier: string; + ModifyDate: string; + Errors: unknown; + }>; +} + +interface PIWebAPIRecordedResponse { + Items: Array; + Links: Record; +} + +/** + * Class SouthPIWebAPI - Connect to OSIsoft PI Web API to retrieve historical data + */ +export default class SouthPIWebAPI extends SouthConnector implements QueriesHistory { + private connected = false; + private reconnectTimeout: NodeJS.Timeout | null = null; + private disconnecting = false; + + constructor( + connector: SouthConnectorEntity, + engineAddContentCallback: (southId: string, data: OIBusContent) => Promise, + encryptionService: EncryptionService, + southConnectorRepository: SouthConnectorRepository, + southCacheRepository: SouthCacheRepository, + scanModeRepository: ScanModeRepository, + logger: pino.Logger, + baseFolders: BaseFolders + ) { + super( + connector, + engineAddContentCallback, + encryptionService, + southConnectorRepository, + southCacheRepository, + scanModeRepository, + logger, + baseFolders + ); + } + + async connect(): Promise { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + try { + // Test connection by trying to get data server info + await this.testConnection(); + this.connected = true; + await super.connect(); + } catch (error) { + this.logger.error(`Error while connecting to PI Web API. Reconnecting in ${this.connector.settings.retryInterval} ms. ${error}`); + if (!this.disconnecting && this.connector.enabled && !this.reconnectTimeout) { + await this.disconnect(); + this.reconnectTimeout = setTimeout(this.connect.bind(this), this.connector.settings.retryInterval); + } + } + } + + async testConnection(): Promise { + try { + // Validate required settings + if (!this.connector.settings.url) { + throw new Error('PI Web API URL is required'); + } + if (!this.connector.settings.dataServerWebId) { + throw new Error('Data Server Web ID is required'); + } + if (!this.connector.settings.username) { + throw new Error('Username is required'); + } + if (!this.connector.settings.password) { + throw new Error('Password is required'); + } + + const fetchOptions = this.createHttpOptions('GET'); + const requestUrl = new URL( + `dataservers/${this.connector.settings.dataServerWebId}`, + this.ensureUrlEndsWithSlash(this.connector.settings.url) + ); + + const response = await HTTPRequest(requestUrl, fetchOptions); + + if (!response.ok) { + throw new Error(`PI Web API connection test failed with status ${response.statusCode}`); + } + + this.logger.info('PI Web API connection test successful'); + } catch (error) { + throw new Error(`PI Web API connection test failed: ${error}`); + } + } + + override async testItem( + item: SouthConnectorItemEntity, + testingSettings: SouthConnectorItemTestingSettings, + callback: (data: OIBusContent) => void + ): Promise { + const content: OIBusContent = { type: 'time-values', content: [] }; + + const startTime = testingSettings.history!.startTime; + const endTime = testingSettings.history!.endTime; + + try { + const values = await this.queryRecordedData(item.settings.pointWebId, startTime, endTime); + content.content = values; + callback(content); + } catch (error) { + throw new Error(`Error testing PI Web API item: ${error}`); + } + } + + /** + * Get entries from PI Web API between startTime and endTime + */ + async historyQuery( + items: Array>, + startTime: Instant, + endTime: Instant + ): Promise { + let updatedStartTime: Instant | null = null; + this.logger.debug(`Requesting ${items.length} items between ${startTime} and ${endTime}`); + const startRequest = DateTime.now().toMillis(); + + const allValues: Array = []; + let maxTimestamp = new Date(startTime).getTime(); + + try { + // Process items in parallel but with some throttling to avoid overwhelming the API + const batchSize = 10; + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize); + const batchPromises = batch.map(async item => { + try { + const values = await this.queryRecordedData(item.settings.pointWebId, startTime, endTime); + return { item, values }; + } catch (error) { + this.logger.error(`Error querying item ${item.name}: ${error}`); + return { item, values: [] }; + } + }); + + const batchResults = await Promise.all(batchPromises); + + for (const { item, values } of batchResults) { + // Add point ID to each value + const valuesWithPointId = values.map(value => ({ + ...value, + pointId: item.name + })); + + allValues.push(...valuesWithPointId); + + // Track the maximum timestamp + if (values.length > 0) { + const itemMaxTimestamp = Math.max(...values.map(v => new Date(v.timestamp).getTime())); + maxTimestamp = Math.max(maxTimestamp, itemMaxTimestamp); + } + } + + // Add delay between batches to respect API limits + if (i + batchSize < items.length) { + await new Promise(resolve => setTimeout(resolve, this.connector.settings.throttling.readDelay)); + } + } + + const requestDuration = DateTime.now().toMillis() - startRequest; + + if (allValues.length > 0) { + this.logger.debug(`Found ${allValues.length} results for ${items.length} items in ${requestDuration} ms`); + await this.addContent({ type: 'time-values', content: allValues }); + + if (maxTimestamp > new Date(startTime).getTime()) { + updatedStartTime = new Date(maxTimestamp).toISOString(); + } + } else { + this.logger.debug(`No result found. Request done in ${requestDuration} ms`); + } + } catch (error) { + throw new Error(`Error querying PI Web API: ${error}`); + } + + return updatedStartTime; + } + + private async queryRecordedData(pointWebId: string, startTime: Instant, endTime: Instant): Promise> { + const fetchOptions = this.createHttpOptions('GET'); + + // Build URL with query parameters + const url = new URL(`streams/${pointWebId}/recorded`, this.ensureUrlEndsWithSlash(this.connector.settings.url)); + url.searchParams.set('startTime', startTime); + url.searchParams.set('endTime', endTime); + url.searchParams.set('maxCount', '10000'); // Reasonable limit + + const response = await HTTPRequest(url, fetchOptions); + + if (!response.ok) { + const errorText = await response.body.text(); + throw new Error(`PI Web API query failed with status ${response.statusCode}: ${errorText}`); + } + + const data = (await response.body.json()) as PIWebAPIRecordedResponse; + + return data.Items.map(item => { + const value = typeof item.Value === 'boolean' ? (item.Value ? 1 : 0) : item.Value; + return { + pointId: pointWebId, + timestamp: item.Timestamp, + value: value as string | number, + quality: item.Good ? 'GOOD' : item.Questionable ? 'UNCERTAIN' : 'BAD', + data: { value: value as string | number } + }; + }); + } + + private createHttpOptions(method: string): ReqOptions { + const auth: ReqAuthOptions = { + type: 'basic', + username: this.connector.settings.username, + password: this.connector.settings.password || '' + }; + + return { + method, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + auth, + timeout: this.connector.settings.timeout * 1000, + acceptUnauthorized: this.connector.settings.acceptUnauthorized + }; + } + + public ensureUrlEndsWithSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; + } + + getThrottlingSettings(settings: SouthPIWebAPISettings): SouthThrottlingSettings { + return { + maxReadInterval: settings.throttling.maxReadInterval, + readDelay: settings.throttling.readDelay + }; + } + + getMaxInstantPerItem(settings: SouthPIWebAPISettings): boolean { + return settings.throttling.maxInstantPerItem; + } + + getOverlap(settings: SouthPIWebAPISettings): number { + return settings.throttling.overlap; + } + + async disconnect(): Promise { + this.disconnecting = true; + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + this.connected = false; + await super.disconnect(); + this.disconnecting = false; + } + + /** + * Get available points from the PI Web API data server + * This can be used by the UI to allow users to select points + * @param nameFilter - Optional name filter (supports * wildcard like PI queries) + * @param maxPoints - Maximum number of points to return (default 1000) + */ + async getAvailablePoints(nameFilter?: string, maxPoints = 1000): Promise> { + const fetchOptions = this.createHttpOptions('GET'); + const requestUrl = new URL( + `dataservers/${this.connector.settings.dataServerWebId}/points`, + this.ensureUrlEndsWithSlash(this.connector.settings.url) + ); + + // Add name filter if provided (PI Web API supports wildcards) + if (nameFilter) { + requestUrl.searchParams.set('nameFilter', nameFilter); + } + + // Set max count to avoid overwhelming responses + requestUrl.searchParams.set('maxCount', maxPoints.toString()); + + const response = await HTTPRequest(requestUrl, fetchOptions); + + if (!response.ok) { + const errorText = await response.body.text(); + throw new Error(`Failed to get PI Web API points with status ${response.statusCode}: ${errorText}`); + } + + const data = (await response.body.json()) as PIWebAPIPointsResponse; + + // Convert PI Web API points to generic AvailablePoint format + return data.Items.map(point => ({ + id: point.WebId, + name: point.Name, + description: point.Descriptor || point.Path, + webId: point.WebId, + pointClass: point.PointClass, + pointType: point.PointType, + path: point.Path, + engineeringUnits: point.EngineeringUnits + })); + } +} diff --git a/backend/src/web-server/controllers/south-connector.controller.ts b/backend/src/web-server/controllers/south-connector.controller.ts index f2dcf56842..be486b878f 100644 --- a/backend/src/web-server/controllers/south-connector.controller.ts +++ b/backend/src/web-server/controllers/south-connector.controller.ts @@ -1,5 +1,6 @@ import { KoaContext } from '../koa'; import { + AvailablePoint, SouthConnectorCommandDTO, SouthConnectorDTO, SouthConnectorItemCommandDTO, @@ -347,4 +348,28 @@ export default class SouthConnectorController { } ctx.noContent(); } + + async browseAvailableItems( + ctx: KoaContext, Array> + ): Promise { + try { + const logger = ctx.app.logger.child( + { + scopeType: 'south', + scopeId: 'browse', + scopeName: 'browse' + }, + { level: 'silent' } + ); + + // Extract nameFilter from query parameters + const nameFilter = ctx.query.nameFilter as string | undefined; + const maxPoints = ctx.query.maxPoints ? parseInt(ctx.query.maxPoints as string, 10) : undefined; + + const result = await ctx.app.southService.browseItems(ctx.params.id, ctx.request.body!, logger, nameFilter, maxPoints); + ctx.ok(result); + } catch (error: unknown) { + ctx.badRequest((error as Error).message); + } + } } diff --git a/backend/src/web-server/routes/index.ts b/backend/src/web-server/routes/index.ts index 6de72ea2f1..59eb9d0a30 100644 --- a/backend/src/web-server/routes/index.ts +++ b/backend/src/web-server/routes/index.ts @@ -33,6 +33,7 @@ import { ChangePasswordCommand, UserDTO, UserCommandDTO } from '../../../shared/ import { ScanModeCommandDTO, ScanModeDTO, ValidatedCronExpression } from '../../../shared/model/scan-mode.model'; import { CertificateCommandDTO, CertificateDTO } from '../../../shared/model/certificate.model'; import { + AvailablePoint, SouthConnectorCommandDTO, SouthConnectorDTO, SouthConnectorItemCommandDTO, @@ -194,6 +195,11 @@ router.get('/api/south', (ctx: KoaContext>) router.put('/api/south/:id/test-connection', (ctx: KoaContext, void>) => southConnectorController.testSouthConnection(ctx) ); +router.put( + '/api/south/:id/browse-items', + (ctx: KoaContext, Array>) => + southConnectorController.browseAvailableItems(ctx) +); router.get('/api/south/:id', (ctx: KoaContext>) => southConnectorController.findById(ctx) ); diff --git a/frontend/src/app/services/south-connector.service.ts b/frontend/src/app/services/south-connector.service.ts index 336ecdb912..6e6a4f6478 100644 --- a/frontend/src/app/services/south-connector.service.ts +++ b/frontend/src/app/services/south-connector.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { map, Observable } from 'rxjs'; import { Injectable, inject } from '@angular/core'; import { + AvailablePoint, SouthConnectorCommandDTO, SouthConnectorDTO, SouthConnectorManifest, @@ -262,6 +263,25 @@ export class SouthConnectorService { return this.http.put(`/api/south/${southId}/test-connection`, settings); } + browseAvailableItems( + southId: string, + settings: SouthConnectorCommandDTO, + params?: { nameFilter?: string; maxPoints?: number } + ): Observable> { + let url = `/api/south/${southId}/browse-items`; + if (params?.nameFilter || params?.maxPoints) { + const queryParams = new URLSearchParams(); + if (params.nameFilter) { + queryParams.set('nameFilter', params.nameFilter); + } + if (params.maxPoints) { + queryParams.set('maxPoints', params.maxPoints.toString()); + } + url += `?${queryParams.toString()}`; + } + return this.http.put>(url, settings); + } + startSouth(southId: string): Observable { return this.http.put(`/api/south/${southId}/start`, null); } diff --git a/frontend/src/app/shared/test-connection-result-modal/test-connection-result-modal.component.html b/frontend/src/app/shared/test-connection-result-modal/test-connection-result-modal.component.html index c2a33d36ce..66f3b3ad1b 100644 --- a/frontend/src/app/shared/test-connection-result-modal/test-connection-result-modal.component.html +++ b/frontend/src/app/shared/test-connection-result-modal/test-connection-result-modal.component.html @@ -12,6 +12,30 @@ } @else { @if (success) {
+ + @if (showAvailablePoints) { +
+
Available Points:
+
+ + + + + + + + + @for (point of availablePoints; track point.id) { + + + + + } + +
NameDescription
{{ point.name }}{{ point.description || point.path || '-' }}
+
+
+ } } } } @else { diff --git a/frontend/src/app/shared/test-connection-result-modal/test-connection-result-modal.component.ts b/frontend/src/app/shared/test-connection-result-modal/test-connection-result-modal.component.ts index 63afda3fff..c116d1233e 100644 --- a/frontend/src/app/shared/test-connection-result-modal/test-connection-result-modal.component.ts +++ b/frontend/src/app/shared/test-connection-result-modal/test-connection-result-modal.component.ts @@ -1,7 +1,7 @@ import { Component, inject } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateDirective } from '@ngx-translate/core'; -import { SouthConnectorCommandDTO, SouthConnectorDTO } from '../../../../../backend/shared/model/south-connector.model'; +import { SouthConnectorCommandDTO, SouthConnectorDTO, AvailablePoint } from '../../../../../backend/shared/model/south-connector.model'; import { SouthConnectorService } from '../../services/south-connector.service'; import { NorthConnectorCommandDTO, NorthConnectorDTO } from '../../../../../backend/shared/model/north-connector.model'; @@ -27,6 +27,8 @@ export class TestConnectionResultModalComponent { success = false; error: string | null = null; connector: SouthConnectorDTO | NorthConnectorDTO | null = null; + availablePoints: Array = []; + showAvailablePoints = false; /** * Prepares the component for creation. @@ -56,6 +58,29 @@ export class TestConnectionResultModalComponent { next: () => { this.success = true; this.loading = false; + + // If this is a PI Web API connector, also try to browse available points + if (this.type === 'south') { + const southCommand = command as SouthConnectorCommandDTO; + if (southCommand.type === 'osisoft-pi-webapi') { + this.browseAvailablePoints(southCommand); + } + } + } + }); + } + + /** + * Browse available points for connectors that support it (like PI Web API) + */ + browseAvailablePoints(command: SouthConnectorCommandDTO) { + this.southConnectorService.browseAvailableItems(this.connector?.id || 'create', command).subscribe({ + error: () => { + // Don't show error for browse points, just ignore it silently + }, + next: (points: Array) => { + this.availablePoints = points; + this.showAvailablePoints = points.length > 0; } }); } @@ -100,4 +125,8 @@ export class TestConnectionResultModalComponent { cancel() { this.modal.dismiss(); } + + selectPoint(point: AvailablePoint) { + this.modal.close(point); + } } diff --git a/frontend/src/app/south/edit-south-item-modal/edit-south-item-modal.component.html b/frontend/src/app/south/edit-south-item-modal/edit-south-item-modal.component.html index 099cc5331f..ffbac123c5 100644 --- a/frontend/src/app/south/edit-south-item-modal/edit-south-item-modal.component.html +++ b/frontend/src/app/south/edit-south-item-modal/edit-south-item-modal.component.html @@ -41,11 +41,99 @@