diff --git a/API/Model/SOEController.cs b/API/Model/SOEController.cs index c96eda8..7c4e963 100644 --- a/API/Model/SOEController.cs +++ b/API/Model/SOEController.cs @@ -52,8 +52,8 @@ public class SOEController : RedirectionController public SOEController(IAPICredentialRetriever retriever) : base(retriever) { } #endif - [Route("{eventID:int}/{timeWindow:int}"), HttpGet] - public async ServerResponse Get(int eventID, int timeWindow, CancellationToken token) => + [Route("{eventID:int}/{timeWindow:int}/{widgetID:int}"), HttpGet] + public async ServerResponse Get(int eventID, int timeWindow, int widgetID, CancellationToken token) => await ForwardRequest(token).ConfigureAwait(false); } } diff --git a/TSX/Widget/DynamicSQLResultsTable.tsx b/TSX/Widget/DynamicSQLResultsTable.tsx new file mode 100644 index 0000000..08ec96b --- /dev/null +++ b/TSX/Widget/DynamicSQLResultsTable.tsx @@ -0,0 +1,63 @@ +//****************************************************************************************************** +// DynamicSQLResultsTable.tsx - Gbtc +// +// Copyright (c) 2025, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this +// file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 08/07/2026 - Preston Crawford +// Generated original version of source code. +// +//****************************************************************************************************** + +import React from 'react'; +import { Column, Table } from '@gpa-gemstone/react-table'; + +export type DynamicSQLRow = Record; + +interface IProps { + Data: DynamicSQLRow[]; + MaxHeight?: number; +} + +const DynamicSQLResultsTable = (props: IProps) => { + const fields = Object.keys(props.Data[0] ?? {}); + + return ( + + Data={props.Data} + OnSort={() => { /*Do Nothing*/ }} + SortKey={''} + Ascending={true} + TableClass="table" + TableStyle={{ maxHeight: props.MaxHeight ?? 500 }} + KeySelector={(_data, index = 0) => index} + > + {fields.map(field => + + key={field} + Key={field} + AllowSort={false} + Field={field} + HeaderStyle={{ width: 'auto' }} + RowStyle={{ width: 'auto' }} + > + {field} + + )} + + ); +} + +export default DynamicSQLResultsTable; diff --git a/TSX/Widget/ITOA.tsx b/TSX/Widget/ITOA.tsx index 5915272..b54ca1f 100644 --- a/TSX/Widget/ITOA.tsx +++ b/TSX/Widget/ITOA.tsx @@ -24,21 +24,14 @@ import React from 'react'; import { EventWidget } from '../global'; import { Input, Select, TextArea } from '@gpa-gemstone/react-forms'; -import { Table, Column } from '@gpa-gemstone/react-table'; import { ReactIcons } from '@gpa-gemstone/gpa-symbols'; import { Application } from '@gpa-gemstone/application-typings'; import { Alert } from '@gpa-gemstone/react-interactive'; +import DynamicSQLResultsTable, { DynamicSQLRow } from './DynamicSQLResultsTable'; interface IValue { Value: string | number } -interface ItoaInfo { - StartTime: string, - Cause: string, - Voltage: string - ID: number, - Station: string -} interface ISetting { SQLCommand: string, TimeWindow: number[] @@ -110,12 +103,14 @@ const ITOA: EventWidget.IWidget = { ); }, Widget: (props: EventWidget.IWidgetProps) => { - const [data, setData] = React.useState([]); + const [data, setData] = React.useState([]); const [timeWindow, setTimeWindow] = React.useState(2); const [status, setStatus] = React.useState('uninitiated'); const timeWindowOptions = React.useMemo(() => props.Settings.TimeWindow.map((t) => ({ Value: t.toString(), Label: t.toString() })), [props.Settings.TimeWindow]); + const resultsMaxHeight = Math.min(props.MaxHeight, 200); + //Effect to get ITOA data React.useEffect(() => { setStatus('loading'); const handle = getITOAData(props.HomePath, props.EventID, timeWindow, props.WidgetID); @@ -147,7 +142,7 @@ const ITOA: EventWidget.IWidget = { /> -
+
{status === 'error' ? An error occurred while fetching ITOA data. Please check System Center for more details. @@ -158,54 +153,10 @@ const ITOA: EventWidget.IWidget = {
: - + { /*Do Nothing*/ }} - SortKey={''} - Ascending={true} - TableClass="table" - KeySelector={data => data.ID} - TheadStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%', height: 50 }} - TbodyStyle={{ display: 'block', overflowY: 'auto', width: '100%', maxHeight: props.MaxHeight ?? 500 }} - RowStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} - > - - Key={'Time'} - AllowSort={false} - Field={'StartTime'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Time - - - Key={'Cause'} - AllowSort={false} - Field={'Cause'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Alarm - - - Key={'Station'} - AllowSort={false} - Field={'Station'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Description - - - Key={'Voltage'} - AllowSort={false} - Field={'Voltage'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Voltages - - + MaxHeight={resultsMaxHeight} + /> }
@@ -222,7 +173,7 @@ const getITOAData = (homePath: string, eventID: number, timeWindow: number, widg dataType: 'json', cache: false, async: true - }) as JQuery.jqXHR; + }) as JQuery.jqXHR; }; -export default ITOA; \ No newline at end of file +export default ITOA; diff --git a/TSX/Widget/SOE.tsx b/TSX/Widget/SOE.tsx index 99ff175..c779b65 100644 --- a/TSX/Widget/SOE.tsx +++ b/TSX/Widget/SOE.tsx @@ -23,33 +23,27 @@ import React from 'react'; import { EventWidget } from '../global'; -import { Input, MultiCheckBoxSelect, Select } from '@gpa-gemstone/react-forms'; -import { Table, Column } from '@gpa-gemstone/react-table'; -import cloneDeep from 'lodash/cloneDeep'; +import { Input, MultiCheckBoxSelect, Select, TextArea } from '@gpa-gemstone/react-forms'; import { ReactIcons } from '@gpa-gemstone/gpa-symbols'; import { Application } from '@gpa-gemstone/application-typings'; -import _ from 'lodash'; import { Alert } from '@gpa-gemstone/react-interactive'; +import DynamicSQLResultsTable, { DynamicSQLRow } from './DynamicSQLResultsTable'; interface IValue { Value: string | number } -interface SOEInfo { - Time: string, - Alarm: string, - Status: string -} - interface ISetting { FilterOut: string[], - TimeWindow: number[], + SQLCommand: string, + TimeWindow: number[] } const SOE: EventWidget.IWidget = { Name: 'SOE', DefaultSettings: { FilterOut: ['abnormal', 'close', 'no', 'normal', 'received', 'start', 'trip', 'yes'], + SQLCommand: '', TimeWindow: [2, 10, 60] }, Settings: (props) => { @@ -63,29 +57,33 @@ const SOE: EventWidget.IWidget = { Record={{ Value: item }} Field={'Value'} Setter={(record) => { - const u = _.cloneDeep(props.Settings.FilterOut); - u[i] = record.Value as string; - props.SetSettings({ ...props.Settings, FilterOut: u }); + const filterOut = [...props.Settings.FilterOut]; + filterOut[i] = record.Value as string; + props.SetSettings({ ...props.Settings, FilterOut: filterOut }); }} Valid={() => true} - Label={'Filter ' + i} /> + Label={'Filter ' + i} + Help={'These filters are applied only when the SQL query returns a Status column. Otherwise, they are ignored.'} + />
+ const filterOut = [...props.Settings.FilterOut]; + filterOut.splice(i, 1); + props.SetSettings({ ...props.Settings, FilterOut: filterOut }); + }}> + +
)}
+ props.SetSettings({ ...props.Settings, FilterOut: [...props.Settings.FilterOut, ''] }); + }}> + Add Exclusion Filter +
{props.Settings.TimeWindow?.map((item, i) => @@ -95,9 +93,9 @@ const SOE: EventWidget.IWidget = { Record={{ Value: item }} Field={'Value'} Setter={(record) => { - const u = _.cloneDeep(props.Settings.TimeWindow); - u[i] = record.Value as number; - props.SetSettings({ ...props.Settings, TimeWindow: u }); + const timeWindow = [...props.Settings.TimeWindow]; + timeWindow[i] = record.Value as number; + props.SetSettings({ ...props.Settings, TimeWindow: timeWindow }) }} Valid={() => true} Type={'number'} @@ -105,20 +103,36 @@ const SOE: EventWidget.IWidget = {
+ const timeWindow = [...props.Settings.TimeWindow]; + timeWindow.splice(i, 1); + props.SetSettings({ ...props.Settings, TimeWindow: timeWindow }) + }}> + +
)}
+ const newSettings = { ...props.Settings }; + newSettings.TimeWindow = [...newSettings.TimeWindow, 0]; + props.SetSettings(newSettings); + }}> + Add Time Window + +
+
+
+
+ + Rows={4} + Record={props.Settings} + Field="SQLCommand" + Label="SQL Command" + Valid={() => true} + Setter={props.SetSettings} + />
@@ -126,29 +140,35 @@ const SOE: EventWidget.IWidget = { ); }, Widget: (props: EventWidget.IWidgetProps) => { - const [soeInfo, setSOEInfo] = React.useState([]); - const [statusFilter, setStatusFilter] = React.useState([]) + const [data, setData] = React.useState([]); + const [statusFilter, setStatusFilter] = React.useState([]); const [timeWindow, setTimeWindow] = React.useState(2); - const [filterOptions, setFilterOptions] = React.useState<{ Value: number, Label: string, Selected: boolean }[]>([]) const [status, setStatus] = React.useState('uninitiated'); const timeWindowOptions = React.useMemo(() => props.Settings.TimeWindow.map((t) => ({ Value: t.toString(), Label: t.toString() })), [props.Settings.TimeWindow]); + const resultsMaxHeight = Math.min(props.MaxHeight, 200); - React.useEffect(() => { - setFilterOptions(props.Settings.FilterOut.map((f, i) => ({ Value: i, Label: f.toLowerCase(), Selected: false }))) - }, [props.Settings.FilterOut]); + const filterOptions = React.useMemo(() => props.Settings.FilterOut.map((filter, index) => { + const normalizedFilter = filter.toLowerCase(); + return { Value: index, Label: normalizedFilter, Selected: statusFilter.includes(normalizedFilter) }; + }), [props.Settings.FilterOut, statusFilter]); - //Effect update selected value based on statusFilter - React.useEffect(() => { - setFilterOptions((d) => d.map(f => ({ ...f, Selected: statusFilter.includes(f.Label) }))); - }, [statusFilter]) + const filteredData = React.useMemo(() => data.filter(row => { + const status = row.Status; + + if (typeof status !== 'string') + return true; + + return !statusFilter.includes(status.toLowerCase()); + }), [data, statusFilter]); + //Effect to get soe data React.useEffect(() => { setStatus('loading'); - const handle = getSOEData(props.HomePath, props.EventID, timeWindow); + const handle = getSOEData(props.HomePath, props.EventID, timeWindow, props.WidgetID); handle.done((data) => { - setSOEInfo(data.filter(si => !statusFilter.includes(si.Status.toLowerCase()))); + setData(data); setStatus('idle'); }).fail(() => setStatus('error')); @@ -157,7 +177,7 @@ const SOE: EventWidget.IWidget = { handle.abort(); } }; - }, [props.EventID, props.HomePath, timeWindow, statusFilter]); + }, [props.EventID, props.HomePath, props.WidgetID, timeWindow]); return (
@@ -177,65 +197,29 @@ const SOE: EventWidget.IWidget = { { - const filters = cloneDeep(statusFilter) - const remove = options.filter(o => o.Selected).map(o => o.Label) - const add = options.filter(o => !o.Selected).map(o => o.Label); - setStatusFilter(filters.filter(t => !remove.includes(t)).concat(add as string[])) - }} /> + OnChange={(_evt, options) => { + const remove = options.filter(option => option.Selected).map(option => option.Label as string); + const add = options.filter(option => !option.Selected).map(option => option.Label as string); + setStatusFilter(filters => filters.filter(filter => !remove.includes(filter)).concat(add)); + }} + />
- -
+
{status === 'error' ? An error occurred while fetching SOE data. Please check System Center for more details. - : null} + : null} {status === 'loading' ?
: - - Data={soeInfo} - OnSort={() => { /*Do Nothing*/ }} - SortKey={''} - Ascending={true} - TableClass="table" - KeySelector={data => { return data.Time; /* Todo: Time might not be unique and generate errors, ensure it is or try to use something else */ }} - TheadStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%', height: 50 }} - TbodyStyle={{ display: 'block', overflowY: 'auto', width: '100%', maxHeight: props.MaxHeight ?? 500 }} - RowStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} - > - - Key={'Time'} - AllowSort={false} - Field={'Time'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Time - - - Key={'Alarm'} - AllowSort={false} - Field={'Alarm'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Alarm - - - Key={'Status'} - AllowSort={false} - Field={'Status'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > - Status - - + }
@@ -244,15 +228,15 @@ const SOE: EventWidget.IWidget = { } } -const getSOEData = (homePath: string, eventID: number, timeWindow: number) => { +const getSOEData = (homePath: string, eventID: number, timeWindow: number, widgetID: number) => { return $.ajax({ type: "GET", - url: `${homePath}api/EventWidgets/SOE/${eventID}/${timeWindow}`, + url: `${homePath}api/EventWidgets/SOE/${eventID}/${timeWindow}/${widgetID}`, contentType: "application/json; charset=utf-8", dataType: 'json', cache: false, async: true - }); + }) as JQuery.jqXHR; }; -export default SOE; \ No newline at end of file +export default SOE;