diff --git a/Source/Applications/SystemCenter/Model/Node.cs b/Source/Applications/SystemCenter/Model/Node.cs index ca1939866..f796f600d 100644 --- a/Source/Applications/SystemCenter/Model/Node.cs +++ b/Source/Applications/SystemCenter/Model/Node.cs @@ -24,6 +24,7 @@ using System.Web.Http; using GSF.Data.Model; using GSF.Web.Model; +using openXDA.Model; namespace SystemCenter.Model { @@ -53,6 +54,15 @@ public class Node public string AssignedHostRegistrationKey { get; set; } } + [RoutePrefix("api/SystemCenter/Node")] + public class SystemCenterNodeController : ModelController {} + + [RoutePrefix("api/OpenXDA/NodeTypes")] + public class OpenXDANodeTypesController : ModelController {} + + [RoutePrefix("api/OpenXDA/HostRegistration")] + public class OpenXDAHostRegistrationController : ModelController {} + [RoutePrefix("api/OpenXDA/Node")] - public class OpenXDANodeController : ModelController {} + public class OpenXDANodeController : ModelController { } } \ No newline at end of file diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Matcher.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Matcher.tsx index 0b81d1750..cef7261f0 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Matcher.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Matcher.tsx @@ -280,7 +280,7 @@ const Matcher: React.FunctionComponent = (props: {}) => { return return } - else if (params['name'] == "Nodes") { + else if (params['name'] == "TaskRunners") { if (roles.indexOf('Administrator') < 0) return return diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Node/ByNode.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Node/ByNode.tsx index 7307675e8..299b07401 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Node/ByNode.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Node/ByNode.tsx @@ -22,18 +22,36 @@ //****************************************************************************************************** import * as React from 'react'; -import { GenericController, Search, SearchBar, LoadingScreen } from '@gpa-gemstone/react-interactive' +import { ReactIcons } from '@gpa-gemstone/gpa-symbols'; +import { GenericController, Search, SearchBar, LoadingScreen, Modal } from '@gpa-gemstone/react-interactive' import { Table, Column, Paging } from '@gpa-gemstone/react-table' -import { Application } from '@gpa-gemstone/application-typings'; +import { Application, OpenXDA } from '@gpa-gemstone/application-typings'; +import NodeForm from './NodeForm' import { SystemCenter as SC } from '../global' -const defaultSearchcols: Search.IField[] = [ - { label: 'Name', key: 'Name', type: 'string', isPivotField: false }, - { label: 'Minimum Host Count', key: 'MinimumHostCount', type: 'number', isPivotField: false }, - { label: 'Type', key: 'NodeType', type: 'string', isPivotField: false }, - { label: 'Host Registration Key', key: 'HostRegistrationKey', type: 'string', isPivotField: false }, - { label: 'Assigned Host Registration Key', key: 'AssignedHostRegistrationKey', type: 'string', isPivotField: false } -]; +interface INodeType { + ID: number, + Name: string, + AssemblyName: string, + TypeName: string +} + +interface IHostRegistration { + ID: number, + RegistrationKey: string, + APIToken: string, + URL: string, + CheckedIn: string +} + +interface IOpenXDANode { + ID: number, + NodeTypeID: number, + HostRegistrationID: number, + AssignedHostRegistrationID: number, + Name: string, + MinimumHostCount: number +} const ByNode = (props: {Roles: Application.Types.SecurityRoleName[]}) => { const [data, setData] = React.useState([]) @@ -45,10 +63,39 @@ const ByNode = (props: {Roles: Application.Types.SecurityRoleName[]}) => { const [status, setStatus] = React.useState('uninitiated') const [recordsPerPage, setRecordsPerPage] = React.useState(0); const [totalRecords, setTotalRecords] = React.useState(0); + const [nodeTypes, setNodeTypes] = React.useState([]); + const [appHosts, setAppHosts] = React.useState([]) + const [showModal, setShowModal] = React.useState(false) + const [selectedNode, setSelectedNode] = React.useState({ ID: '-1', Name: "", AssignedHostRegistrationKey: '', HostRegistrationKey: '', NodeType: '', MinimumHostCount: 0 }); + const [errors, setErrors] = React.useState([]); + const [refreshCount, refreshData] = React.useState(0); + + + React.useEffect(() => { + if (status === 'uninitiated') { + const nodeTypeController = new GenericController(`${homePath}api/OpenXDA/NodeTypes`, 'Name', true); + const handle = nodeTypeController.Fetch(); + handle.done((d: INodeType[]) => { + setNodeTypes(d); + }).fail((d) => { + setStatus('error'); + }) + } + }, [status]) + + React.useEffect(() => { + const appHostController = new GenericController(`${homePath}api/OpenXDA/HostRegistration`, 'ID', true); + const handle = appHostController.Fetch(); + handle.done((d: IHostRegistration[]) => { + setAppHosts(d); + }).fail((d) => { + setStatus('error'); + }) + }, [status]) React.useEffect(() => { setStatus('loading'); - const nodeController = new GenericController(`${homePath}api/OpenXDA/Node`, 'Name', true) + const nodeController = new GenericController(`${homePath}api/SystemCenter/Node`, 'Name', true) const handle = nodeController.PagedSearch(filters, sortField, ascending, page); handle.done((d) => { setData(JSON.parse(d.Data as unknown as string)); @@ -58,7 +105,28 @@ const ByNode = (props: {Roles: Application.Types.SecurityRoleName[]}) => { setStatus('idle'); }).fail((d) => { setStatus('error'); - }) },[filters, sortField, ascending, page]) + }) + }, [filters, sortField, ascending, page, refreshCount]) + + + const defaultSearchcols: Search.IField[] = [ + { label: 'Name', key: 'Name', type: 'string', isPivotField: false }, + { label: 'Minimum Host Count', key: 'MinimumHostCount', type: 'number', isPivotField: false }, + { label: 'Type', key: 'NodeType', isPivotField: false, type: 'enum', enum: nodeTypes.map((n) => { return { Value: n.Name, Label: n.Name } }) }, + { label: 'Node', key: 'HostRegistrationKey', isPivotField: false, type: 'enum', enum: appHosts.map((h) => { return { Value: h.RegistrationKey, Label: h.RegistrationKey } })}, + { label: 'Assigned Node', key: 'AssignedHostRegistrationKey', isPivotField: false, type: 'enum', enum: appHosts.map((h) => { return { Value: h.RegistrationKey, Label: h.RegistrationKey } })} + ]; + + const convertToXDANode = React.useCallback((node: SC.Node): IOpenXDANode => { + return { + ID: parseInt(node.ID), + MinimumHostCount: node.MinimumHostCount, + NodeTypeID: nodeTypes.find(nt => nt.Name == node.NodeType).ID, + AssignedHostRegistrationID: appHosts.find(ah => ah.RegistrationKey == node.AssignedHostRegistrationKey)?.ID ?? null, + HostRegistrationID: appHosts.find(ah => ah.RegistrationKey == node.HostRegistrationKey)?.ID ?? null, + Name: node.Name + } + }, [nodeTypes, appHosts]) return
@@ -66,7 +134,7 @@ const ByNode = (props: {Roles: Application.Types.SecurityRoleName[]}) => {
CollumnList={defaultSearchcols} SetFilter={setFilters} Direction={'left'} defaultCollumn={{ label: 'Name', key: 'Name', type: 'string', isPivotField: false }} Width={'50%'} Label={'Search'} - ShowLoading={status === 'loading'} ResultNote={status === 'error' ? 'Could not complete search.' : `Displaying Node(s) ${totalRecords > 0 ? (recordsPerPage * page + 1) : 0} - ${recordsPerPage * page + data.length} out of ${totalRecords}`} + ShowLoading={status === 'loading'} ResultNote={status === 'error' ? 'Could not complete search.' : `Displaying TaskRunner(s) ${totalRecords > 0 ? (recordsPerPage * page + 1) : 0} - ${recordsPerPage * page + data.length} out of ${totalRecords}`} StorageID="NodesFilter" > @@ -87,6 +155,7 @@ const ByNode = (props: {Roles: Application.Types.SecurityRoleName[]}) => { }} Selected={(item) => false} KeySelector={(item) => item.ID} + OnClick={(data) => { setSelectedNode(data.row); setShowModal(true) } } > Key={'Name'} @@ -110,7 +179,7 @@ const ByNode = (props: {Roles: Application.Types.SecurityRoleName[]}) => { Field={'MinimumHostCount'} HeaderStyle={{ width: 'auto' }} RowStyle={{ width: 'auto' }} - > Minimum Host Count + > Minimum Node Count Key={'HostRegistrationKey'} @@ -119,8 +188,8 @@ const ByNode = (props: {Roles: Application.Types.SecurityRoleName[]}) => { HeaderStyle={{ width: 'auto' }} RowStyle={{ width: 'auto' }} Content={({ item, field }) => { - return {item[field]} }} - > Host Registration Key + return item[field] === 'N/A' ? item[field] : {item[field]} }} + > Node Key={'AssignedHostRegistrationKey'} @@ -129,8 +198,9 @@ const ByNode = (props: {Roles: Application.Types.SecurityRoleName[]}) => { HeaderStyle={{ width: 'auto' }} RowStyle={{ width: 'auto' }} Content={({ item, field }) => { - return {item[field]} }} - > Assigned Host Registration Key + return item[field] === 'N/A' ? item[field] : {item[field]} + }} + > Assigned Nodes
@@ -140,6 +210,27 @@ const ByNode = (props: {Roles: Application.Types.SecurityRoleName[]}) => {
+ { + setShowModal(false); + if (!c) + return; + new GenericController(`${homePath}api/OpenXDA/Node`, 'ID').DBAction('PATCH', convertToXDANode(selectedNode)).then(() => refreshData(x => x + 1)) + }} + ShowCancel={false} + ShowX={true} + DisableConfirm={errors.length > 0} + ConfirmShowToolTip={errors.length > 0} + ConfirmToolTipContent={errors.map((t, i) =>

{t}

)} > +
+ { return { Value: n.Name, Label: n.Name } })} + HostOptions={appHosts.map((h) => { return { Value: h.RegistrationKey, Label: h.RegistrationKey } })} + /> +
+
} -export default ByNode; \ No newline at end of file +export default ByNode; diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Node/NodeForm.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Node/NodeForm.tsx new file mode 100644 index 000000000..a345ccfb5 --- /dev/null +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Node/NodeForm.tsx @@ -0,0 +1,65 @@ +//****************************************************************************************************** +// NodeForm.tsx - Gbtc +// +// Copyright © 2026, 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: +// ---------------------------------------------------------------------------------------------------- +// 06/17/2026 - Natalie Beatty +// Generated original version of source code. +// +//****************************************************************************************************** + +import * as React from 'react'; +import { useAppSelector } from '../hooks'; +import { Gemstone } from '@gpa-gemstone/application-typings' +import { Input, Select, MultiCheckBoxSelect } from '@gpa-gemstone/react-forms' +import { SelectRoles } from '../Store/UserSettings'; +import { SystemCenter as SC } from '../global' + +interface IProps { + Node: SC.Node, + stateSetter: (customer: SC.Node) => void, + setErrors?: (e: string[]) => void, + NodeTypeOptions: Gemstone.TSX.Interfaces.ILabelValue[] + HostOptions: Gemstone.TSX.Interfaces.ILabelValue[] +} + + +const NodeForm = (props: IProps) => { + const roles = useAppSelector(SelectRoles); + function hasPermissions(): boolean { + if (roles.indexOf('Administrator') < 0 && roles.indexOf('Engineer') < 0) + return false; + return true; + } + function valid(field: keyof (SC.Node)): boolean { + if (field == 'Name') + return props.Node.Name != null // && props.Customer.CustomerKey.length > 0 && props.Customer.CustomerKey.length <= 25; + if (field == 'MinimumHostCount') + return props.Node.MinimumHostCount > 0 && props.Node.MinimumHostCount < 100 + return true; + } + + + return
+ Record={props.Node} Field={'Name'} Label='Name' Feedback={'A unique Key of less than 25 characters is required.'} Valid={valid} Setter={(record) => props.stateSetter(record)} Disabled={!hasPermissions()} /> + Type={'number'} Record={props.Node} Field={'MinimumHostCount'} Label='Minimum Node Count' Feedback='A number between 0 and 100 is required.' Valid={valid} Setter={(record) => props.stateSetter(record)} Disabled={!hasPermissions()} /> + Options={props.NodeTypeOptions} Record={props.Node} Field={'NodeType'} Setter={(record) => props.stateSetter(record)} /> + Record={props.Node} Options={props.HostOptions} Field={'HostRegistrationKey'} Label={'Node'} EmptyOption={true} Setter={(record) => props.stateSetter(record)} /> + Record={props.Node} Options={props.HostOptions} Field={'AssignedHostRegistrationKey'} Label={'Assigned Nodes'} EmptyOption={true} Setter={(record) => props.stateSetter(record)} /> +
+} + +export default NodeForm; \ No newline at end of file diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/SystemCenter.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/SystemCenter.tsx index 08753c1be..88eafa681 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/SystemCenter.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/SystemCenter.tsx @@ -119,7 +119,7 @@ const SystemCenter: React.FunctionComponent = (props: {}) => {
- +