diff --git a/packages/react-components/src/components/alarm-state/alarm-state-text.css b/packages/react-components/src/components/alarm-state/alarm-state-text.css new file mode 100644 index 000000000..59ea4062c --- /dev/null +++ b/packages/react-components/src/components/alarm-state/alarm-state-text.css @@ -0,0 +1,3 @@ +.alarm-state-text { + display: flex; +} diff --git a/packages/react-components/src/components/alarm-state/alarm-state-text.tsx b/packages/react-components/src/components/alarm-state/alarm-state-text.tsx new file mode 100644 index 000000000..d426d1eeb --- /dev/null +++ b/packages/react-components/src/components/alarm-state/alarm-state-text.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import Box from '@cloudscape-design/components/box'; +import Icon from '@cloudscape-design/components/icon'; +import { + colorBorderStatusError, + colorBorderStatusSuccess, + colorBorderStatusWarning, + spaceScaledXxs, +} from '@cloudscape-design/design-tokens'; + +import './alarm-state-text.css'; +import { PascalCaseStateName } from '../../hooks/useAlarms/transformers'; + +type AlarmStateTextOptions = { + state?: PascalCaseStateName; + inheritFontColor?: boolean; +}; + +export const alarmStateHasText = (state: PascalCaseStateName) => { + return state === 'Active' || state === 'Normal' || state === 'Latched'; +}; + +export const AlarmStateText = ({ + state, + inheritFontColor, +}: AlarmStateTextOptions) => { + let icon = null; + let text = null; + let styles: React.CSSProperties = { + gap: spaceScaledXxs, + textDecoration: 'none', + }; + + let borderBottom = '1px dashed '; + + switch (state) { + case 'Active': + icon = ( + + ); + text = ( + + Active alarm + + ); + borderBottom += inheritFontColor ? 'inherit' : colorBorderStatusError; + styles = { + ...styles, + borderBottom: borderBottom, + }; + break; + case 'Normal': + icon = ( + + ); + text = ( + + Normal + + ); + borderBottom += inheritFontColor ? 'inherit' : colorBorderStatusSuccess; + styles = { + ...styles, + borderBottom: borderBottom, + }; + break; + case 'Latched': + icon = ( + + ); + text = ( + + Latched alarm + + ); + borderBottom += inheritFontColor ? 'inherit' : colorBorderStatusWarning; + styles = { + ...styles, + borderBottom: borderBottom, + }; + break; + } + + return ( +
+
+ {icon} + {text} +
+
+ ); +}; diff --git a/packages/react-components/src/components/data-quality/data-quality-text.tsx b/packages/react-components/src/components/data-quality/data-quality-text.tsx index e40acc74c..724021296 100644 --- a/packages/react-components/src/components/data-quality/data-quality-text.tsx +++ b/packages/react-components/src/components/data-quality/data-quality-text.tsx @@ -1,60 +1,48 @@ import React from 'react'; import { Quality } from '@aws-sdk/client-iotsitewise'; import Box from '@cloudscape-design/components/box'; -import Icon from '@cloudscape-design/components/icon'; -import { - colorBorderStatusError, - colorBorderStatusWarning, - spaceScaledXxs, -} from '@cloudscape-design/design-tokens'; +import { spaceScaledXxs } from '@cloudscape-design/design-tokens'; import './data-quality-text.css'; type DataQualityTextOptions = { quality?: Quality; + inheritFontColor?: boolean; }; -export const DataQualityText = ({ quality }: DataQualityTextOptions) => { +export const DataQualityText = ({ + quality, + inheritFontColor, +}: DataQualityTextOptions) => { // Don't show any special UX for good quality points if (!quality || quality === 'GOOD') return null; - let icon = null; let text = null; - let styles: React.CSSProperties = { + const styles: React.CSSProperties = { gap: spaceScaledXxs, textDecoration: 'none', }; - let borderBottom = '1px dashed '; - switch (quality) { case 'BAD': - icon = ; - text = Bad Quality; - borderBottom += colorBorderStatusError; - styles = { - ...styles, - borderBottom: borderBottom, - }; + text = ( + + Bad Quality + + ); break; case 'UNCERTAIN': - icon = ; - text = Uncertain Quality; - borderBottom += colorBorderStatusWarning; - styles = { - ...styles, - borderBottom: borderBottom, - }; + text = ( + + Uncertain Quality + + ); break; } return ( -
+
- {icon} {text}
diff --git a/packages/react-components/src/components/kpi/kpi.css b/packages/react-components/src/components/kpi/kpi.css index 71157de06..451c4c9c2 100644 --- a/packages/react-components/src/components/kpi/kpi.css +++ b/packages/react-components/src/components/kpi/kpi.css @@ -36,11 +36,10 @@ .kpi .timestamp-border { height: 2px; - background-color: #e9ebed; } .kpi .timestamp { - padding: 6px; + padding: 8px; height: 18px; } @@ -54,5 +53,5 @@ } .aggregation { - padding: 6px; + padding: 8px; } diff --git a/packages/react-components/src/components/kpi/kpi.tsx b/packages/react-components/src/components/kpi/kpi.tsx index 2483961d9..e4192d05b 100644 --- a/packages/react-components/src/components/kpi/kpi.tsx +++ b/packages/react-components/src/components/kpi/kpi.tsx @@ -11,7 +11,10 @@ import type { import type { KPISettings } from './types'; import { KpiBase } from './kpiBase'; import type { ComponentQuery } from '../../common/chartTypes'; -import { getTimeSeriesQueries } from '../../utils/queries'; +import { getAlarmQueries, getTimeSeriesQueries } from '../../utils/queries'; +import { convertAlarmQueryToAlarmRequest } from '../../queries/utils/convertAlarmQueryToAlarmRequest'; +import { useAlarms } from '../../hooks/useAlarms'; +import { buildTransformAlarmForSingleQueryWidgets } from '../../utils/buildTransformAlarmForSingleQueryWidgets'; export const KPI = ({ query, @@ -31,23 +34,47 @@ export const KPI = ({ significantDigits?: number; timeZone?: string; }) => { + const { viewport } = useViewport(); + const utilizedViewport = passedInViewport || viewport || DEFAULT_VIEWPORT; // explicitly passed in viewport overrides viewport group + + const alarmQueries = getAlarmQueries([query]); + const timeSeriesQueries = getTimeSeriesQueries([query]); + + const mapAlarmQueriesToRequests = alarmQueries.flatMap((query) => + convertAlarmQueryToAlarmRequest(query) + ); + + const transformedAlarm = useAlarms({ + iotSiteWiseClient: alarmQueries.at(0)?.iotSiteWiseClient, + iotEventsClient: alarmQueries.at(0)?.iotEventsClient, + requests: mapAlarmQueriesToRequests, + viewport: utilizedViewport, + settings: { + fetchThresholds: true, + refreshRate: alarmQueries.at(0)?.query.requestSettings?.refreshRate, + }, + transform: buildTransformAlarmForSingleQueryWidgets({ + iotSiteWiseClient: alarmQueries.at(0)?.iotSiteWiseClient, + iotEventsClient: alarmQueries.at(0)?.iotEventsClient, + }), + }) + .filter((alarm) => !!alarm) + .at(0); + const { dataStreams, thresholds: queryThresholds } = useTimeSeriesData({ viewport: passedInViewport, - queries: getTimeSeriesQueries([query]), + queries: transformedAlarm + ? transformedAlarm.timeSeriesDataQueries + : timeSeriesQueries, settings: { fetchMostRecentBeforeEnd: true, }, styles, }); - const { viewport } = useViewport(); - - const utilizedViewport = passedInViewport || viewport || DEFAULT_VIEWPORT; // explicitly passed in viewport overrides viewport group const { propertyPoint, - alarmPoint, propertyThreshold, - alarmStream, propertyStream, propertyResolution, } = widgetPropertiesFromInputs({ @@ -56,17 +83,16 @@ export const KPI = ({ viewport: utilizedViewport, }); - const name = propertyStream?.name || alarmStream?.name; - const unit = propertyStream?.unit || alarmStream?.unit; + const name = propertyStream?.name; + const unit = propertyStream?.unit; const backgroundColor = settings?.color || settings?.backgroundColor; - const isLoading = - alarmStream?.isLoading || propertyStream?.isLoading || false; - const error = alarmStream?.error || propertyStream?.error; + const isLoading = propertyStream?.isLoading || false; + const error = propertyStream?.error; return ( = ({ propertyPoint, @@ -30,6 +35,7 @@ export const KpiBase: React.FC = ({ significantDigits = DEFAULT_DECIMAL_PLACES, propertyThreshold, timeZone, + alarmState, }) => { const { showUnit, @@ -59,6 +65,11 @@ export const KpiBase: React.FC = ({ const fontColor = showFilledThreshold ? highContrastColor(propertyThreshold.color) : nonThresholdFontColor; + const borderColor = showFilledThreshold + ? highContrastColor(propertyThreshold.color) + : settings.backgroundColor + ? nonThresholdFontColor + : colorBorderDividerSecondary; const point = propertyPoint; const aggregationResolutionString = getAggregationFrequency( @@ -88,7 +99,7 @@ export const KpiBase: React.FC = ({ ); } - const nameAndUnit = (showName || showUnit) && ( + const nameAndUnit = (
= ({
); + const alarmSection = ( +
+ +
+ ); + + const dataQualitySection = ( +
+ +
+ ); + return (
= ({ >
- {nameAndUnit} + {alarmState && alarmStateHasText(alarmState) && alarmSection} + {(showName || showUnit) && nameAndUnit}
= ({ )}
- {!isLoading && showDataQuality && ( - - )} + {!isLoading && showDataQuality && dataQualitySection}
{point && (
= ({
diff --git a/packages/react-components/src/components/kpi/types.ts b/packages/react-components/src/components/kpi/types.ts index ca866ec8d..2f9be6d20 100644 --- a/packages/react-components/src/components/kpi/types.ts +++ b/packages/react-components/src/components/kpi/types.ts @@ -1,10 +1,12 @@ import { StyledThreshold } from '@iot-app-kit/core'; import type { WidgetSettings } from '../../common/dataTypes'; +import { PascalCaseStateName } from '../../hooks/useAlarms/transformers'; export type KPIBaseProperties = WidgetSettings & { settings?: Partial; propertyThreshold?: StyledThreshold; timeZone?: string; + alarmState?: PascalCaseStateName; }; export type KPISettings = { diff --git a/packages/react-components/src/utils/buildTransformAlarmForSingleQueryWidgets.ts b/packages/react-components/src/utils/buildTransformAlarmForSingleQueryWidgets.ts new file mode 100644 index 000000000..b52e29633 --- /dev/null +++ b/packages/react-components/src/utils/buildTransformAlarmForSingleQueryWidgets.ts @@ -0,0 +1,50 @@ +import { initialize } from '@iot-app-kit/source-iotsitewise'; +import { IoTSiteWiseClient } from '@aws-sdk/client-iotsitewise'; +import type { IoTEventsClient } from '@aws-sdk/client-iot-events'; +import { AlarmData } from '../hooks/useAlarms'; +import { parseAlarmStateAssetProperty } from '../hooks/useAlarms/transformers'; + +// Transforms an AlarmData object into an object containing a timeSeriesDataQuery and alarm state +export const buildTransformAlarmForSingleQueryWidgets = + ({ + iotSiteWiseClient, + iotEventsClient, + }: { + iotSiteWiseClient?: IoTSiteWiseClient; + iotEventsClient?: IoTEventsClient; + }) => + (alarm: AlarmData) => { + const { inputProperty, assetId, status, state } = alarm; + + const propertyId = inputProperty?.at(0)?.property.id; + const latestState = state?.data?.at(-1); + + if ( + status.isSuccess && + assetId && + propertyId && + iotSiteWiseClient && + iotEventsClient + ) { + const timeSeriesDataQuery = initialize({ + iotSiteWiseClient, + iotEventsClient, + }).query.timeSeriesData({ + assets: [ + { + assetId, + properties: [ + { + propertyId, + }, + ], + }, + ], + }); + + return { + state: parseAlarmStateAssetProperty(latestState)?.value.state, + timeSeriesDataQueries: [timeSeriesDataQuery], + }; + } + }; diff --git a/packages/react-components/stories/kpi/connected-kpi.stories.tsx b/packages/react-components/stories/kpi/connected-kpi.stories.tsx index ca03652fb..2374729cc 100644 --- a/packages/react-components/stories/kpi/connected-kpi.stories.tsx +++ b/packages/react-components/stories/kpi/connected-kpi.stories.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { ComponentMeta, ComponentStory } from '@storybook/react'; import { KPI } from '../../src/components/kpi/kpi'; import { + getSingleValueAlarmDataQuery, getSingleValueTimeSeriesDataQuery, queryConfigured, } from '../utils/query'; @@ -22,7 +23,9 @@ export default { } as ComponentMeta; export const ConnectedKPIWidget: ComponentStory = () => { - if (!queryConfigured()) { + const hasAlarmIds = process.env.ALARM_COMPOSITE_MODEL_ID_1 !== null; + + if (!queryConfigured() || !hasAlarmIds) { return (

All required Env variables not set

@@ -36,6 +39,7 @@ export const ConnectedKPIWidget: ComponentStory = () => {
  • PROPERTY_ID_1
  • PROPERTY_ID_2
  • PROPERTY_ID_3
  • +
  • ALARM_COMPOSITE_MODEL_ID_1
  • ); @@ -49,6 +53,12 @@ export const ConnectedKPIWidget: ComponentStory = () => { query={getSingleValueTimeSeriesDataQuery()} />
    +
    + +
    ); }; diff --git a/packages/react-components/stories/utils/query.ts b/packages/react-components/stories/utils/query.ts index 07fb07886..205be46fe 100644 --- a/packages/react-components/stories/utils/query.ts +++ b/packages/react-components/stories/utils/query.ts @@ -1,5 +1,6 @@ import { getIotEventsClient, getSiteWiseClient } from '@iot-app-kit/core-util'; import { + SiteWiseAlarmDataStreamQuery, SiteWiseDataStreamQuery, initialize, } from '@iot-app-kit/source-iotsitewise'; @@ -124,6 +125,35 @@ export const getSingleValueTimeSeriesDataQuery = ( }); }; +export const getSingleValueAlarmDataQuery = ( + alarmStreamQuery?: SiteWiseAlarmDataStreamQuery +) => { + if (alarmStreamQuery) { + return getIotSiteWiseQuery().alarmData(alarmStreamQuery); + } + + const { assetId } = getAssetQuery(); + const alarmId1 = process.env.ALARM_COMPOSITE_MODEL_ID_1; + + if (!alarmId1) return getIotSiteWiseQuery().alarmData({}); + + return getIotSiteWiseQuery().alarmData({ + alarms: [ + { + assetId: assetId, + alarmComponents: [ + { + assetCompositeModelId: alarmId1, + }, + ], + }, + ], + requestSettings: { + refreshRate: 5000, + }, + }); +}; + export const queryConfigured = () => { try { getEnvCredentials(); diff --git a/turbo.json b/turbo.json index de11b45e4..69afb0486 100644 --- a/turbo.json +++ b/turbo.json @@ -40,6 +40,7 @@ "PROPERTY_ID_1", "PROPERTY_ID_2", "PROPERTY_ID_3", + "ALARM_COMPOSITE_MODEL_ID_1", "REGION", "TZ" ]