diff --git a/Anvil/Tools/Striker.pm b/Anvil/Tools/Striker.pm index 54d46a22..a6cef8c1 100644 --- a/Anvil/Tools/Striker.pm +++ b/Anvil/Tools/Striker.pm @@ -308,68 +308,89 @@ sub generate_manifest my $self = shift; my $parameter = shift; my $anvil = $self->parent; - my $debug = defined $parameter->{debug} ? $parameter->{debug} : 3; + my $debug = $parameter->{debug} // 3; + + my $domain = $parameter->{domain} // $anvil->data->{cgi}{domain}{value}; + my $manifest_uuid = $parameter->{manifest_uuid} // $anvil->data->{cgi}{manifest_uuid}{value}; + my $name_prefix = $parameter->{prefix} // $anvil->data->{cgi}{prefix}{value}; + my $network_dns = $parameter->{dns} // $anvil->data->{cgi}{dns}{value}; + my $network_mtu = $parameter->{mtu} // $anvil->data->{cgi}{mtu}{value}; + my $network_ntp = $parameter->{ntp} // $anvil->data->{cgi}{ntp}{value}; + my $padded_sequence = $parameter->{sequence} // $anvil->data->{cgi}{sequence}{value}; + $anvil->Log->entry({source => $THIS_FILE, line => __LINE__, level => $debug, key => "log_0125", variables => { method => "Striker->generate_manifest()" }}); $anvil->Database->get_upses({debug => $debug}); $anvil->Database->get_fences({debug => $debug}); - my $manifest_uuid = $anvil->data->{cgi}{manifest_uuid}{value} eq "new" ? "" : $anvil->data->{cgi}{manifest_uuid}{value}; - my $padded_sequence = $anvil->data->{cgi}{sequence}{value}; + $manifest_uuid = $manifest_uuid eq "new" ? "" : $manifest_uuid; + if (length($padded_sequence) == 1) { $padded_sequence = sprintf("%02d", $padded_sequence); } - my $anvil_name = $anvil->data->{cgi}{prefix}{value}."-anvil-".$padded_sequence; - my $node1_name = $anvil->data->{cgi}{prefix}{value}."-a".$padded_sequence."n01"; - my $node2_name = $anvil->data->{cgi}{prefix}{value}."-a".$padded_sequence."n02"; - my $dr1_name = $anvil->data->{cgi}{prefix}{value}."-a".$padded_sequence."dr01"; + + my $anvil_name = $name_prefix."-anvil-".$padded_sequence; + my $node1_name = $name_prefix."-a".$padded_sequence."n01"; + my $node2_name = $name_prefix."-a".$padded_sequence."n02"; + my $dr1_name = $name_prefix."-a".$padded_sequence."dr01"; my $machines = {}; $anvil->Log->variables({source => $THIS_FILE, line => __LINE__, level => $debug, list => { anvil_name => $anvil_name }}); my $manifest_xml = ' - - + + '; foreach my $network ("bcn", "sn", "ifn") { my $count_key = $network."_count"; - $anvil->Log->variables({source => $THIS_FILE, line => __LINE__, level => $debug, list => { "cgi::${count_key}::value" => $anvil->data->{cgi}{$count_key}{value} }}); - foreach my $i (1..$anvil->data->{cgi}{$count_key}{value}) + my $count_value = $parameter->{$count_key} // $anvil->data->{cgi}{$count_key}{value}; + $anvil->Log->variables({source => $THIS_FILE, line => __LINE__, level => $debug, list => { "${count_key}" => $count_value }}); + foreach my $i (1..$count_value) { - my $network_name = $network.$i; - my $network_key = $network_name."_network"; - my $subnet_key = $network_name."_subnet"; - my $gateway_key = $network_name."_gateway"; - $manifest_xml .= ' '."\n"; + my $network_name = $network.$i; + my $network_key = $network_name."_network"; + my $network_value = $parameter->{$network_key} // $anvil->data->{cgi}{$network_key}{value}; + my $subnet_key = $network_name."_subnet"; + my $subnet_value = $parameter->{$subnet_key} // $anvil->data->{cgi}{$subnet_key}{value}; + my $gateway_key = $network_name."_gateway"; + my $gateway_value = $parameter->{$gateway_key} // $anvil->data->{cgi}{$gateway_key}{value}; + + $manifest_xml .= ' '."\n"; # While we're here, gather the network data for the machines. foreach my $machine ("node1", "node2", "dr1") { # Record the network - my $ip_key = $machine."_".$network_name."_ip"; - $machines->{$machine}{network}{$network_name} = defined $anvil->data->{cgi}{$ip_key}{value} ? $anvil->data->{cgi}{$ip_key}{value} : ""; + my $ip_key = $machine."_".$network_name."_ip"; + my $ip_value = ($parameter->{$ip_key} // $anvil->data->{cgi}{$ip_key}{value}) // ""; + + $machines->{$machine}{network}{$network_name} = $ip_value; # On the first loop (bcn1), pull in the other information as well. if (($network eq "bcn") && ($i eq "1")) { # Get the IP. - my $ipmi_ip_key = $machine."_ipmi_ip"; - $machines->{$machine}{ipmi_ip} = defined $anvil->data->{cgi}{$ipmi_ip_key}{value} ? $anvil->data->{cgi}{$ipmi_ip_key}{value} : ""; + my $ipmi_ip_key = $machine."_ipmi_ip"; + my $ipmi_ip_value = ($parameter->{$ipmi_ip_key} // $anvil->data->{cgi}{$ipmi_ip_key}{value}) // ""; + + $machines->{$machine}{ipmi_ip} = $ipmi_ip_value; # Find the UPSes. foreach my $ups_name (sort {$a cmp $b} keys %{$anvil->data->{upses}{ups_name}}) { my $ups_key = $machine."_ups_".$ups_name; - $anvil->data->{cgi}{$ups_key}{value} = "" if not defined $anvil->data->{cgi}{$ups_key}{value}; - $machines->{$machine}{ups}{$ups_name} = $anvil->data->{cgi}{$ups_key}{value} ? "1" : "0"; + my $ups_value = ($parameter->{$ups_key} // $anvil->data->{cgi}{$ups_key}{value}) // ""; + + $machines->{$machine}{ups}{$ups_name} = $ups_value ? "1" : "0"; } # Find the Fence devices. foreach my $fence_name (sort {$a cmp $b} keys %{$anvil->data->{fences}{fence_name}}) { - my $fence_key = $machine."_fence_".$fence_name; - $anvil->data->{cgi}{$fence_key}{value} = "" if not defined $anvil->data->{cgi}{$fence_key}{value}; - $machines->{$machine}{fence}{$fence_name} = $anvil->data->{cgi}{$fence_key}{value}; + my $fence_key = $machine."_fence_".$fence_name; + my $fence_value = ($parameter->{$fence_key} // $anvil->data->{cgi}{$fence_key}{value}) // ""; + + $machines->{$machine}{fence}{$fence_name} = $fence_value; } } } diff --git a/striker-ui-api/src/lib/accessModule.ts b/striker-ui-api/src/lib/accessModule.ts index f1b02268..6365cd54 100644 --- a/striker-ui-api/src/lib/accessModule.ts +++ b/striker-ui-api/src/lib/accessModule.ts @@ -148,6 +148,22 @@ const getAnvilData = ( spawnSyncOptions, ).stdout; +const getLocalHostName = () => { + let result: string; + + try { + result = execModuleSubroutine('host_name', { + subModuleName: 'Get', + }).stdout; + } catch (subError) { + throw new Error(`Failed to get local host name; CAUSE: ${subError}`); + } + + shout(`localHostName=${result}`); + + return result; +}; + const getLocalHostUUID = () => { let result: string; @@ -156,7 +172,7 @@ const getLocalHostUUID = () => { subModuleName: 'Get', }).stdout; } catch (subError) { - throw new Error(`Failed to get localhost UUID; CAUSE: ${subError}`); + throw new Error(`Failed to get local host UUID; CAUSE: ${subError}`); } shout(`localHostUUID=[${result}]`); @@ -201,6 +217,7 @@ export { dbSubRefreshTimestamp, dbWrite, getAnvilData, + getLocalHostName, getLocalHostUUID, getPeerData, execModuleSubroutine as sub, diff --git a/striker-ui-api/src/lib/consts/SERVER_PATHS.ts b/striker-ui-api/src/lib/consts/SERVER_PATHS.ts index f8cc8a2c..132d3015 100644 --- a/striker-ui-api/src/lib/consts/SERVER_PATHS.ts +++ b/striker-ui-api/src/lib/consts/SERVER_PATHS.ts @@ -19,6 +19,7 @@ const EMPTY_SERVER_PATHS: ServerPath = { 'anvil-access-module': {}, 'anvil-configure-host': {}, 'anvil-get-server-screenshot': {}, + 'anvil-join-anvil': {}, 'anvil-manage-keys': {}, 'anvil-manage-power': {}, 'anvil-provision-server': {}, diff --git a/striker-ui-api/src/lib/disassembleEntityId.ts b/striker-ui-api/src/lib/disassembleEntityId.ts new file mode 100644 index 00000000..609d46f3 --- /dev/null +++ b/striker-ui-api/src/lib/disassembleEntityId.ts @@ -0,0 +1,20 @@ +export const getEntityName = (id: string) => id.replace(/\d*$/, ''); + +export const getEntityNumber = (id: string) => + Number.parseInt(id.replace(/^[^\d]*/, '')); + +export const getEntityParts = (id: string) => { + let name = ''; + let number = NaN; + + const matchResult = id.match(/^([^\d]*)(\d*)$/); + + if (matchResult) { + const parts = matchResult; + + name = parts[1]; + number = Number.parseInt(parts[2]); + } + + return { name, number }; +}; diff --git a/striker-ui-api/src/lib/disassembleHostName.ts b/striker-ui-api/src/lib/disassembleHostName.ts new file mode 100644 index 00000000..c30593a5 --- /dev/null +++ b/striker-ui-api/src/lib/disassembleHostName.ts @@ -0,0 +1,8 @@ +export const getHostNameDomain = (hostName: string) => + hostName.replace(/^.*?[.]/, ''); + +export const getHostNamePrefix = (hostName: string) => + hostName.replace(/-.*$/, ''); + +export const getShortHostName = (hostName: string) => + hostName.replace(/[.].*$/, ''); diff --git a/striker-ui-api/src/lib/getShortHostName.ts b/striker-ui-api/src/lib/getShortHostName.ts deleted file mode 100644 index 1cd3daa1..00000000 --- a/striker-ui-api/src/lib/getShortHostName.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const getShortHostName = (hostName: string) => - hostName.replace(/[.].*$/, ''); diff --git a/striker-ui-api/src/lib/request_handlers/command/index.ts b/striker-ui-api/src/lib/request_handlers/command/index.ts index b42e11b0..2888de07 100644 --- a/striker-ui-api/src/lib/request_handlers/command/index.ts +++ b/striker-ui-api/src/lib/request_handlers/command/index.ts @@ -1,4 +1,5 @@ export * from './getHostSSH'; export * from './poweroffHost'; export * from './rebootHost'; +export * from './runManifest'; export * from './updateSystem'; diff --git a/striker-ui-api/src/lib/request_handlers/command/runManifest.ts b/striker-ui-api/src/lib/request_handlers/command/runManifest.ts new file mode 100644 index 00000000..6f98296a --- /dev/null +++ b/striker-ui-api/src/lib/request_handlers/command/runManifest.ts @@ -0,0 +1,196 @@ +import assert from 'assert'; +import { RequestHandler } from 'express'; + +import { REP_PEACEFUL_STRING, REP_UUID } from '../../consts/REG_EXP_PATTERNS'; +import SERVER_PATHS from '../../consts/SERVER_PATHS'; + +import { getAnvilData, job, sub } from '../../accessModule'; +import { sanitize } from '../../sanitize'; +import { stderr } from '../../shell'; + +export const runManifest: RequestHandler< + { manifestUuid: string }, + undefined, + RunManifestRequestBody +> = (request, response) => { + const { + params: { manifestUuid }, + body: { + debug = 2, + description: rawDescription, + hosts: rawHostList = {}, + password: rawPassword, + } = {}, + } = request; + + const description = sanitize(rawDescription, 'string'); + const password = sanitize(rawPassword, 'string'); + + const hostList: ManifestExecutionHostList = {}; + + const handleAssertError = (assertError: unknown) => { + stderr( + `Failed to assert value when trying to run manifest ${manifestUuid}; CAUSE: ${assertError}`, + ); + + response.status(400).send(); + }; + + try { + assert( + REP_PEACEFUL_STRING.test(description), + `Description must be a peaceful string; got: [${description}]`, + ); + + assert( + REP_PEACEFUL_STRING.test(password), + `Password must be a peaceful string; got [${password}]`, + ); + + const uniqueList: Record = {}; + const isHostListUnique = !Object.values(rawHostList).some( + ({ hostNumber, hostType, hostUuid }) => { + const hostId = `${hostType}${hostNumber}`; + assert( + /^node[12]$/.test(hostId), + `Host ID must be "node" followed by 1 or 2; got [${hostId}]`, + ); + + assert( + REP_UUID.test(hostUuid), + `Host UUID assigned to ${hostId} must be a UUIDv4; got [${hostUuid}]`, + ); + + const isIdDuplicate = Boolean(uniqueList[hostId]); + const isUuidDuplicate = Boolean(uniqueList[hostUuid]); + + uniqueList[hostId] = true; + uniqueList[hostUuid] = true; + + hostList[hostId] = { hostNumber, hostType, hostUuid, hostId }; + + return isIdDuplicate || isUuidDuplicate; + }, + ); + + assert(isHostListUnique, `Each entry in hosts must be unique`); + } catch (assertError) { + handleAssertError(assertError); + + return; + } + + let rawHostListData: AnvilDataHostListHash | undefined; + let rawManifestListData: AnvilDataManifestListHash | undefined; + let rawSysData: AnvilDataSysHash | undefined; + + try { + ({ + hosts: rawHostListData, + manifests: rawManifestListData, + sys: rawSysData, + } = getAnvilData<{ + hosts?: AnvilDataHostListHash; + manifests?: AnvilDataManifestListHash; + sys?: AnvilDataSysHash; + }>( + { hosts: true, manifests: true, sys: true }, + { + predata: [ + ['Database->get_hosts'], + [ + 'Striker->load_manifest', + { + debug, + manifest_uuid: manifestUuid, + }, + ], + ], + }, + )); + } catch (subError) { + stderr( + `Failed to get install manifest ${manifestUuid}; CAUSE: ${subError}`, + ); + + response.status(500).send(); + + return; + } + + if (!rawHostListData || !rawManifestListData || !rawSysData) { + response.status(404).send(); + + return; + } + + const { host_uuid: hostUuidMapToData } = rawHostListData; + const { + manifest_uuid: { + [manifestUuid]: { + parsed: { name: manifestName }, + }, + }, + } = rawManifestListData; + const { hosts: { by_uuid: mapToHostNameData = {} } = {} } = rawSysData; + + const joinAnJobs: DBJobParams[] = []; + + let anParams: Record | undefined; + + try { + anParams = Object.values(hostList).reduce>( + (previous, { hostId = '', hostUuid }) => { + const hostName = mapToHostNameData[hostUuid]; + const { anvil_name: anName } = hostUuidMapToData[hostUuid]; + + assert( + anName && anName !== manifestName, + `Host ${hostName} cannot be used for ${manifestName} because it belongs to ${anName}`, + ); + + joinAnJobs.push({ + debug, + file: __filename, + job_command: SERVER_PATHS.usr.sbin['anvil-join-anvil'].self, + job_data: `as_machine=${hostId},manifest_uuid=${manifestUuid}`, + job_description: 'job_0073', + job_host_uuid: hostUuid, + job_name: `join_anvil::${hostId}`, + job_title: 'job_0072', + }); + + previous[`anvil_${hostId}_host_uuid`] = hostUuid; + + return previous; + }, + { + anvil_description: description, + anvil_name: manifestName, + anvil_password: password, + }, + ); + } catch (assertError) { + handleAssertError(assertError); + + return; + } + + try { + const [newAnUuid] = sub('insert_or_update_anvils', { subParams: anParams }) + .stdout as [string]; + + joinAnJobs.forEach((jobParams) => { + jobParams.job_data += `,anvil_uuid=${newAnUuid}`; + job(jobParams); + }); + } catch (subError) { + stderr(`Failed to record new anvil node entry; CAUSE: ${subError}`); + + response.status(500).send(); + + return; + } + + response.status(204).send(); +}; diff --git a/striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts b/striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts index 05da6d53..155fd06b 100644 --- a/striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts +++ b/striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts @@ -1,7 +1,7 @@ import { buildKnownIDCondition } from '../../buildCondition'; import { buildQueryResultModifier } from '../../buildQueryResultModifier'; import { cap } from '../../cap'; -import { getShortHostName } from '../../getShortHostName'; +import { getShortHostName } from '../../disassembleHostName'; import { stdout } from '../../shell'; type ExtractVariableKeyFunction = (parts: string[]) => string; diff --git a/striker-ui-api/src/lib/request_handlers/host/getHost.ts b/striker-ui-api/src/lib/request_handlers/host/getHost.ts index 8e5b2000..1b47cdaa 100644 --- a/striker-ui-api/src/lib/request_handlers/host/getHost.ts +++ b/striker-ui-api/src/lib/request_handlers/host/getHost.ts @@ -4,7 +4,7 @@ import buildGetRequestHandler from '../buildGetRequestHandler'; import { buildQueryHostDetail } from './buildQueryHostDetail'; import { buildQueryResultReducer } from '../../buildQueryResultModifier'; import { toLocal } from '../../convertHostUUID'; -import { getShortHostName } from '../../getShortHostName'; +import { getShortHostName } from '../../disassembleHostName'; import { sanitize } from '../../sanitize'; export const getHost = buildGetRequestHandler((request, buildQueryOptions) => { diff --git a/striker-ui-api/src/lib/request_handlers/manifest/buildManifest.ts b/striker-ui-api/src/lib/request_handlers/manifest/buildManifest.ts new file mode 100644 index 00000000..249499d8 --- /dev/null +++ b/striker-ui-api/src/lib/request_handlers/manifest/buildManifest.ts @@ -0,0 +1,285 @@ +import assert from 'assert'; +import { RequestHandler } from 'express'; + +import { + REP_INTEGER, + REP_IPV4, + REP_IPV4_CSV, + REP_PEACEFUL_STRING, + REP_UUID, +} from '../../consts/REG_EXP_PATTERNS'; + +import { sub } from '../../accessModule'; +import { sanitize } from '../../sanitize'; +import { stdout } from '../../shell'; + +export const buildManifest = ( + ...[request]: Parameters< + RequestHandler< + { manifestUuid?: string }, + undefined, + BuildManifestRequestBody + > + > +) => { + const { + body: { + domain: rawDomain, + hostConfig: { hosts: hostList = {} } = {}, + networkConfig: { + dnsCsv: rawDns, + mtu: rawMtu = 1500, + networks: networkList = {}, + ntpCsv: rawNtp, + } = {}, + prefix: rawPrefix, + sequence: rawSequence, + } = {}, + params: { manifestUuid: rawManifestUuid = 'new' }, + } = request; + + stdout('Begin building install manifest.'); + + const dns = sanitize(rawDns, 'string'); + assert(REP_IPV4_CSV.test(dns), `DNS must be an IPv4 CSV; got [${dns}]`); + + const domain = sanitize(rawDomain, 'string'); + assert( + REP_PEACEFUL_STRING.test(domain), + `Domain must be a peaceful string; got [${domain}]`, + ); + + const manifestUuid = sanitize(rawManifestUuid, 'string'); + assert( + REP_UUID.test(manifestUuid), + `Manifest UUID must be a UUIDv4; got [${manifestUuid}]`, + ); + + const mtu = sanitize(rawMtu, 'number'); + assert(REP_INTEGER.test(String(mtu)), `MTU must be an integer; got [${mtu}]`); + + const ntp = sanitize(rawNtp, 'string'); + + if (ntp) { + assert(REP_IPV4_CSV.test(ntp), `NTP must be an IPv4 CSV; got [${ntp}]`); + } + + const prefix = sanitize(rawPrefix, 'string'); + assert( + REP_PEACEFUL_STRING.test(prefix), + `Prefix must be a peaceful string; got [${prefix}]`, + ); + + const sequence = sanitize(rawSequence, 'number'); + assert( + REP_INTEGER.test(String(sequence)), + `Sequence must be an integer; got [${sequence}]`, + ); + + const { counts: networkCountContainer, networks: networkContainer } = + Object.values(networkList).reduce<{ + counts: Record; + networks: Record; + }>( + ( + previous, + { + networkGateway: rawGateway, + networkMinIp: rawMinIp, + networkNumber: rawNetworkNumber, + networkSubnetMask: rawSubnetMask, + networkType: rawNetworkType, + }, + ) => { + const networkType = sanitize(rawNetworkType, 'string'); + assert( + REP_PEACEFUL_STRING.test(networkType), + `Network type must be a peaceful string; got [${networkType}]`, + ); + + const networkNumber = sanitize(rawNetworkNumber, 'number'); + assert( + REP_INTEGER.test(String(networkNumber)), + `Network number must be an integer; got [${networkNumber}]`, + ); + + const networkId = `${networkType}${networkNumber}`; + + const gateway = sanitize(rawGateway, 'string'); + + if (networkType === 'ifn') { + assert( + REP_IPV4.test(gateway), + `Gateway of ${networkId} must be an IPv4; got [${gateway}]`, + ); + } + + const minIp = sanitize(rawMinIp, 'string'); + assert( + REP_IPV4.test(minIp), + `Minimum IP of ${networkId} must be an IPv4; got [${minIp}]`, + ); + + const subnetMask = sanitize(rawSubnetMask, 'string'); + assert( + REP_IPV4.test(subnetMask), + `Subnet mask of ${networkId} must be an IPv4; got [${subnetMask}]`, + ); + + const { counts: countContainer, networks: networkContainer } = previous; + + const countKey = `${networkType}_count`; + const countValue = countContainer[countKey] ?? 0; + + countContainer[countKey] = countValue + 1; + + const gatewayKey = `${networkId}_gateway`; + const minIpKey = `${networkId}_network`; + const subnetMaskKey = `${networkId}_subnet`; + + networkContainer[gatewayKey] = gateway; + networkContainer[minIpKey] = minIp; + networkContainer[subnetMaskKey] = subnetMask; + + return previous; + }, + { counts: {}, networks: {} }, + ); + + const hostContainer = Object.values(hostList).reduce>( + ( + previous, + { + fences, + hostNumber: rawHostNumber, + hostType: rawHostType, + ipmiIp: rawIpmiIp, + networks, + upses, + }, + ) => { + const hostType = sanitize(rawHostType, 'string'); + assert( + REP_PEACEFUL_STRING.test(hostType), + `Host type must be a peaceful string; got [${hostType}]`, + ); + + const hostNumber = sanitize(rawHostNumber, 'number'); + assert( + REP_INTEGER.test(String(hostNumber)), + `Host number must be an integer; got [${hostNumber}]`, + ); + + const hostId = `${hostType}${hostNumber}`; + + const ipmiIp = sanitize(rawIpmiIp, 'string'); + assert( + REP_IPV4.test(ipmiIp), + `IPMI IP of ${hostId} must be an IPv4; got [${ipmiIp}]`, + ); + + const ipmiIpKey = `${hostId}_ipmi_ip`; + + previous[ipmiIpKey] = ipmiIp; + + Object.values(networks).forEach( + ({ + networkIp: rawIp, + networkNumber: rawNetworkNumber, + networkType: rawNetworkType, + }) => { + const networkType = sanitize(rawNetworkType, 'string'); + assert( + REP_PEACEFUL_STRING.test(networkType), + `Network type must be a peaceful string; got [${networkType}]`, + ); + + const networkNumber = sanitize(rawNetworkNumber, 'number'); + assert( + REP_INTEGER.test(String(networkNumber)), + `Network number must be an integer; got [${networkNumber}]`, + ); + + const networkId = `${networkType}${networkNumber}`; + + const ip = sanitize(rawIp, 'string'); + assert( + REP_IPV4.test(ip), + `IP of host network ${networkId} must be an IPv4; got [${ip}]`, + ); + + const networkIpKey = `${hostId}_${networkId}_ip`; + + previous[networkIpKey] = ip; + }, + ); + + Object.values(fences).forEach( + ({ fenceName: rawFenceName, fencePort: rawPort }) => { + const fenceName = sanitize(rawFenceName, 'string'); + assert( + REP_PEACEFUL_STRING.test(fenceName), + `Fence name must be a peaceful string; got [${fenceName}]`, + ); + + const fenceKey = `${hostId}_fence_${fenceName}`; + + const port = sanitize(rawPort, 'string'); + assert( + REP_PEACEFUL_STRING.test(port), + `Port of ${fenceName} must be a peaceful string; got [${port}]`, + ); + + previous[fenceKey] = port; + }, + ); + + Object.values(upses).forEach( + ({ isUsed: rawIsUsed, upsName: rawUpsName }) => { + const upsName = sanitize(rawUpsName, 'string'); + assert( + REP_PEACEFUL_STRING.test(upsName), + `UPS name must be a peaceful string; got [${upsName}]`, + ); + + const upsKey = `${hostId}_ups_${upsName}`; + + const isUsed = sanitize(rawIsUsed, 'boolean'); + + if (isUsed) { + previous[upsKey] = 'checked'; + } + }, + ); + + return previous; + }, + {}, + ); + + let result: { name: string; uuid: string } | undefined; + + try { + const [uuid, name] = sub('generate_manifest', { + subModuleName: 'Striker', + subParams: { + dns, + domain, + manifest_uuid: manifestUuid, + mtu, + ntp, + prefix, + sequence, + ...networkCountContainer, + ...networkContainer, + ...hostContainer, + }, + }).stdout as [manifestUuid: string, anvilName: string]; + + result = { name, uuid }; + } catch (subError) { + throw new Error(`Failed to generate manifest; CAUSE: ${subError}`); + } + + return result; +}; diff --git a/striker-ui-api/src/lib/request_handlers/manifest/createManifest.ts b/striker-ui-api/src/lib/request_handlers/manifest/createManifest.ts new file mode 100644 index 00000000..b2c5331d --- /dev/null +++ b/striker-ui-api/src/lib/request_handlers/manifest/createManifest.ts @@ -0,0 +1,29 @@ +import { AssertionError } from 'assert'; +import { RequestHandler } from 'express'; + +import { buildManifest } from './buildManifest'; +import { stderr } from '../../shell'; + +export const createManifest: RequestHandler = (...handlerArgs) => { + const [, response] = handlerArgs; + + let result: Record = {}; + + try { + result = buildManifest(...handlerArgs); + } catch (buildError) { + stderr(`Failed to create new install manifest; CAUSE ${buildError}`); + + let code = 500; + + if (buildError instanceof AssertionError) { + code = 400; + } + + response.status(code).send(); + + return; + } + + response.status(201).send(result); +}; diff --git a/striker-ui-api/src/lib/request_handlers/manifest/deleteManifest.ts b/striker-ui-api/src/lib/request_handlers/manifest/deleteManifest.ts new file mode 100644 index 00000000..0ba2d14b --- /dev/null +++ b/striker-ui-api/src/lib/request_handlers/manifest/deleteManifest.ts @@ -0,0 +1,37 @@ +import { RequestHandler } from 'express'; + +import { sub } from '../../accessModule'; +import { stderr, stdout } from '../../shell'; + +export const deleteManifest: RequestHandler< + { manifestUuid: string }, + undefined, + { uuids: string[] } +> = (request, response) => { + const { + params: { manifestUuid: rawManifestUuid }, + body: { uuids: rawManifestUuidList } = {}, + } = request; + + const manifestUuidList: string[] = rawManifestUuidList + ? rawManifestUuidList + : [rawManifestUuid]; + + manifestUuidList.forEach((uuid) => { + stdout(`Begin delete manifest ${uuid}.`); + + try { + sub('insert_or_update_manifests', { + subParams: { delete: 1, manifest_uuid: uuid }, + }); + } catch (subError) { + stderr(`Failed to delete manifest ${uuid}; CAUSE: ${subError}`); + + response.status(500).send(); + + return; + } + }); + + response.status(204).send(); +}; diff --git a/striker-ui-api/src/lib/request_handlers/manifest/getManifest.ts b/striker-ui-api/src/lib/request_handlers/manifest/getManifest.ts new file mode 100644 index 00000000..380acee0 --- /dev/null +++ b/striker-ui-api/src/lib/request_handlers/manifest/getManifest.ts @@ -0,0 +1,34 @@ +import { RequestHandler } from 'express'; + +import buildGetRequestHandler from '../buildGetRequestHandler'; +import { buildQueryResultReducer } from '../../buildQueryResultModifier'; + +export const getManifest: RequestHandler = buildGetRequestHandler( + (response, buildQueryOptions) => { + const query = ` + SELECT + manifest_uuid, + manifest_name + FROM manifests + WHERE manifest_note != 'DELETED' + ORDER BY manifest_name ASC;`; + const afterQueryReturn: QueryResultModifierFunction | undefined = + buildQueryResultReducer<{ [manifestUUID: string]: ManifestOverview }>( + (previous, [manifestUUID, manifestName]) => { + previous[manifestUUID] = { + manifestName, + manifestUUID, + }; + + return previous; + }, + {}, + ); + + if (buildQueryOptions) { + buildQueryOptions.afterQueryReturn = afterQueryReturn; + } + + return query; + }, +); diff --git a/striker-ui-api/src/lib/request_handlers/manifest/getManifestDetail.ts b/striker-ui-api/src/lib/request_handlers/manifest/getManifestDetail.ts new file mode 100644 index 00000000..0da929a9 --- /dev/null +++ b/striker-ui-api/src/lib/request_handlers/manifest/getManifestDetail.ts @@ -0,0 +1,266 @@ +import { RequestHandler } from 'express'; + +import { getAnvilData } from '../../accessModule'; +import { getEntityParts } from '../../disassembleEntityId'; +import { stderr, stdout } from '../../shell'; + +const handleSortEntries = ( + [aId]: T, + [bId]: T, +): number => { + const { name: at, number: an } = getEntityParts(aId); + const { name: bt, number: bn } = getEntityParts(bId); + + let result = 0; + + if (at === bt) { + if (an > bn) { + result = 1; + } else if (an < bn) { + result = -1; + } + } else if (at > bt) { + result = 1; + } else if (at < bt) { + result = -1; + } + + return result; +}; + +/** + * This handler sorts networks in ascending order. But, it groups IFNs at the + * end of the list in ascending order. + * + * When the sort callback returns: + * - positive, element `a` will get a higher index than element `b` + * - negative, element `a` will get a lower index than element `b` + * - zero, elements' index will remain unchanged + */ +const handleSortNetworks = ( + [aId]: T, + [bId]: T, +): number => { + const isAIfn = /^ifn/.test(aId); + const isBIfn = /^ifn/.test(bId); + const { name: at, number: an } = getEntityParts(aId); + const { name: bt, number: bn } = getEntityParts(bId); + + let result = 0; + + if (at === bt) { + if (an > bn) { + result = 1; + } else if (an < bn) { + result = -1; + } + } else if (isAIfn) { + result = 1; + } else if (isBIfn) { + result = -1; + } else if (at > bt) { + result = 1; + } else if (at < bt) { + result = -1; + } + + return result; +}; + +export const getManifestDetail: RequestHandler = (request, response) => { + const { + params: { manifestUUID }, + } = request; + + let rawManifestListData: AnvilDataManifestListHash | undefined; + + try { + ({ manifests: rawManifestListData } = getAnvilData<{ + manifests?: AnvilDataManifestListHash; + }>( + { manifests: true }, + { + predata: [['Striker->load_manifest', { manifest_uuid: manifestUUID }]], + }, + )); + } catch (subError) { + stderr( + `Failed to get install manifest ${manifestUUID}; CAUSE: ${subError}`, + ); + + response.status(500).send(); + + return; + } + + stdout( + `Raw install manifest list:\n${JSON.stringify( + rawManifestListData, + null, + 2, + )}`, + ); + + if (!rawManifestListData) { + response.status(404).send(); + + return; + } + + const { + manifest_uuid: { + [manifestUUID]: { + parsed: { + domain, + fences: fenceUuidList = {}, + machine, + name, + networks: { dns: dnsCsv, mtu, name: networkList, ntp: ntpCsv }, + prefix, + sequence, + upses: upsUuidList = {}, + }, + }, + }, + } = rawManifestListData; + + const manifestData: ManifestDetail = { + domain, + hostConfig: { + hosts: Object.entries(machine) + .sort(handleSortEntries) + .reduce( + ( + previous, + [ + hostId, + { + fence = {}, + ipmi_ip: ipmiIp, + name: hostName, + network, + ups = {}, + }, + ], + ) => { + const { name: hostType, number: hostNumber } = + getEntityParts(hostId); + + stdout(`host=${hostType},n=${hostNumber}`); + + // Only include node-type host(s). + if (hostType !== 'node') { + return previous; + } + + previous[hostId] = { + fences: Object.entries(fence) + .sort(handleSortEntries) + .reduce( + (fences, [fenceName, { port: fencePort }]) => { + const fenceUuidContainer = fenceUuidList[fenceName]; + + if (fenceUuidContainer) { + const { uuid: fenceUuid } = fenceUuidContainer; + + fences[fenceName] = { + fenceName, + fencePort, + fenceUuid, + }; + } + + return fences; + }, + {}, + ), + hostName, + hostNumber, + hostType, + ipmiIp, + networks: Object.entries(network) + .sort(handleSortNetworks) + .reduce( + (hostNetworks, [networkId, { ip: networkIp }]) => { + const { name: networkType, number: networkNumber } = + getEntityParts(networkId); + + stdout(`hostnetwork=${networkType},n=${networkNumber}`); + + hostNetworks[networkId] = { + networkIp, + networkNumber, + networkType, + }; + + return hostNetworks; + }, + {}, + ), + upses: Object.entries(ups) + .sort(handleSortEntries) + .reduce((upses, [upsName, { used }]) => { + const upsUuidContainer = upsUuidList[upsName]; + + if (upsUuidContainer) { + const { uuid: upsUuid } = upsUuidContainer; + + upses[upsName] = { + isUsed: Boolean(used), + upsName, + upsUuid, + }; + } + + return upses; + }, {}), + }; + + return previous; + }, + {}, + ), + }, + name, + networkConfig: { + dnsCsv, + mtu: Number.parseInt(mtu), + networks: Object.entries(networkList) + .sort(handleSortNetworks) + .reduce( + ( + networks, + [ + networkId, + { + gateway: networkGateway, + network: networkMinIp, + subnet: networkSubnetMask, + }, + ], + ) => { + const { name: networkType, number: networkNumber } = + getEntityParts(networkId); + + stdout(`network=${networkType},n=${networkNumber}`); + + networks[networkId] = { + networkGateway, + networkMinIp, + networkNumber, + networkSubnetMask, + networkType, + }; + + return networks; + }, + {}, + ), + ntpCsv, + }, + prefix, + sequence: Number.parseInt(sequence), + }; + + response.status(200).send(manifestData); +}; diff --git a/striker-ui-api/src/lib/request_handlers/manifest/getManifestTemplate.ts b/striker-ui-api/src/lib/request_handlers/manifest/getManifestTemplate.ts new file mode 100644 index 00000000..d3a87085 --- /dev/null +++ b/striker-ui-api/src/lib/request_handlers/manifest/getManifestTemplate.ts @@ -0,0 +1,119 @@ +import { RequestHandler } from 'express'; + +import { dbQuery, getLocalHostName } from '../../accessModule'; +import { + getHostNameDomain, + getHostNamePrefix, + getShortHostName, +} from '../../disassembleHostName'; +import { stderr } from '../../shell'; + +export const getManifestTemplate: RequestHandler = (request, response) => { + let localHostName = ''; + + try { + localHostName = getLocalHostName(); + } catch (subError) { + stderr(String(subError)); + + response.status(500).send(); + + return; + } + + const localShortHostName = getShortHostName(localHostName); + + const domain = getHostNameDomain(localHostName); + const prefix = getHostNamePrefix(localShortHostName); + + let rawQueryResult: Array< + [ + fenceUUID: string, + fenceName: string, + upsUUID: string, + upsName: string, + manifestUuid: string, + lastSequence: string, + ] + >; + + try { + ({ stdout: rawQueryResult } = dbQuery( + `SELECT + a.fence_uuid, + a.fence_name, + b.ups_uuid, + b.ups_name, + c.last_sequence + FROM ( + SELECT + ROW_NUMBER() OVER (ORDER BY fence_name), + fence_uuid, + fence_name + FROM fences + ORDER BY fence_name + ) AS a + FULL JOIN ( + SELECT + ROW_NUMBER() OVER (ORDER BY ups_name), + ups_uuid, + ups_name + FROM upses + ORDER BY ups_name + ) AS b ON a.row_number = b.row_number + FULL JOIN ( + SELECT + ROW_NUMBER() OVER (ORDER BY manifest_name DESC), + CAST( + SUBSTRING(manifest_name, '([\\d]*)$') AS INTEGER + ) AS last_sequence + FROM manifests + ORDER BY manifest_name DESC + LIMIT 1 + ) AS c ON a.row_number = c.row_number;`, + )); + } catch (queryError) { + stderr(`Failed to execute query; CAUSE: ${queryError}`); + + response.status(500).send(); + + return; + } + + const queryResult = rawQueryResult.reduce< + Pick + >( + (previous, [fenceUUID, fenceName, upsUUID, upsName, lastSequence]) => { + const { fences, upses } = previous; + + if (fenceUUID) { + fences[fenceUUID] = { + fenceName, + fenceUUID, + }; + } + + if (upsUUID) { + upses[upsUUID] = { + upsName, + upsUUID, + }; + } + + if (lastSequence) { + previous.sequence = Number.parseInt(lastSequence) + 1; + } + + return previous; + }, + { fences: {}, sequence: 1, upses: {} }, + ); + + const result: ManifestTemplate = { + domain, + prefix, + ...queryResult, + }; + + response.status(200).send(result); +}; diff --git a/striker-ui-api/src/lib/request_handlers/manifest/index.ts b/striker-ui-api/src/lib/request_handlers/manifest/index.ts new file mode 100644 index 00000000..da85b0ec --- /dev/null +++ b/striker-ui-api/src/lib/request_handlers/manifest/index.ts @@ -0,0 +1,6 @@ +export * from './createManifest'; +export * from './deleteManifest'; +export * from './getManifest'; +export * from './getManifestDetail'; +export * from './getManifestTemplate'; +export * from './updateManifest'; diff --git a/striker-ui-api/src/lib/request_handlers/manifest/updateManifest.ts b/striker-ui-api/src/lib/request_handlers/manifest/updateManifest.ts new file mode 100644 index 00000000..728d0af7 --- /dev/null +++ b/striker-ui-api/src/lib/request_handlers/manifest/updateManifest.ts @@ -0,0 +1,34 @@ +import { AssertionError } from 'assert'; +import { RequestHandler } from 'express'; + +import { buildManifest } from './buildManifest'; +import { stderr } from '../../shell'; + +export const updateManifest: RequestHandler = (...args) => { + const [request, response] = args; + const { + params: { manifestUuid }, + } = request; + + let result: Record = {}; + + try { + result = buildManifest(...args); + } catch (buildError) { + stderr( + `Failed to update install manifest ${manifestUuid}; CAUSE: ${buildError}`, + ); + + let code = 500; + + if (buildError instanceof AssertionError) { + code = 400; + } + + response.status(code).send(); + + return; + } + + response.status(200).send(result); +}; diff --git a/striker-ui-api/src/routes/command.ts b/striker-ui-api/src/routes/command.ts index 347c4f37..d547aba1 100644 --- a/striker-ui-api/src/routes/command.ts +++ b/striker-ui-api/src/routes/command.ts @@ -4,6 +4,7 @@ import { getHostSSH, poweroffHost, rebootHost, + runManifest, updateSystem, } from '../lib/request_handlers/command'; @@ -13,6 +14,7 @@ router .put('/inquire-host', getHostSSH) .put('/poweroff-host', poweroffHost) .put('/reboot-host', rebootHost) + .put('/run-manifest/:manifestUuid', runManifest) .put('/update-system', updateSystem); export default router; diff --git a/striker-ui-api/src/routes/index.ts b/striker-ui-api/src/routes/index.ts index 618e952c..ef703777 100644 --- a/striker-ui-api/src/routes/index.ts +++ b/striker-ui-api/src/routes/index.ts @@ -7,6 +7,7 @@ import fenceRouter from './fence'; import fileRouter from './file'; import hostRouter from './host'; import jobRouter from './job'; +import manifestRouter from './manifest'; import networkInterfaceRouter from './network-interface'; import serverRouter from './server'; import sshKeyRouter from './ssh-key'; @@ -21,6 +22,7 @@ const routes: Readonly> = { file: fileRouter, host: hostRouter, job: jobRouter, + manifest: manifestRouter, 'network-interface': networkInterfaceRouter, server: serverRouter, 'ssh-key': sshKeyRouter, diff --git a/striker-ui-api/src/routes/manifest.ts b/striker-ui-api/src/routes/manifest.ts new file mode 100644 index 00000000..6d554b43 --- /dev/null +++ b/striker-ui-api/src/routes/manifest.ts @@ -0,0 +1,23 @@ +import express from 'express'; + +import { + createManifest, + deleteManifest, + getManifest, + getManifestDetail, + getManifestTemplate, + updateManifest, +} from '../lib/request_handlers/manifest'; + +const router = express.Router(); + +router + .delete('/', deleteManifest) + .delete('/manifestUuid', deleteManifest) + .get('/', getManifest) + .get('/template', getManifestTemplate) + .get('/:manifestUUID', getManifestDetail) + .post('/', createManifest) + .put('/:manifestUuid', updateManifest); + +export default router; diff --git a/striker-ui-api/src/types/APIManifest.d.ts b/striker-ui-api/src/types/APIManifest.d.ts new file mode 100644 index 00000000..4d9efbc3 --- /dev/null +++ b/striker-ui-api/src/types/APIManifest.d.ts @@ -0,0 +1,112 @@ +type ManifestOverview = { + manifestName: string; + manifestUUID: string; +}; + +type ManifestDetailNetwork = { + networkGateway: string; + networkMinIp: string; + networkNumber: number; + networkSubnetMask: string; + networkType: string; +}; + +type ManifestDetailNetworkList = { + [networkId: string]: ManifestDetailNetwork; +}; + +type ManifestDetailFence = { + fenceName: string; + fencePort: string; + fenceUuid: string; +}; + +type ManifestDetailFenceList = { + [fenceId: string]: ManifestDetailFence; +}; + +type ManifestDetailHostNetwork = { + networkIp: string; + networkNumber: number; + networkType: string; +}; + +type ManifestDetailHostNetworkList = { + [networkId: string]: ManifestDetailHostNetwork; +}; + +type ManifestDetailUps = { + isUsed: boolean; + upsName: string; + upsUuid: string; +}; + +type ManifestDetailUpsList = { + [upsId: string]: ManifestDetailUps; +}; + +type ManifestDetailHostList = { + [hostId: string]: { + fences: ManifestDetailFenceList; + hostName: string; + hostNumber: number; + hostType: string; + ipmiIp: string; + networks: ManifestDetailHostNetworkList; + upses: ManifestDetailUpsList; + }; +}; + +type ManifestDetail = { + domain: string; + hostConfig: { + hosts: ManifestDetailHostList; + }; + name: string; + networkConfig: { + dnsCsv: string; + mtu: number; + networks: ManifestDetailNetworkList; + ntpCsv: string; + }; + prefix: string; + sequence: number; +}; + +type ManifestExecutionHost = { + hostId?: string; + hostNumber: number; + hostType: string; + hostUuid: string; +}; + +type ManifestExecutionHostList = { + [hostId: string]: ManifestExecutionHost; +}; + +type ManifestTemplate = { + domain: string; + fences: { + [fenceUUID: string]: { + fenceName: string; + fenceUUID: string; + }; + }; + prefix: string; + sequence: number; + upses: { + [upsUUID: string]: { + upsName: string; + upsUUID: string; + }; + }; +}; + +type BuildManifestRequestBody = Omit; + +type RunManifestRequestBody = { + debug?: number; + description: string; + hosts: ManifestExecutionHostList; + password: string; +}; diff --git a/striker-ui-api/src/types/GetAnvilDataFunction.d.ts b/striker-ui-api/src/types/GetAnvilDataFunction.d.ts index 191337b3..0d8c2a0c 100644 --- a/striker-ui-api/src/types/GetAnvilDataFunction.d.ts +++ b/striker-ui-api/src/types/GetAnvilDataFunction.d.ts @@ -13,6 +13,96 @@ type AnvilDataDatabaseHash = { }; }; +type AnvilDataHostListHash = { + host_uuid: { + [hostUuid: string]: { + anvil_name?: string; + anvil_uuid?: string; + host_ipmi: string; + host_key: string; + host_name: string; + host_status: string; + host_type: string; + short_host_name: string; + }; + }; +}; + +type AnvilDataManifestListHash = { + manifest_uuid: { + [manifestUUID: string]: { + parsed: { + domain: string; + fences?: { + [fenceId: string]: { + uuid: string; + }; + }; + machine: { + [hostId: string]: { + fence?: { + [fenceName: string]: { + port: string; + }; + }; + ipmi_ip: string; + name: string; + network: { + [networkId: string]: { + ip: string; + }; + }; + ups?: { + [upsName: string]: { + used: string; + }; + }; + }; + }; + name: string; + networks: { + count: { + [networkType: string]: number; + }; + dns: string; + mtu: string; + name: { + [networkId: string]: { + gateway: string; + network: string; + subnet: string; + }; + }; + ntp: string; + }; + prefix: string; + sequence: string; + upses?: { + [upsId: string]: { + uuid: string; + }; + }; + }; + }; + }; + name_to_uuid: Record; +} & Record< + string, + { + manifest_last_ran: number; + manifest_name: string; + manifest_note: string; + manifest_xml: string; + } +>; + +type AnvilDataSysHash = { + hosts?: { + by_uuid: { [hostUuid: string]: string }; + by_name: { [hostName: string]: string }; + }; +}; + type AnvilDataUPSHash = { [upsName: string]: { agent: string; diff --git a/striker-ui/components/ConfirmDialog.tsx b/striker-ui/components/ConfirmDialog.tsx index 5259db49..319bf313 100644 --- a/striker-ui/components/ConfirmDialog.tsx +++ b/striker-ui/components/ConfirmDialog.tsx @@ -42,7 +42,9 @@ const ConfirmDialog = forwardRef< PaperProps: paperProps = {}, ...restDialogProps } = {}, + disableProceed: isDisableProceed, formContent: isFormContent, + loading: isLoading = false, loadingAction: isLoadingAction = false, onActionAppend, onCancelAppend, @@ -59,8 +61,11 @@ const ConfirmDialog = forwardRef< ref, ) => { const { sx: paperSx, ...restPaperProps } = paperProps; - const { sx: proceedButtonSx, ...restProceedButtonProps } = - proceedButtonProps; + const { + disabled: proceedButtonDisabled = isDisableProceed, + sx: proceedButtonSx, + ...restProceedButtonProps + } = proceedButtonProps; const [isOpen, setIsOpen] = useState(openInitially); @@ -141,6 +146,7 @@ const ConfirmDialog = forwardRef< const proceedButtonElement = useMemo( () => ( | undefined>( + const combinedScrollBoxSx = useMemo(() => { + let result: SxProps | undefined; + + if (isScrollContent) { + let overflowX: 'hidden' | undefined; + let paddingTop: string | undefined; + + if (isFormContent) { + overflowX = 'hidden'; + paddingTop = '.6em'; + } + + result = { + maxHeight: '60vh', + overflowX, + overflowY: 'scroll', + paddingRight: '.4em', + paddingTop, + ...scrollBoxSx, + }; + } + + return result; + }, [isFormContent, isScrollContent, scrollBoxSx]); + + const contentAreaElement = useMemo( () => - isScrollContent - ? { - maxHeight: '60vh', - overflowY: 'scroll', - ...scrollBoxSx, - } - : undefined, - [isScrollContent, scrollBoxSx], + isLoading ? ( + + ) : ( + <> + + {contentElement} + + {preActionArea} + {actionAreaElement} + + ), + [ + actionAreaElement, + combinedScrollBoxSx, + contentElement, + isLoading, + preActionArea, + restScrollBoxProps, + ], ); useImperativeHandle( @@ -232,11 +275,7 @@ const ConfirmDialog = forwardRef< onSubmit={contentContainerSubmitEventHandler} {...contentContainerProps} > - - {contentElement} - - {preActionArea} - {actionAreaElement} + {contentAreaElement} ); diff --git a/striker-ui/components/IconButton/IconButton.tsx b/striker-ui/components/IconButton/IconButton.tsx index 1eae04e5..9d73871c 100644 --- a/striker-ui/components/IconButton/IconButton.tsx +++ b/striker-ui/components/IconButton/IconButton.tsx @@ -1,6 +1,9 @@ import { + Add as MUIAddIcon, + Close as MUICloseIcon, Done as MUIDoneIcon, Edit as MUIEditIcon, + PlayCircle as MUIPlayCircleIcon, Visibility as MUIVisibilityIcon, VisibilityOff as MUIVisibilityOffIcon, } from '@mui/icons-material'; @@ -14,6 +17,7 @@ import { createElement, FC, ReactNode, useMemo } from 'react'; import { BLACK, + BLUE, BORDER_RADIUS, DISABLED, GREY, @@ -39,21 +43,36 @@ const NormalIconButton = styled(MUIIconButton)({ color: GREY, }); -const MAP_TO_VISIBILITY_ICON: IconButtonMapToStateIcon = { - false: MUIVisibilityIcon, - true: MUIVisibilityOffIcon, +const MAP_TO_ADD_ICON: IconButtonMapToStateIconBundle = { + none: { iconType: MUIAddIcon }, }; -const MAP_TO_EDIT_ICON: IconButtonMapToStateIcon = { - false: MUIEditIcon, - true: MUIDoneIcon, +const MAP_TO_CLOSE_ICON: IconButtonMapToStateIconBundle = { + none: { iconType: MUICloseIcon }, +}; + +const MAP_TO_EDIT_ICON: IconButtonMapToStateIconBundle = { + false: { iconType: MUIEditIcon }, + true: { iconType: MUIDoneIcon, iconProps: { sx: { color: BLUE } } }, +}; + +const MAP_TO_PLAY_ICON: IconButtonMapToStateIconBundle = { + none: { iconType: MUIPlayCircleIcon }, +}; + +const MAP_TO_VISIBILITY_ICON: IconButtonMapToStateIconBundle = { + false: { iconType: MUIVisibilityIcon }, + true: { iconType: MUIVisibilityOffIcon }, }; const MAP_TO_MAP_PRESET: Record< - IconButtonPresetMapToStateIcon, - IconButtonMapToStateIcon + IconButtonPresetMapToStateIconBundle, + IconButtonMapToStateIconBundle > = { + add: MAP_TO_ADD_ICON, + close: MAP_TO_CLOSE_ICON, edit: MAP_TO_EDIT_ICON, + play: MAP_TO_PLAY_ICON, visibility: MAP_TO_VISIBILITY_ICON, }; @@ -68,11 +87,11 @@ const IconButton: FC = ({ iconProps, mapPreset, mapToIcon: externalMapToIcon, - state, + state = 'none', variant = 'contained', ...restIconButtonProps }) => { - const mapToIcon = useMemo( + const mapToIcon = useMemo( () => externalMapToIcon ?? (mapPreset && MAP_TO_MAP_PRESET[mapPreset]), [externalMapToIcon, mapPreset], ); @@ -81,19 +100,22 @@ const IconButton: FC = ({ let result: ReactNode; if (mapToIcon) { - const iconElementType: CreatableComponent | undefined = state - ? mapToIcon[state] ?? defaultIcon - : defaultIcon; - - if (iconElementType) { - result = createElement(iconElementType, iconProps); + const { iconType, iconProps: presetIconProps } = mapToIcon[state] ?? { + iconType: defaultIcon, + }; + + if (iconType) { + result = createElement(iconType, { + ...presetIconProps, + ...iconProps, + }); } } else { result = children; } return result; - }, [children, mapToIcon, state, defaultIcon, iconProps]); + }, [mapToIcon, state, defaultIcon, iconProps, children]); const iconButtonElementType = useMemo( () => MAP_TO_VARIANT[variant], [variant], diff --git a/striker-ui/components/InputWithRef.tsx b/striker-ui/components/InputWithRef.tsx index 16f769aa..7c0ad269 100644 --- a/striker-ui/components/InputWithRef.tsx +++ b/striker-ui/components/InputWithRef.tsx @@ -5,6 +5,7 @@ import { forwardRef, ReactElement, useCallback, + useEffect, useImperativeHandle, useMemo, useState, @@ -12,7 +13,6 @@ import { import createInputOnChangeHandler from '../lib/createInputOnChangeHandler'; import { createTestInputFunction } from '../lib/test_input'; -import useIsFirstRender from '../hooks/useIsFirstRender'; type InputWithRefOptionalPropsWithDefault< TypeName extends keyof MapToInputType, @@ -26,6 +26,7 @@ type InputWithRefOptionalPropsWithoutDefault< > = { inputTestBatch?: InputTestBatch; onFirstRender?: InputFirstRenderFunction; + onUnmount?: () => void; valueKey?: CreateInputOnChangeHandlerOptions['valueKey']; }; @@ -69,6 +70,7 @@ const InputWithRef = forwardRef( input, inputTestBatch, onFirstRender, + onUnmount, required: isRequired = INPUT_WITH_REF_DEFAULT_PROPS.required, valueKey, valueType = INPUT_WITH_REF_DEFAULT_PROPS.valueType as TypeName, @@ -96,8 +98,6 @@ const InputWithRef = forwardRef( ...restInitProps } = inputProps; - const isFirstRender = useIsFirstRender(); - const [inputValue, setInputValue] = useState(initValue); const [isChangedByUser, setIsChangedByUser] = useState(false); @@ -166,7 +166,14 @@ const InputWithRef = forwardRef( [initOnFocus, inputTestBatch], ); - if (isFirstRender) { + /** + * Using any setState function synchronously in the render function + * directly will trigger the 'cannot update a component while readering a + * different component' warning. This can be solved by wrapping the + * setState call(s) in a useEffect hook because it executes **after** the + * render function completes. + */ + useEffect(() => { const isValid = testInput?.call(null, { inputs: { [INPUT_TEST_ID]: { value: inputValue } }, @@ -174,7 +181,11 @@ const InputWithRef = forwardRef( }) ?? false; onFirstRender?.call(null, { isValid }); - } + + return onUnmount; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useImperativeHandle( ref, diff --git a/striker-ui/components/ManageFence/CommonFenceInputGroup.tsx b/striker-ui/components/ManageFence/CommonFenceInputGroup.tsx index 9c70146e..cccfee64 100644 --- a/striker-ui/components/ManageFence/CommonFenceInputGroup.tsx +++ b/striker-ui/components/ManageFence/CommonFenceInputGroup.tsx @@ -24,7 +24,6 @@ const MAP_TO_INPUT_BUILDER: MapToInputBuilder = { input={ ({ + formUtils, + knownFences, + knownUpses, + previous: { + hostConfig: previousHostConfig, + networkConfig: previousNetworkConfig = {}, + ...previousAnId + } = {}, +}: AddManifestInputGroupProps): ReactElement => { + const { networks: previousNetworkList = DEFAULT_NETWORK_LIST } = + previousNetworkConfig; + + const [networkList, setNetworkList] = + useState(previousNetworkList); + + const networkListEntries = useMemo( + () => Object.entries(networkList), + [networkList], + ); + + return ( + + + + + + ); +}; + +export default AddManifestInputGroup; diff --git a/striker-ui/components/ManageManifest/AnHostConfigInputGroup.tsx b/striker-ui/components/ManageManifest/AnHostConfigInputGroup.tsx new file mode 100644 index 00000000..6f27710c --- /dev/null +++ b/striker-ui/components/ManageManifest/AnHostConfigInputGroup.tsx @@ -0,0 +1,130 @@ +import { ReactElement, useMemo } from 'react'; + +import AnHostInputGroup from './AnHostInputGroup'; +import Grid from '../Grid'; + +const INPUT_ID_PREFIX_AN_HOST_CONFIG = 'an-host-config-input'; + +const INPUT_GROUP_ID_PREFIX_AHC = `${INPUT_ID_PREFIX_AN_HOST_CONFIG}-group`; +const INPUT_GROUP_CELL_ID_PREFIX_AHC = `${INPUT_GROUP_ID_PREFIX_AHC}-cell`; + +const DEFAULT_HOST_LIST: ManifestHostList = { + node1: { + hostNumber: 1, + hostType: 'node', + }, + node2: { + hostNumber: 2, + hostType: 'node', + }, +}; + +const AnHostConfigInputGroup = ({ + formUtils, + knownFences = {}, + knownUpses = {}, + networkListEntries, + previous: { hosts: previousHostList = DEFAULT_HOST_LIST } = {}, +}: AnHostConfigInputGroupProps): ReactElement => { + const hostListEntries = useMemo( + () => Object.entries(previousHostList), + [previousHostList], + ); + const knownFenceListValues = useMemo( + () => Object.values(knownFences), + [knownFences], + ); + const knownUpsListValues = useMemo( + () => Object.values(knownUpses), + [knownUpses], + ); + + const hostListGridLayout = useMemo( + () => + hostListEntries.reduce( + (previous, [hostId, previousHostArgs]) => { + const { + fences: previousFenceList = {}, + hostNumber, + hostType, + ipmiIp, + networks: previousNetworkList = {}, + upses: previousUpsList = {}, + }: ManifestHost = previousHostArgs; + + const fences = knownFenceListValues.reduce( + (fenceList, { fenceName }) => { + const { [fenceName]: { fencePort = '' } = {} } = + previousFenceList; + + fenceList[fenceName] = { fenceName, fencePort }; + + return fenceList; + }, + {}, + ); + const networks = networkListEntries.reduce( + (networkList, [networkId, { networkNumber, networkType }]) => { + const { [networkId]: { networkIp = '' } = {} } = + previousNetworkList; + + networkList[networkId] = { + networkIp, + networkNumber, + networkType, + }; + + return networkList; + }, + {}, + ); + const upses = knownUpsListValues.reduce( + (upsList, { upsName }) => { + const { [upsName]: { isUsed = true } = {} } = previousUpsList; + + upsList[upsName] = { isUsed, upsName }; + + return upsList; + }, + {}, + ); + + const cellId = `${INPUT_GROUP_CELL_ID_PREFIX_AHC}-${hostId}`; + + previous[cellId] = { + children: ( + + ), + md: 3, + sm: 2, + }; + + return previous; + }, + {}, + ), + [ + formUtils, + hostListEntries, + knownFenceListValues, + knownUpsListValues, + networkListEntries, + ], + ); + + return ( + + ); +}; + +export default AnHostConfigInputGroup; diff --git a/striker-ui/components/ManageManifest/AnHostInputGroup.tsx b/striker-ui/components/ManageManifest/AnHostInputGroup.tsx new file mode 100644 index 00000000..e0153b35 --- /dev/null +++ b/striker-ui/components/ManageManifest/AnHostInputGroup.tsx @@ -0,0 +1,404 @@ +import { ReactElement, useMemo } from 'react'; + +import FlexBox from '../FlexBox'; +import Grid from '../Grid'; +import InputWithRef from '../InputWithRef'; +import OutlinedInputWithLabel from '../OutlinedInputWithLabel'; +import { InnerPanel, InnerPanelBody, InnerPanelHeader } from '../Panels'; +import SwitchWithLabel from '../SwitchWithLabel'; +import { + buildIPAddressTestBatch, + buildPeacefulStringTestBatch, +} from '../../lib/test_input'; +import { BodyText } from '../Text'; + +const INPUT_ID_PREFIX_AN_HOST = 'an-host-input'; + +const INPUT_CELL_ID_PREFIX_AH = `${INPUT_ID_PREFIX_AN_HOST}-cell`; + +const INPUT_LABEL_AH_IPMI_IP = 'IPMI IP'; + +const MAP_TO_AH_INPUT_HANDLER: MapToManifestFormInputHandler = { + fence: (container, input) => { + const { + dataset: { hostId = '', fenceId = '', fenceName = '' }, + value: fencePort, + } = input; + const { + hostConfig: { + hosts: { [hostId]: host }, + }, + } = container; + const { fences = {} } = host; + + fences[fenceId] = { + fenceName, + fencePort, + }; + host.fences = fences; + }, + host: (container, input) => { + const { + dataset: { hostId = '', hostNumber: rawHostNumber = '', hostType = '' }, + } = input; + const hostNumber = Number.parseInt(rawHostNumber, 10); + + container.hostConfig.hosts[hostId] = { + hostNumber, + hostType, + }; + }, + ipmi: (container, input) => { + const { + dataset: { hostId = '' }, + value: ipmiIp, + } = input; + const { + hostConfig: { + hosts: { [hostId]: host }, + }, + } = container; + + host.ipmiIp = ipmiIp; + }, + network: (container, input) => { + const { + dataset: { + hostId = '', + networkId = '', + networkNumber: rawNetworkNumber = '', + networkType = '', + }, + value: networkIp, + } = input; + const { + hostConfig: { + hosts: { [hostId]: host }, + }, + } = container; + const { networks = {} } = host; + const networkNumber = Number.parseInt(rawNetworkNumber, 10); + + networks[networkId] = { + networkIp, + networkNumber, + networkType, + }; + host.networks = networks; + }, + ups: (container, input) => { + const { + checked: isUsed, + dataset: { hostId = '', upsId = '', upsName = '' }, + } = input; + const { + hostConfig: { + hosts: { [hostId]: host }, + }, + } = container; + const { upses = {} } = host; + + upses[upsId] = { + isUsed, + upsName, + }; + host.upses = upses; + }, +}; + +const GRID_COLUMNS = { xs: 1, sm: 2, md: 3 }; +const GRID_SPACING = '1em'; + +const buildInputIdAHFencePort = (hostId: string, fenceId: string): string => + `${INPUT_ID_PREFIX_AN_HOST}-${hostId}-${fenceId}-port`; + +const buildInputIdAHIpmiIp = (hostId: string): string => + `${INPUT_ID_PREFIX_AN_HOST}-${hostId}-ipmi-ip`; + +const buildInputIdAHNetworkIp = (hostId: string, networkId: string): string => + `${INPUT_ID_PREFIX_AN_HOST}-${hostId}-${networkId}-ip`; + +const buildInputIdAHUpsPowerHost = (hostId: string, upsId: string): string => + `${INPUT_ID_PREFIX_AN_HOST}-${hostId}-${upsId}-power-host`; + +const AnHostInputGroup = ({ + formUtils: { + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + buildInputUnmountFunction, + setMessage, + }, + hostId, + hostNumber, + hostType, + previous: { + fences: fenceList = {}, + ipmiIp: previousIpmiIp, + networks: networkList = {}, + upses: upsList = {}, + } = {}, + // Props that depend on others. + hostLabel = `${hostType} ${hostNumber}`, +}: AnHostInputGroupProps): ReactElement => { + const fenceListEntries = useMemo( + () => Object.entries(fenceList), + [fenceList], + ); + const networkListEntries = useMemo( + () => Object.entries(networkList), + [networkList], + ); + const upsListEntries = useMemo(() => Object.entries(upsList), [upsList]); + + const isShowUpsListGrid = useMemo( + () => Boolean(upsListEntries.length), + [upsListEntries.length], + ); + + const inputIdAHHost = useMemo( + () => `${INPUT_ID_PREFIX_AN_HOST}-${hostId}`, + [hostId], + ); + const inputIdAHIpmiIp = useMemo(() => buildInputIdAHIpmiIp(hostId), [hostId]); + + const inputCellIdAHIpmiIp = useMemo( + () => `${INPUT_CELL_ID_PREFIX_AH}-${hostId}-ipmi-ip`, + [hostId], + ); + + const fenceListGridLayout = useMemo( + () => + fenceListEntries.reduce( + (previous, [fenceId, { fenceName, fencePort }]) => { + const cellId = `${INPUT_CELL_ID_PREFIX_AH}-${hostId}-${fenceId}-port`; + + const inputId = buildInputIdAHFencePort(hostId, fenceId); + const inputLabel = `Port on ${fenceName}`; + + previous[cellId] = { + children: ( + + } + inputTestBatch={buildPeacefulStringTestBatch( + `${hostId} ${inputLabel}`, + () => { + setMessage(inputId); + }, + { onFinishBatch: buildFinishInputTestBatchFunction(inputId) }, + (message) => { + setMessage(inputId, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(inputId)} + required + /> + ), + }; + + return previous; + }, + {}, + ), + [ + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + fenceListEntries, + hostId, + setMessage, + ], + ); + + const networkListGridLayout = useMemo( + () => + networkListEntries.reduce( + (previous, [networkId, { networkIp, networkNumber, networkType }]) => { + const cellId = `${INPUT_CELL_ID_PREFIX_AH}-${hostId}-${networkId}-ip`; + + const inputId = buildInputIdAHNetworkIp(hostId, networkId); + const inputLabel = `${networkType.toUpperCase()} ${networkNumber} IP`; + + previous[cellId] = { + children: ( + + } + inputTestBatch={buildIPAddressTestBatch( + `${hostId} ${inputLabel}`, + () => { + setMessage(inputId); + }, + { onFinishBatch: buildFinishInputTestBatchFunction(inputId) }, + (message) => { + setMessage(inputId, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(inputId)} + onUnmount={buildInputUnmountFunction(inputId)} + required + /> + ), + }; + + return previous; + }, + {}, + ), + [ + networkListEntries, + hostId, + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + buildInputUnmountFunction, + setMessage, + ], + ); + + const upsListGridLayout = useMemo( + () => + upsListEntries.reduce( + (previous, [upsId, { isUsed, upsName }]) => { + const cellId = `${INPUT_CELL_ID_PREFIX_AH}-${hostId}-${upsId}-power-host`; + + const inputId = buildInputIdAHUpsPowerHost(hostId, upsId); + const inputLabel = `Uses ${upsName}`; + + previous[cellId] = { + children: ( + + } + valueType="boolean" + /> + ), + }; + + return previous; + }, + {}, + ), + [hostId, upsListEntries], + ); + + const upsListGrid = useMemo( + () => + isShowUpsListGrid && ( + + ), + [isShowUpsListGrid, upsListGridLayout], + ); + + return ( + + + {hostLabel} + + + + + + } + inputTestBatch={buildIPAddressTestBatch( + `${hostId} ${INPUT_LABEL_AH_IPMI_IP}`, + () => { + setMessage(inputIdAHIpmiIp); + }, + { + onFinishBatch: + buildFinishInputTestBatchFunction(inputIdAHIpmiIp), + }, + (message) => { + setMessage(inputIdAHIpmiIp, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction( + inputIdAHIpmiIp, + )} + onUnmount={buildInputUnmountFunction(inputIdAHIpmiIp)} + required + /> + ), + }, + ...fenceListGridLayout, + }} + spacing={GRID_SPACING} + /> + {upsListGrid} + + + + ); +}; + +export { + INPUT_ID_PREFIX_AN_HOST, + MAP_TO_AH_INPUT_HANDLER, + buildInputIdAHFencePort, + buildInputIdAHIpmiIp, + buildInputIdAHNetworkIp, + buildInputIdAHUpsPowerHost, +}; + +export default AnHostInputGroup; diff --git a/striker-ui/components/ManageManifest/AnIdInputGroup.tsx b/striker-ui/components/ManageManifest/AnIdInputGroup.tsx new file mode 100644 index 00000000..04f78ad0 --- /dev/null +++ b/striker-ui/components/ManageManifest/AnIdInputGroup.tsx @@ -0,0 +1,135 @@ +import { ReactElement } from 'react'; + +import Grid from '../Grid'; +import InputWithRef from '../InputWithRef'; +import OutlinedInputWithLabel from '../OutlinedInputWithLabel'; +import { + buildNumberTestBatch, + buildPeacefulStringTestBatch, +} from '../../lib/test_input'; + +const INPUT_ID_PREFIX_AN_ID = 'an-id-input'; + +const INPUT_ID_AI_DOMAIN = `${INPUT_ID_PREFIX_AN_ID}-domain`; +const INPUT_ID_AI_PREFIX = `${INPUT_ID_PREFIX_AN_ID}-prefix`; +const INPUT_ID_AI_SEQUENCE = `${INPUT_ID_PREFIX_AN_ID}-sequence`; + +const INPUT_LABEL_AI_DOMAIN = 'Domain name'; +const INPUT_LABEL_AI_PREFIX = 'Prefix'; +const INPUT_LABEL_AI_SEQUENCE = 'Sequence'; + +const AnIdInputGroup = < + M extends { + [K in + | typeof INPUT_ID_AI_DOMAIN + | typeof INPUT_ID_AI_PREFIX + | typeof INPUT_ID_AI_SEQUENCE]: string; + }, +>({ + formUtils: { + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + setMessage, + }, + previous: { + domain: previousDomain, + prefix: previousPrefix, + sequence: previousSequence, + } = {}, +}: AnIdInputGroupProps): ReactElement => ( + + } + inputTestBatch={buildPeacefulStringTestBatch( + INPUT_LABEL_AI_PREFIX, + () => { + setMessage(INPUT_ID_AI_PREFIX); + }, + { + onFinishBatch: + buildFinishInputTestBatchFunction(INPUT_ID_AI_PREFIX), + }, + (message) => { + setMessage(INPUT_ID_AI_PREFIX, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(INPUT_ID_AI_PREFIX)} + required + /> + ), + }, + 'an-id-input-cell-domain': { + children: ( + + } + inputTestBatch={buildPeacefulStringTestBatch( + INPUT_LABEL_AI_DOMAIN, + () => { + setMessage(INPUT_ID_AI_DOMAIN); + }, + { + onFinishBatch: + buildFinishInputTestBatchFunction(INPUT_ID_AI_DOMAIN), + }, + (message) => { + setMessage(INPUT_ID_AI_DOMAIN, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(INPUT_ID_AI_DOMAIN)} + required + /> + ), + }, + 'an-id-input-cell-sequence': { + children: ( + + } + inputTestBatch={buildNumberTestBatch( + INPUT_LABEL_AI_SEQUENCE, + () => { + setMessage(INPUT_ID_AI_SEQUENCE); + }, + { + onFinishBatch: + buildFinishInputTestBatchFunction(INPUT_ID_AI_SEQUENCE), + }, + (message) => { + setMessage(INPUT_ID_AI_SEQUENCE, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(INPUT_ID_AI_SEQUENCE)} + required + valueType="number" + /> + ), + }, + }} + spacing="1em" + /> +); + +export { INPUT_ID_AI_DOMAIN, INPUT_ID_AI_PREFIX, INPUT_ID_AI_SEQUENCE }; + +export default AnIdInputGroup; diff --git a/striker-ui/components/ManageManifest/AnNetworkConfigInputGroup.tsx b/striker-ui/components/ManageManifest/AnNetworkConfigInputGroup.tsx new file mode 100644 index 00000000..4faee782 --- /dev/null +++ b/striker-ui/components/ManageManifest/AnNetworkConfigInputGroup.tsx @@ -0,0 +1,388 @@ +import { ReactElement, useCallback, useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import NETWORK_TYPES from '../../lib/consts/NETWORK_TYPES'; + +import AnNetworkInputGroup from './AnNetworkInputGroup'; +import buildObjectStateSetterCallback from '../../lib/buildObjectStateSetterCallback'; +import Grid from '../Grid'; +import IconButton from '../IconButton'; +import InputWithRef from '../InputWithRef'; +import OutlinedInputWithLabel from '../OutlinedInputWithLabel'; +import { + buildIpCsvTestBatch, + buildNumberTestBatch, +} from '../../lib/test_input'; + +const INPUT_ID_PREFIX_AN_NETWORK_CONFIG = 'an-network-config-input'; + +const INPUT_CELL_ID_PREFIX_ANC = `${INPUT_ID_PREFIX_AN_NETWORK_CONFIG}-cell`; + +const INPUT_ID_ANC_DNS = `${INPUT_ID_PREFIX_AN_NETWORK_CONFIG}-dns`; +const INPUT_ID_ANC_MTU = `${INPUT_ID_PREFIX_AN_NETWORK_CONFIG}-mtu`; +const INPUT_ID_ANC_NTP = `${INPUT_ID_PREFIX_AN_NETWORK_CONFIG}-ntp`; + +const INPUT_LABEL_ANC_DNS = 'DNS'; +const INPUT_LABEL_ANC_MTU = 'MTU'; +const INPUT_LABEL_ANC_NTP = 'NTP'; + +const DEFAULT_DNS_CSV = '8.8.8.8,8.8.4.4'; + +const NETWORK_TYPE_ENTRIES = Object.entries(NETWORK_TYPES); + +const assertIfn = (type: string) => type === 'ifn'; +const assertMn = (type: string) => type === 'mn'; + +const AnNetworkConfigInputGroup = < + M extends MapToInputTestID & { + [K in + | typeof INPUT_ID_ANC_DNS + | typeof INPUT_ID_ANC_MTU + | typeof INPUT_ID_ANC_NTP]: string; + }, +>({ + formUtils, + networkListEntries, + previous: { + dnsCsv: previousDnsCsv = DEFAULT_DNS_CSV, + mtu: previousMtu, + ntpCsv: previousNtpCsv, + } = {}, + setNetworkList, +}: AnNetworkConfigInputGroupProps): ReactElement => { + const { + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + setMessage, + setMessageRe, + } = formUtils; + + const getNetworkNumber = useCallback( + ( + type: string, + { + input = networkListEntries, + end = networkListEntries.length, + }: { + input?: Array<[string, ManifestNetwork]>; + end?: number; + } = {}, + ) => { + const limit = end - 1; + + let netNum = 0; + + input.every(([, { networkType }], networkIndex) => { + if (networkType === type) { + netNum += 1; + } + + return networkIndex < limit; + }); + + return netNum; + }, + [networkListEntries], + ); + + const networkTypeOptions = useMemo( + () => + NETWORK_TYPE_ENTRIES.map(([key, value]) => ({ + displayValue: value, + value: key, + })), + [], + ); + + const buildNetwork = useCallback( + ({ + networkMinIp = '', + networkSubnetMask = '', + networkType = 'ifn', + // Params that depend on others. + networkGateway = assertIfn(networkType) ? '' : undefined, + networkNumber = getNetworkNumber(networkType) + 1, + }: Partial = {}): { + network: ManifestNetwork; + networkId: string; + } => ({ + network: { + networkGateway, + networkMinIp, + networkNumber, + networkSubnetMask, + networkType, + }, + networkId: uuidv4(), + }), + [getNetworkNumber], + ); + + const setNetwork = useCallback( + (key: string, value?: ManifestNetwork) => + setNetworkList(buildObjectStateSetterCallback(key, value)), + [setNetworkList], + ); + + const handleNetworkTypeChange = useCallback( + ( + { networkId: targetId, networkType: previousType }, + { target: { value } }, + ) => { + const newType = String(value); + + let isIdMatch = false; + let newTypeNumber = 0; + + const newList = networkListEntries.reduce( + (previous, [networkId, networkValue]) => { + const { networkNumber: initnn, networkType: initnt } = networkValue; + + let networkNumber = initnn; + let networkType = initnt; + + if (networkId === targetId) { + isIdMatch = true; + networkType = newType; + setMessageRe(RegExp(networkId)); + } + + const isTypeMatch = networkType === newType; + + if (isTypeMatch) { + newTypeNumber += 1; + } + + if (isIdMatch) { + if (isTypeMatch) { + networkNumber = newTypeNumber; + } else if (networkType === previousType) { + networkNumber -= 1; + } + + previous[networkId] = { + ...networkValue, + networkNumber, + networkType, + }; + } else { + previous[networkId] = networkValue; + } + + return previous; + }, + {}, + ); + + setNetworkList(newList); + }, + [networkListEntries, setMessageRe, setNetworkList], + ); + + const handleNetworkRemove = useCallback( + ({ networkId: rmId, networkType: rmType }) => { + let isIdMatch = false; + let networkNumber = 0; + + const newList = networkListEntries.reduce( + (previous, [networkId, networkValue]) => { + if (networkId === rmId) { + isIdMatch = true; + } else { + const { networkType } = networkValue; + + if (networkType === rmType) { + networkNumber += 1; + } + + previous[networkId] = isIdMatch + ? { ...networkValue, networkNumber } + : networkValue; + } + + return previous; + }, + {}, + ); + + setNetworkList(newList); + }, + [networkListEntries, setNetworkList], + ); + + const networksGridLayout = useMemo(() => { + let result: GridLayout = {}; + + result = networkListEntries.reduce( + ( + previous, + [ + networkId, + { + networkGateway, + networkMinIp, + networkNumber, + networkSubnetMask, + networkType, + }, + ], + ) => { + const cellId = `${INPUT_CELL_ID_PREFIX_ANC}-${networkId}`; + + const isFirstNetwork = networkNumber === 1; + const isIfn = assertIfn(networkType); + const isMn = assertMn(networkType); + const isOptional = isMn || !isFirstNetwork; + + previous[cellId] = { + children: ( + + ), + md: 3, + sm: 2, + }; + + return previous; + }, + result, + ); + + return result; + }, [ + formUtils, + networkListEntries, + networkTypeOptions, + handleNetworkRemove, + handleNetworkTypeChange, + ]); + + return ( + { + const { network: newNet, networkId: newNetId } = buildNetwork(); + + setNetwork(newNetId, newNet); + }} + /> + ), + display: 'flex', + justifyContent: 'center', + md: 3, + sm: 2, + }, + 'an-network-config-input-cell-dns': { + children: ( + + } + inputTestBatch={buildIpCsvTestBatch( + INPUT_LABEL_ANC_DNS, + () => { + setMessage(INPUT_ID_ANC_DNS); + }, + { + onFinishBatch: + buildFinishInputTestBatchFunction(INPUT_ID_ANC_DNS), + }, + (message) => { + setMessage(INPUT_ID_ANC_DNS, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(INPUT_ID_ANC_DNS)} + required + /> + ), + }, + 'an-network-config-input-cell-ntp': { + children: ( + + } + inputTestBatch={buildIpCsvTestBatch( + INPUT_LABEL_ANC_NTP, + () => { + setMessage(INPUT_ID_ANC_NTP); + }, + { + onFinishBatch: + buildFinishInputTestBatchFunction(INPUT_ID_ANC_NTP), + }, + (message) => { + setMessage(INPUT_ID_ANC_NTP, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(INPUT_ID_ANC_NTP)} + /> + ), + }, + 'an-network-config-input-cell-mtu': { + children: ( + + } + inputTestBatch={buildNumberTestBatch( + INPUT_LABEL_ANC_MTU, + () => { + setMessage(INPUT_ID_ANC_MTU); + }, + { + onFinishBatch: + buildFinishInputTestBatchFunction(INPUT_ID_ANC_MTU), + }, + (message) => { + setMessage(INPUT_ID_ANC_MTU, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(INPUT_ID_ANC_MTU)} + valueType="number" + /> + ), + }, + }} + spacing="1em" + /> + ); +}; + +export { INPUT_ID_ANC_DNS, INPUT_ID_ANC_MTU, INPUT_ID_ANC_NTP }; + +export default AnNetworkConfigInputGroup; diff --git a/striker-ui/components/ManageManifest/AnNetworkInputGroup.tsx b/striker-ui/components/ManageManifest/AnNetworkInputGroup.tsx new file mode 100644 index 00000000..73eaba0a --- /dev/null +++ b/striker-ui/components/ManageManifest/AnNetworkInputGroup.tsx @@ -0,0 +1,345 @@ +import { ReactElement, ReactNode, useMemo } from 'react'; + +import NETWORK_TYPES from '../../lib/consts/NETWORK_TYPES'; + +import Grid from '../Grid'; +import IconButton from '../IconButton'; +import InputWithRef from '../InputWithRef'; +import OutlinedInputWithLabel from '../OutlinedInputWithLabel'; +import { InnerPanel, InnerPanelBody, InnerPanelHeader } from '../Panels'; +import SelectWithLabel from '../SelectWithLabel'; +import { buildIPAddressTestBatch } from '../../lib/test_input'; + +const INPUT_ID_PREFIX_AN_NETWORK = 'an-network-input'; + +const INPUT_CELL_ID_PREFIX_AN = `${INPUT_ID_PREFIX_AN_NETWORK}-cell`; + +const MAP_TO_AN_INPUT_HANDLER: MapToManifestFormInputHandler = { + gateway: (container, input) => { + const { + dataset: { networkId = '' }, + value, + } = input; + const { + networkConfig: { networks }, + } = container; + + networks[networkId].networkGateway = value; + }, + minip: (container, input) => { + const { + dataset: { networkId = '' }, + value, + } = input; + const { + networkConfig: { networks }, + } = container; + + networks[networkId].networkMinIp = value; + }, + network: (container, input) => { + const { + dataset: { networkId = '', networkNumber: rawNn = '', networkType = '' }, + } = input; + const { + networkConfig: { networks }, + } = container; + const networkNumber = Number.parseInt(rawNn, 10); + + networks[networkId] = { + networkNumber, + networkType, + } as ManifestNetwork; + }, + subnetmask: (container, input) => { + const { + dataset: { networkId = '' }, + value, + } = input; + const { + networkConfig: { networks }, + } = container; + + networks[networkId].networkSubnetMask = value; + }, +}; + +const buildInputIdANGateway = (networkId: string): string => + `${INPUT_ID_PREFIX_AN_NETWORK}-${networkId}-gateway`; + +const buildInputIdANMinIp = (networkId: string): string => + `${INPUT_ID_PREFIX_AN_NETWORK}-${networkId}-min-ip`; + +const buildInputIdANNetworkType = (networkId: string): string => + `${INPUT_ID_PREFIX_AN_NETWORK}-${networkId}-network-type`; + +const buildInputIdANSubnetMask = (networkId: string): string => + `${INPUT_ID_PREFIX_AN_NETWORK}-${networkId}-subnet-mask`; + +const AnNetworkInputGroup = ({ + formUtils: { + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + buildInputUnmountFunction, + setMessage, + }, + inputGatewayLabel = 'Gateway', + inputMinIpLabel = 'IP address', + inputSubnetMaskLabel = 'Subnet mask', + networkId, + networkNumber, + networkType, + networkTypeOptions, + onClose, + onNetworkTypeChange, + previous: { + gateway: previousGateway, + minIp: previousIpAddress, + subnetMask: previousSubnetMask, + } = {}, + readonlyNetworkName: isReadonlyNetworkName, + showCloseButton: isShowCloseButton, + showGateway: isShowGateway, +}: AnNetworkInputGroupProps): ReactElement => { + const networkName = useMemo( + () => `${NETWORK_TYPES[networkType]} ${networkNumber}`, + [networkNumber, networkType], + ); + + const inputCellIdGateway = useMemo( + () => `${INPUT_CELL_ID_PREFIX_AN}-${networkId}-gateway`, + [networkId], + ); + const inputCellIdIp = useMemo( + () => `${INPUT_CELL_ID_PREFIX_AN}-${networkId}-ip`, + [networkId], + ); + const inputCellIdSubnetMask = useMemo( + () => `${INPUT_CELL_ID_PREFIX_AN}-${networkId}-subnet-mask`, + [networkId], + ); + + const inputIdANNetwork = useMemo( + () => `${INPUT_ID_PREFIX_AN_NETWORK}-${networkId}`, + [networkId], + ); + + const inputIdGateway = useMemo( + () => buildInputIdANGateway(networkId), + [networkId], + ); + const inputIdMinIp = useMemo( + () => buildInputIdANMinIp(networkId), + [networkId], + ); + const inputIdNetworkType = useMemo( + () => buildInputIdANNetworkType(networkId), + [networkId], + ); + const inputIdSubnetMask = useMemo( + () => buildInputIdANSubnetMask(networkId), + [networkId], + ); + + const inputCellGatewayDisplay = useMemo( + () => (isShowGateway ? undefined : 'none'), + [isShowGateway], + ); + + const closeButtonElement = useMemo( + () => + isShowCloseButton && ( + { + onClose?.call(null, { networkId, networkType }, ...args); + }} + sx={{ + padding: '.2em', + position: 'absolute', + right: '-.6rem', + top: '-.2rem', + }} + /> + ), + [isShowCloseButton, networkId, networkType, onClose], + ); + + const inputGatewayElement = useMemo(() => { + let result: ReactNode; + + if (isShowGateway && inputIdGateway) { + result = ( + + } + inputTestBatch={buildIPAddressTestBatch( + `${networkName} ${inputGatewayLabel}`, + () => { + setMessage(inputIdGateway); + }, + { + onFinishBatch: buildFinishInputTestBatchFunction(inputIdGateway), + }, + (message) => { + setMessage(inputIdGateway, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(inputIdGateway)} + onUnmount={buildInputUnmountFunction(inputIdGateway)} + required={isShowGateway} + /> + ); + } + + return result; + }, [ + isShowGateway, + inputIdGateway, + networkId, + inputGatewayLabel, + previousGateway, + networkName, + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + buildInputUnmountFunction, + setMessage, + ]); + + return ( + + + { + onNetworkTypeChange?.call( + null, + { networkId, networkType }, + ...args, + ); + }} + selectItems={networkTypeOptions} + selectProps={{ + renderValue: () => networkName, + }} + value={networkType} + /> + } + /> + {closeButtonElement} + + + + + } + inputTestBatch={buildIPAddressTestBatch( + `${networkName} ${inputMinIpLabel}`, + () => { + setMessage(inputIdMinIp); + }, + { + onFinishBatch: + buildFinishInputTestBatchFunction(inputIdMinIp), + }, + (message) => { + setMessage(inputIdMinIp, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(inputIdMinIp)} + onUnmount={buildInputUnmountFunction(inputIdMinIp)} + required + /> + ), + }, + [inputCellIdSubnetMask]: { + children: ( + + } + inputTestBatch={buildIPAddressTestBatch( + `${networkName} ${inputSubnetMaskLabel}`, + () => { + setMessage(inputIdSubnetMask); + }, + { + onFinishBatch: + buildFinishInputTestBatchFunction(inputIdSubnetMask), + }, + (message) => { + setMessage(inputIdSubnetMask, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction( + inputIdSubnetMask, + )} + onUnmount={buildInputUnmountFunction(inputIdSubnetMask)} + required + /> + ), + }, + [inputCellIdGateway]: { + children: inputGatewayElement, + display: inputCellGatewayDisplay, + }, + }} + spacing="1em" + /> + + + ); +}; + +export { + INPUT_ID_PREFIX_AN_NETWORK, + MAP_TO_AN_INPUT_HANDLER, + buildInputIdANGateway, + buildInputIdANMinIp, + buildInputIdANNetworkType, + buildInputIdANSubnetMask, +}; + +export default AnNetworkInputGroup; diff --git a/striker-ui/components/ManageManifest/EditManifestInputGroup.tsx b/striker-ui/components/ManageManifest/EditManifestInputGroup.tsx new file mode 100644 index 00000000..94ab3472 --- /dev/null +++ b/striker-ui/components/ManageManifest/EditManifestInputGroup.tsx @@ -0,0 +1,39 @@ +import { ReactElement } from 'react'; + +import { + INPUT_ID_AI_DOMAIN, + INPUT_ID_AI_PREFIX, + INPUT_ID_AI_SEQUENCE, +} from './AnIdInputGroup'; +import { + INPUT_ID_ANC_DNS, + INPUT_ID_ANC_MTU, + INPUT_ID_ANC_NTP, +} from './AnNetworkConfigInputGroup'; +import AddManifestInputGroup from './AddManifestInputGroup'; + +const EditManifestInputGroup = < + M extends { + [K in + | typeof INPUT_ID_AI_DOMAIN + | typeof INPUT_ID_AI_PREFIX + | typeof INPUT_ID_AI_SEQUENCE + | typeof INPUT_ID_ANC_DNS + | typeof INPUT_ID_ANC_MTU + | typeof INPUT_ID_ANC_NTP]: string; + }, +>({ + formUtils, + knownFences, + knownUpses, + previous, +}: EditManifestInputGroupProps): ReactElement => ( + +); + +export default EditManifestInputGroup; diff --git a/striker-ui/components/ManageManifest/ManageManifestPanel.tsx b/striker-ui/components/ManageManifest/ManageManifestPanel.tsx new file mode 100644 index 00000000..a6dbdc3f --- /dev/null +++ b/striker-ui/components/ManageManifest/ManageManifestPanel.tsx @@ -0,0 +1,572 @@ +import { FC, ReactNode, useCallback, useMemo, useRef, useState } from 'react'; + +import API_BASE_URL from '../../lib/consts/API_BASE_URL'; + +import AddManifestInputGroup from './AddManifestInputGroup'; +import { + INPUT_ID_AI_DOMAIN, + INPUT_ID_AI_PREFIX, + INPUT_ID_AI_SEQUENCE, +} from './AnIdInputGroup'; +import { + INPUT_ID_PREFIX_AN_HOST, + MAP_TO_AH_INPUT_HANDLER, +} from './AnHostInputGroup'; +import { + INPUT_ID_PREFIX_AN_NETWORK, + MAP_TO_AN_INPUT_HANDLER, +} from './AnNetworkInputGroup'; +import { + INPUT_ID_ANC_DNS, + INPUT_ID_ANC_MTU, + INPUT_ID_ANC_NTP, +} from './AnNetworkConfigInputGroup'; +import api from '../../lib/api'; +import ConfirmDialog from '../ConfirmDialog'; +import EditManifestInputGroup from './EditManifestInputGroup'; +import FlexBox from '../FlexBox'; +import FormDialog from '../FormDialog'; +import handleAPIError from '../../lib/handleAPIError'; +import IconButton from '../IconButton'; +import List from '../List'; +import MessageGroup, { MessageGroupForwardedRefContent } from '../MessageGroup'; +import { Panel, PanelHeader } from '../Panels'; +import periodicFetch from '../../lib/fetchers/periodicFetch'; +import RunManifestInputGroup, { + buildInputIdRMHost, + INPUT_ID_RM_AN_CONFIRM_PASSWORD, + INPUT_ID_RM_AN_DESCRIPTION, + INPUT_ID_RM_AN_PASSWORD, +} from './RunManifestInputGroup'; +import Spinner from '../Spinner'; +import { BodyText, HeaderText } from '../Text'; +import useConfirmDialogProps from '../../hooks/useConfirmDialogProps'; +import useFormUtils from '../../hooks/useFormUtils'; +import useIsFirstRender from '../../hooks/useIsFirstRender'; +import useProtectedState from '../../hooks/useProtectedState'; + +const MSG_ID_API = 'api'; + +const getFormData = ( + ...[{ target }]: DivFormEventHandlerParameters +): APIBuildManifestRequestBody => { + const { elements } = target as HTMLFormElement; + + const { value: domain } = elements.namedItem( + INPUT_ID_AI_DOMAIN, + ) as HTMLInputElement; + const { value: prefix } = elements.namedItem( + INPUT_ID_AI_PREFIX, + ) as HTMLInputElement; + const { value: rawSequence } = elements.namedItem( + INPUT_ID_AI_SEQUENCE, + ) as HTMLInputElement; + const { value: dnsCsv } = elements.namedItem( + INPUT_ID_ANC_DNS, + ) as HTMLInputElement; + const { value: rawMtu } = elements.namedItem( + INPUT_ID_ANC_MTU, + ) as HTMLInputElement; + const { value: ntpCsv } = elements.namedItem( + INPUT_ID_ANC_NTP, + ) as HTMLInputElement; + + const mtu = Number.parseInt(rawMtu, 10); + const sequence = Number.parseInt(rawSequence, 10); + + return Object.values(elements).reduce( + (previous, element) => { + const { id: inputId } = element; + + if (RegExp(`^${INPUT_ID_PREFIX_AN_HOST}`).test(inputId)) { + const input = element as HTMLInputElement; + + const { + dataset: { handler: key = '' }, + } = input; + + MAP_TO_AH_INPUT_HANDLER[key]?.call(null, previous, input); + } else if (RegExp(`^${INPUT_ID_PREFIX_AN_NETWORK}`).test(inputId)) { + const input = element as HTMLInputElement; + + const { + dataset: { handler: key = '' }, + } = input; + + MAP_TO_AN_INPUT_HANDLER[key]?.call(null, previous, input); + } + + return previous; + }, + { + domain, + hostConfig: { hosts: {} }, + networkConfig: { + dnsCsv, + mtu, + networks: {}, + ntpCsv, + }, + prefix, + sequence, + }, + ); +}; + +const getRunFormData = ( + mdetailHosts: ManifestHostList, + ...[{ target }]: DivFormEventHandlerParameters +): APIRunManifestRequestBody => { + const { elements } = target as HTMLFormElement; + + const { value: description } = elements.namedItem( + INPUT_ID_RM_AN_DESCRIPTION, + ) as HTMLInputElement; + const { value: password } = elements.namedItem( + INPUT_ID_RM_AN_PASSWORD, + ) as HTMLInputElement; + + const hosts = Object.entries(mdetailHosts).reduce< + APIRunManifestRequestBody['hosts'] + >((previous, [hostId, { hostNumber, hostType }]) => { + const inputId = buildInputIdRMHost(hostId); + const { value: hostUuid } = elements.namedItem(inputId) as HTMLInputElement; + + previous[hostId] = { hostNumber, hostType, hostUuid }; + + return previous; + }, {}); + + return { description, hosts, password }; +}; + +const ManageManifestPanel: FC = () => { + const isFirstRender = useIsFirstRender(); + + const confirmDialogRef = useRef({}); + const addManifestFormDialogRef = useRef({}); + const editManifestFormDialogRef = useRef( + {}, + ); + const runManifestFormDialogRef = useRef({}); + const messageGroupRef = useRef({}); + + const [confirmDialogProps, setConfirmDialogProps] = useConfirmDialogProps(); + + const [hostOverviews, setHostOverviews] = useProtectedState< + APIHostOverviewList | undefined + >(undefined); + const [isEditManifests, setIsEditManifests] = useState(false); + const [isLoadingHostOverviews, setIsLoadingHostOverviews] = + useProtectedState(true); + const [isLoadingManifestDetail, setIsLoadingManifestDetail] = + useProtectedState(true); + const [isLoadingManifestTemplate, setIsLoadingManifestTemplate] = + useProtectedState(true); + const [isSubmittingForm, setIsSubmittingForm] = + useProtectedState(false); + const [manifestDetail, setManifestDetail] = useProtectedState< + APIManifestDetail | undefined + >(undefined); + const [manifestTemplate, setManifestTemplate] = useProtectedState< + APIManifestTemplate | undefined + >(undefined); + + const { data: manifestOverviews, isLoading: isLoadingManifestOverviews } = + periodicFetch(`${API_BASE_URL}/manifest`, { + refreshInterval: 60000, + }); + + const formUtils = useFormUtils( + [ + INPUT_ID_AI_DOMAIN, + INPUT_ID_AI_PREFIX, + INPUT_ID_AI_SEQUENCE, + INPUT_ID_ANC_DNS, + INPUT_ID_ANC_MTU, + INPUT_ID_ANC_NTP, + ], + messageGroupRef, + ); + const { isFormInvalid, setMessage } = formUtils; + + const runFormUtils = useFormUtils( + [ + INPUT_ID_RM_AN_CONFIRM_PASSWORD, + INPUT_ID_RM_AN_DESCRIPTION, + INPUT_ID_RM_AN_PASSWORD, + ], + messageGroupRef, + ); + const { isFormInvalid: isRunFormInvalid } = runFormUtils; + + const { + hostConfig: { hosts: mdetailHosts = {} } = {}, + name: mdetailName, + uuid: mdetailUuid, + } = useMemo>( + () => manifestDetail ?? {}, + [manifestDetail], + ); + const { + domain: mtemplateDomain, + fences: knownFences, + prefix: mtemplatePrefix, + sequence: mtemplateSequence, + upses: knownUpses, + } = useMemo>( + () => manifestTemplate ?? {}, + [manifestTemplate], + ); + + const submitForm = useCallback( + ({ + body, + getErrorMsg, + method, + successMsg, + url, + }: { + body: Record; + getErrorMsg: (parentMsg: ReactNode) => ReactNode; + method: 'post' | 'put'; + successMsg: ReactNode; + url: string; + }) => { + setIsSubmittingForm(true); + + api[method](url, body) + .then(() => { + setMessage(MSG_ID_API, { + children: successMsg, + }); + }) + .catch((apiError) => { + const emsg = handleAPIError(apiError); + + emsg.children = getErrorMsg(emsg.children); + setMessage(MSG_ID_API, emsg); + }) + .finally(() => { + setIsSubmittingForm(false); + }); + }, + [setIsSubmittingForm, setMessage], + ); + + const addManifestFormDialogProps = useMemo( + () => ({ + actionProceedText: 'Add', + content: ( + + ), + onSubmitAppend: (...args) => { + const body = getFormData(...args); + + setConfirmDialogProps({ + actionProceedText: 'Add', + content: <>, + onProceedAppend: () => { + submitForm({ + body, + getErrorMsg: (parentMsg) => ( + <>Failed to add install manifest. {parentMsg} + ), + method: 'post', + successMsg: 'Successfully added install manifest', + url: '/manifest', + }); + }, + titleText: `Add install manifest?`, + }); + + confirmDialogRef.current.setOpen?.call(null, true); + }, + titleText: 'Add an install manifest', + }), + [ + formUtils, + knownFences, + knownUpses, + mtemplateDomain, + mtemplatePrefix, + mtemplateSequence, + setConfirmDialogProps, + submitForm, + ], + ); + + const editManifestFormDialogProps = useMemo( + () => ({ + actionProceedText: 'Edit', + content: ( + + ), + onSubmitAppend: (...args) => { + const body = getFormData(...args); + + setConfirmDialogProps({ + actionProceedText: 'Edit', + content: <>, + onProceedAppend: () => { + submitForm({ + body, + getErrorMsg: (parentMsg) => ( + <>Failed to update install manifest. {parentMsg} + ), + method: 'put', + successMsg: `Successfully updated install manifest ${mdetailName}`, + url: `/manifest/${mdetailUuid}`, + }); + }, + titleText: `Update install manifest ${mdetailName}?`, + }); + + confirmDialogRef.current.setOpen?.call(null, true); + }, + loading: isLoadingManifestDetail, + titleText: `Update install manifest ${mdetailName}`, + }), + [ + formUtils, + knownFences, + knownUpses, + manifestDetail, + isLoadingManifestDetail, + mdetailName, + setConfirmDialogProps, + submitForm, + mdetailUuid, + ], + ); + + const runManifestFormDialogProps = useMemo( + () => ({ + actionProceedText: 'Run', + content: ( + + ), + loading: isLoadingManifestDetail, + onSubmitAppend: (...args) => { + const body = getRunFormData(mdetailHosts, ...args); + + setConfirmDialogProps({ + actionProceedText: 'Run', + content: <>, + onProceedAppend: () => { + submitForm({ + body, + getErrorMsg: (parentMsg) => ( + <>Failed to run install manifest. {parentMsg} + ), + method: 'put', + successMsg: `Successfully ran install manifest ${mdetailName}`, + url: `/command/run-manifest/${mdetailUuid}`, + }); + }, + titleText: `Run install manifest ${mdetailName}?`, + }); + + confirmDialogRef.current.setOpen?.call(null, true); + }, + titleText: `Run install manifest ${mdetailName}`, + }), + [ + runFormUtils, + knownFences, + hostOverviews, + knownUpses, + manifestDetail, + isLoadingManifestDetail, + mdetailName, + mdetailHosts, + setConfirmDialogProps, + submitForm, + mdetailUuid, + ], + ); + + const getManifestDetail = useCallback( + (manifestUuid: string, finallyAppend?: () => void) => { + setIsLoadingManifestDetail(true); + + api + .get(`manifest/${manifestUuid}`) + .then(({ data }) => { + data.uuid = manifestUuid; + + setManifestDetail(data); + }) + .catch((error) => { + handleAPIError(error); + }) + .finally(() => { + setIsLoadingManifestDetail(false); + finallyAppend?.call(null); + }); + }, + [setIsLoadingManifestDetail, setManifestDetail], + ); + + const listElement = useMemo( + () => ( + { + addManifestFormDialogRef.current.setOpen?.call(null, true); + }} + onEdit={() => { + setIsEditManifests((previous) => !previous); + }} + onItemClick={({ manifestName, manifestUUID }) => { + setManifestDetail({ + name: manifestName, + uuid: manifestUUID, + } as APIManifestDetail); + editManifestFormDialogRef.current.setOpen?.call(null, true); + getManifestDetail(manifestUUID); + }} + renderListItem={(manifestUUID, { manifestName }) => ( + + { + setManifestDetail({ + name: manifestName, + uuid: manifestUUID, + } as APIManifestDetail); + runManifestFormDialogRef.current.setOpen?.call(null, true); + getManifestDetail(manifestUUID); + }} + variant="normal" + /> + {manifestName} + + )} + /> + ), + [getManifestDetail, isEditManifests, manifestOverviews, setManifestDetail], + ); + + const panelContent = useMemo( + () => + isLoadingHostOverviews || + isLoadingManifestTemplate || + isLoadingManifestOverviews ? ( + + ) : ( + listElement + ), + [ + isLoadingHostOverviews, + isLoadingManifestOverviews, + isLoadingManifestTemplate, + listElement, + ], + ); + + const messageArea = useMemo( + () => ( + + ), + [], + ); + + if (isFirstRender) { + api + .get('/manifest/template') + .then(({ data }) => { + setManifestTemplate(data); + }) + .catch((error) => { + handleAPIError(error); + }) + .finally(() => { + setIsLoadingManifestTemplate(false); + }); + + api + .get('/host', { params: { types: 'node' } }) + .then(({ data }) => { + setHostOverviews(data); + }) + .catch((apiError) => { + handleAPIError(apiError); + }) + .finally(() => { + setIsLoadingHostOverviews(false); + }); + } + + return ( + <> + + + Manage manifests + + {panelContent} + + + + + + + ); +}; + +export default ManageManifestPanel; diff --git a/striker-ui/components/ManageManifest/RunManifestInputGroup.tsx b/striker-ui/components/ManageManifest/RunManifestInputGroup.tsx new file mode 100644 index 00000000..618ef6ff --- /dev/null +++ b/striker-ui/components/ManageManifest/RunManifestInputGroup.tsx @@ -0,0 +1,454 @@ +import { styled } from '@mui/material'; +import { ReactElement, useMemo, useRef } from 'react'; + +import INPUT_TYPES from '../../lib/consts/INPUT_TYPES'; + +import FlexBox from '../FlexBox'; +import Grid from '../Grid'; +import InputWithRef, { InputForwardedRefContent } from '../InputWithRef'; +import OutlinedInputWithLabel from '../OutlinedInputWithLabel'; +import SelectWithLabel from '../SelectWithLabel'; +import { buildPeacefulStringTestBatch } from '../../lib/test_input'; +import { BodyText, MonoText } from '../Text'; + +const INPUT_ID_PREFIX_RUN_MANIFEST = 'run-manifest-input'; +const INPUT_ID_PREFIX_RM_HOST = `${INPUT_ID_PREFIX_RUN_MANIFEST}-host`; + +const INPUT_ID_RM_AN_DESCRIPTION = `${INPUT_ID_PREFIX_RUN_MANIFEST}-an-description`; +const INPUT_ID_RM_AN_PASSWORD = `${INPUT_ID_PREFIX_RUN_MANIFEST}-an-password`; +const INPUT_ID_RM_AN_CONFIRM_PASSWORD = `${INPUT_ID_PREFIX_RUN_MANIFEST}-an-confirm-password`; + +const INPUT_LABEL_RM_AN_DESCRIPTION = 'Description'; +const INPUT_LABEL_RM_AN_PASSWORD = 'Password'; +const INPUT_LABEL_RM_AN_CONFIRM_PASSWORD = 'Confirm password'; + +const MANIFEST_PARAM_NONE = '--'; + +const EndMono = styled(MonoText)({ + justifyContent: 'end', +}); + +const buildInputIdRMHost = (hostId: string): string => + `${INPUT_ID_PREFIX_RM_HOST}-${hostId}`; + +const RunManifestInputGroup = ({ + formUtils: { + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + setMessage, + }, + knownFences = {}, + knownHosts = {}, + knownUpses = {}, + previous: { domain: anDomain, hostConfig = {}, networkConfig = {} } = {}, +}: RunManifestInputGroupProps): ReactElement => { + const passwordRef = useRef>({}); + + const { hosts: initHostList = {} } = hostConfig; + const { + dnsCsv, + mtu, + networks: initNetworkList = {}, + ntpCsv = MANIFEST_PARAM_NONE, + } = networkConfig; + + const hostListEntries = useMemo( + () => Object.entries(initHostList), + [initHostList], + ); + const knownFenceListEntries = useMemo( + () => Object.entries(knownFences), + [knownFences], + ); + const knownHostListEntries = useMemo( + () => Object.entries(knownHosts), + [knownHosts], + ); + const knownUpsListEntries = useMemo( + () => Object.entries(knownUpses), + [knownUpses], + ); + const networkListEntries = useMemo( + () => Object.entries(initNetworkList), + [initNetworkList], + ); + + const hostOptionList = useMemo( + () => + knownHostListEntries.map(([, { hostName, hostUUID }]) => ({ + displayValue: hostName, + value: hostUUID, + })), + [knownHostListEntries], + ); + + const { + headers: hostHeaderRow, + hosts: hostSelectRow, + hostNames: hostNewNameRow, + } = useMemo( + () => + hostListEntries.reduce<{ + headers: GridLayout; + hosts: GridLayout; + hostNames: GridLayout; + }>( + (previous, [hostId, { hostName, hostNumber, hostType }]) => { + const { headers, hosts, hostNames } = previous; + + const prettyId = `${hostType}${hostNumber}`; + + headers[`run-manifest-column-header-cell-${hostId}`] = { + children: {prettyId}, + }; + + const inputId = buildInputIdRMHost(hostId); + const inputLabel = `${prettyId} host`; + + hosts[`run-manifest-host-cell-${hostId}`] = { + children: ( + + } + inputTestBatch={buildPeacefulStringTestBatch( + inputLabel, + () => { + setMessage(inputId); + }, + { onFinishBatch: buildFinishInputTestBatchFunction(inputId) }, + (message) => { + setMessage(inputId, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction(inputId)} + required + /> + ), + }; + + hostNames[`run-manifest-new-host-name-cell-${hostId}`] = { + children: ( + + {hostName}.{anDomain} + + ), + }; + + return previous; + }, + { + headers: { + 'run-manifest-column-header-cell-offset': {}, + }, + hosts: { + 'run-manifest-host-cell-header': { + children: Uses host, + }, + }, + hostNames: { + 'run-manifest-new-host-name-cell-header': { + children: New hostname, + }, + }, + }, + ), + [ + anDomain, + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + hostListEntries, + hostOptionList, + setMessage, + ], + ); + + const { + gateway: defaultGatewayGridLayout, + hostNetworks: hostNetworkRowList, + } = useMemo( + () => + networkListEntries.reduce<{ + gateway: GridLayout; + hostNetworks: GridLayout; + }>( + ( + previous, + [networkId, { networkGateway, networkNumber, networkType }], + ) => { + const { gateway, hostNetworks } = previous; + + const idPrefix = `run-manifest-host-network-cell-${networkId}`; + + const networkShortName = `${networkType.toUpperCase()}${networkNumber}`; + + hostNetworks[`${idPrefix}-header`] = { + children: {networkShortName}, + }; + + hostListEntries.forEach(([hostId, { networks = {} }]) => { + const { + [networkId]: { networkIp: ip = MANIFEST_PARAM_NONE } = {}, + } = networks; + + hostNetworks[`${idPrefix}-${hostId}-ip`] = { + children: {ip}, + }; + }); + + const cellId = 'run-manifest-gateway-cell'; + + if (networkGateway && !gateway[cellId]) { + gateway[cellId] = { + children: {networkGateway}, + }; + } + + return previous; + }, + { + gateway: { + 'run-manifest-gateway-cell-header': { + children: Gateway, + }, + }, + hostNetworks: {}, + }, + ), + [hostListEntries, networkListEntries], + ); + + const hostFenceRowList = useMemo( + () => + knownFenceListEntries.reduce( + (previous, [fenceUuid, { fenceName }]) => { + const idPrefix = `run-manifest-fence-cell-${fenceUuid}`; + + previous[`${idPrefix}-header`] = { + children: Port on {fenceName}, + }; + + hostListEntries.forEach(([hostId, { fences = {} }]) => { + const { [fenceName]: { fencePort = MANIFEST_PARAM_NONE } = {} } = + fences; + + previous[`${idPrefix}-${hostId}-port`] = { + children: {fencePort}, + }; + }); + + return previous; + }, + {}, + ), + [hostListEntries, knownFenceListEntries], + ); + + const hostUpsRowList = useMemo( + () => + knownUpsListEntries.reduce( + (previous, [upsUuid, { upsName }]) => { + const idPrefix = `run-manifest-ups-cell-${upsUuid}`; + + previous[`${idPrefix}-header`] = { + children: Uses {upsName}, + }; + + hostListEntries.forEach(([hostId, { upses = {} }]) => { + const { [upsName]: { isUsed = false } = {} } = upses; + + previous[`${idPrefix}-${hostId}-is-used`] = { + children: {isUsed ? 'yes' : 'no'}, + }; + }); + + return previous; + }, + {}, + ), + [hostListEntries, knownUpsListEntries], + ); + + const confirmPasswordProps = useMemo(() => { + const inputTestBatch = buildPeacefulStringTestBatch( + INPUT_LABEL_RM_AN_CONFIRM_PASSWORD, + () => { + setMessage(INPUT_ID_RM_AN_CONFIRM_PASSWORD); + }, + { + onFinishBatch: buildFinishInputTestBatchFunction( + INPUT_ID_RM_AN_CONFIRM_PASSWORD, + ), + }, + (message) => { + setMessage(INPUT_ID_RM_AN_CONFIRM_PASSWORD, { children: message }); + }, + ); + + const onFirstRender = buildInputFirstRenderFunction( + INPUT_ID_RM_AN_CONFIRM_PASSWORD, + ); + + inputTestBatch.tests.push({ + onFailure: () => { + setMessage(INPUT_ID_RM_AN_CONFIRM_PASSWORD, { + children: <>Confirm password must match password., + }); + }, + test: ({ value }) => passwordRef.current.getValue?.call(null) === value, + }); + + return { + inputTestBatch, + onFirstRender, + }; + }, [ + buildFinishInputTestBatchFunction, + buildInputFirstRenderFunction, + setMessage, + ]); + + return ( + + + } + inputTestBatch={buildPeacefulStringTestBatch( + INPUT_LABEL_RM_AN_DESCRIPTION, + () => { + setMessage(INPUT_ID_RM_AN_DESCRIPTION); + }, + { + onFinishBatch: buildFinishInputTestBatchFunction( + INPUT_ID_RM_AN_DESCRIPTION, + ), + }, + (message) => { + setMessage(INPUT_ID_RM_AN_DESCRIPTION, { + children: message, + }); + }, + )} + onFirstRender={buildInputFirstRenderFunction( + INPUT_ID_RM_AN_DESCRIPTION, + )} + required + /> + ), + sm: 2, + }, + 'run-manifest-input-cell-an-password': { + children: ( + + } + inputTestBatch={buildPeacefulStringTestBatch( + INPUT_LABEL_RM_AN_PASSWORD, + () => { + setMessage(INPUT_ID_RM_AN_PASSWORD); + }, + { + onFinishBatch: buildFinishInputTestBatchFunction( + INPUT_ID_RM_AN_PASSWORD, + ), + }, + (message) => { + setMessage(INPUT_ID_RM_AN_PASSWORD, { children: message }); + }, + )} + onFirstRender={buildInputFirstRenderFunction( + INPUT_ID_RM_AN_PASSWORD, + )} + ref={passwordRef} + required + /> + ), + }, + 'run-manifest-input-cell-an-confirm-password': { + children: ( + + } + required + {...confirmPasswordProps} + /> + ), + }, + }} + spacing="1em" + /> + + DNS, + }, + 'run-manifest-dns-csv-cell': { + children: {dnsCsv}, + }, + 'run-manifest-ntp-csv-cell-header': { + children: NTP, + }, + 'run-manifest-ntp-csv-cell': { + children: {ntpCsv}, + }, + 'run-manifest-mtu-cell-header': { + children: MTU, + }, + 'run-manifest-mtu-cell': { + children: {mtu}, + }, + }} + spacing="0.4em" + /> + + ); +}; + +export { + INPUT_ID_RM_AN_CONFIRM_PASSWORD, + INPUT_ID_RM_AN_DESCRIPTION, + INPUT_ID_RM_AN_PASSWORD, + buildInputIdRMHost, +}; + +export default RunManifestInputGroup; diff --git a/striker-ui/components/ManageManifest/index.tsx b/striker-ui/components/ManageManifest/index.tsx new file mode 100644 index 00000000..815cbed8 --- /dev/null +++ b/striker-ui/components/ManageManifest/index.tsx @@ -0,0 +1,3 @@ +import ManageManifestPanel from './ManageManifestPanel'; + +export default ManageManifestPanel; diff --git a/striker-ui/components/ManageUps/AddUpsInputGroup.tsx b/striker-ui/components/ManageUps/AddUpsInputGroup.tsx index e0896c23..9d8e83ff 100644 --- a/striker-ui/components/ManageUps/AddUpsInputGroup.tsx +++ b/striker-ui/components/ManageUps/AddUpsInputGroup.tsx @@ -1,4 +1,4 @@ -import { ReactElement, ReactNode, useMemo, useState } from 'react'; +import { ReactElement, ReactNode, useEffect, useMemo, useState } from 'react'; import { BLACK } from '../../lib/consts/DEFAULT_THEME'; @@ -142,11 +142,13 @@ const AddUpsInputGroup = < ], ); - if (isFirstRender) { - buildInputFirstRenderFunction(INPUT_ID_UPS_TYPE)({ - isValid: Boolean(inputUpsTypeIdValue), - }); - } + useEffect(() => { + if (isFirstRender) { + buildInputFirstRenderFunction(INPUT_ID_UPS_TYPE)({ + isValid: Boolean(inputUpsTypeIdValue), + }); + } + }, [buildInputFirstRenderFunction, inputUpsTypeIdValue, isFirstRender]); return content; }; diff --git a/striker-ui/components/ManageUps/CommonUpsInputGroup.tsx b/striker-ui/components/ManageUps/CommonUpsInputGroup.tsx index 7e744061..ff0d1cf7 100644 --- a/striker-ui/components/ManageUps/CommonUpsInputGroup.tsx +++ b/striker-ui/components/ManageUps/CommonUpsInputGroup.tsx @@ -22,7 +22,7 @@ const CommonUpsInputGroup = < formUtils: { buildFinishInputTestBatchFunction, buildInputFirstRenderFunction, - msgSetters, + setMessage, }, previous: { upsIPAddress: previousIpAddress, upsName: previousUpsName } = {}, }: CommonUpsInputGroupProps): ReactElement => ( @@ -42,16 +42,14 @@ const CommonUpsInputGroup = < inputTestBatch={buildPeacefulStringTestBatch( INPUT_LABEL_UPS_NAME, () => { - msgSetters[INPUT_ID_UPS_NAME](); + setMessage(INPUT_ID_UPS_NAME); }, { onFinishBatch: buildFinishInputTestBatchFunction(INPUT_ID_UPS_NAME), }, (message) => { - msgSetters[INPUT_ID_UPS_NAME]({ - children: message, - }); + setMessage(INPUT_ID_UPS_NAME, { children: message }); }, )} onFirstRender={buildInputFirstRenderFunction(INPUT_ID_UPS_NAME)} @@ -72,16 +70,14 @@ const CommonUpsInputGroup = < inputTestBatch={buildIPAddressTestBatch( INPUT_LABEL_UPS_IP, () => { - msgSetters[INPUT_ID_UPS_IP](); + setMessage(INPUT_ID_UPS_IP); }, { onFinishBatch: buildFinishInputTestBatchFunction(INPUT_ID_UPS_IP), }, (message) => { - msgSetters[INPUT_ID_UPS_IP]({ - children: message, - }); + setMessage(INPUT_ID_UPS_IP, { children: message }); }, )} onFirstRender={buildInputFirstRenderFunction(INPUT_ID_UPS_IP)} diff --git a/striker-ui/components/MessageGroup.tsx b/striker-ui/components/MessageGroup.tsx index 1ada5852..1bfda7ad 100644 --- a/striker-ui/components/MessageGroup.tsx +++ b/striker-ui/components/MessageGroup.tsx @@ -62,20 +62,22 @@ const MessageGroup = forwardRef< (key: string, message?: Message) => { let length = 0; - const { [key]: unused, ...rest } = messages; - const result: Messages = rest; + setMessages((previous) => { + const { [key]: unused, ...rest } = previous; + const result: Messages = rest; - if (message) { - result[key] = message; - } + if (message) { + result[key] = message; + } - length = Object.keys(result).length; + length = Object.keys(result).length; - onSet?.call(null, length); + return result; + }); - setMessages(result); + onSet?.call(null, length); }, - [messages, onSet], + [onSet], ); const setMessageRe = useCallback( (re: RegExp, message?: Message) => { @@ -87,22 +89,25 @@ const MessageGroup = forwardRef< length += 1; } : undefined; - const result: Messages = {}; - - Object.keys(messages).forEach((key: string) => { - if (re.test(key)) { - assignMessage?.call(null, result, key); - } else { - result[key] = messages[key]; - length += 1; - } + + setMessages((previous) => { + const result: Messages = {}; + + Object.keys(previous).forEach((key: string) => { + if (re.test(key)) { + assignMessage?.call(null, result, key); + } else { + result[key] = previous[key]; + length += 1; + } + }); + + return result; }); onSet?.call(null, length); - - setMessages(result); }, - [messages, onSet], + [onSet], ); const messageElements = useMemo(() => { diff --git a/striker-ui/components/NetworkInitForm.tsx b/striker-ui/components/NetworkInitForm.tsx index 507454e1..db4a9e8d 100644 --- a/striker-ui/components/NetworkInitForm.tsx +++ b/striker-ui/components/NetworkInitForm.tsx @@ -34,6 +34,7 @@ import { v4 as uuidv4 } from 'uuid'; import API_BASE_URL from '../lib/consts/API_BASE_URL'; import { BLUE, GREY } from '../lib/consts/DEFAULT_THEME'; +import NETWORK_TYPES from '../lib/consts/NETWORK_TYPES'; import { REP_IPV4, REP_IPV4_CSV } from '../lib/consts/REG_EXP_PATTERNS'; import BriefNetworkInterface from './BriefNetworkInterface'; @@ -106,17 +107,6 @@ const CLASSES = { }; const INITIAL_IFACES = [undefined, undefined]; -const NETWORK_TYPES: Record = { - bcn: 'Back-Channel Network', - ifn: 'Internet-Facing Network', - sn: 'Storage Network', -}; - -const NODE_NETWORK_TYPES: Record = { - ...NETWORK_TYPES, - mn: 'Migration Network', -}; - const STRIKER_REQUIRED_NETWORKS: NetworkInput[] = [ { inputUUID: '30dd2ac5-8024-4a7e-83a1-6a3df7218972', @@ -349,11 +339,13 @@ const NetworkForm: FC<{ !isNode && networkInterfaceCount <= 2 ? [1] : NETWORK_INTERFACE_TEMPLATE, [isNode, networkInterfaceCount], ); - const netTypeList = useMemo( - () => - isNode && networkInterfaceCount >= 8 ? NODE_NETWORK_TYPES : NETWORK_TYPES, - [isNode, networkInterfaceCount], - ); + const netTypeList = useMemo(() => { + const { bcn, ifn, mn, sn } = NETWORK_TYPES; + + return isNode && networkInterfaceCount >= 8 + ? { bcn, ifn, mn, sn } + : { bcn, ifn, sn }; + }, [isNode, networkInterfaceCount]); useEffect(() => { const { ipAddressInputRef: ipRef, subnetMaskInputRef: maskRef } = diff --git a/striker-ui/components/OutlinedInputWithLabel.tsx b/striker-ui/components/OutlinedInputWithLabel.tsx index 7ded6bfe..3ee5adfb 100644 --- a/striker-ui/components/OutlinedInputWithLabel.tsx +++ b/striker-ui/components/OutlinedInputWithLabel.tsx @@ -6,6 +6,7 @@ import { IconButtonProps as MUIIconButtonProps, iconButtonClasses as muiIconButtonClasses, InputAdornment as MUIInputAdornment, + InputBaseComponentProps as MUIInputBaseComponentProps, } from '@mui/material'; import { FC, useCallback, useMemo, useState } from 'react'; @@ -31,6 +32,7 @@ type OutlinedInputWithLabelOptionalPropsWithDefault = { }; type OutlinedInputWithLabelOptionalPropsWithoutDefault = { + baseInputProps?: MUIInputBaseComponentProps; onHelp?: MUIIconButtonProps['onClick']; onHelpAppend?: MUIIconButtonProps['onClick']; type?: string; @@ -50,6 +52,7 @@ type OutlinedInputWithLabelProps = Pick< const OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS: Required & OutlinedInputWithLabelOptionalPropsWithoutDefault = { + baseInputProps: undefined, fillRow: false, formControlProps: {}, helpMessageBoxProps: {}, @@ -65,6 +68,7 @@ const OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS: Required = ({ + baseInputProps, fillRow: isFillRow = OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS.fillRow, formControlProps = OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS.formControlProps, helpMessageBoxProps = OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS.helpMessageBoxProps, @@ -171,6 +175,7 @@ const OutlinedInputWithLabel: FC = ({ } fullWidth={formControlProps.fullWidth} id={id} + inputProps={baseInputProps} label={label} name={name} onBlur={onBlur} diff --git a/striker-ui/components/Panels/InnerPanel.tsx b/striker-ui/components/Panels/InnerPanel.tsx index 65239514..8abb0613 100644 --- a/striker-ui/components/Panels/InnerPanel.tsx +++ b/striker-ui/components/Panels/InnerPanel.tsx @@ -3,15 +3,33 @@ import { Box as MUIBox, SxProps, Theme } from '@mui/material'; import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME'; -const InnerPanel: FC = ({ sx, ...muiBoxRestProps }) => { +const InnerPanel: FC = ({ + headerMarginOffset: hmo = '.3em', + ml, + mv = '1.4em', + sx, + // Props that depend on others. + mb = mv, + mt = mv, + + ...muiBoxRestProps +}) => { + const marginLeft = useMemo( + () => (ml ? `calc(${ml} + ${hmo})` : hmo), + [hmo, ml], + ); + const marginTop = useMemo(() => { + const resultMt = typeof mt === 'number' ? `${mt}px` : mt; + + return `calc(${resultMt} + ${hmo})`; + }, [hmo, mt]); + const combinedSx = useMemo>( () => ({ borderWidth: '1px', borderRadius: BORDER_RADIUS, borderStyle: 'solid', borderColor: DIVIDER, - marginTop: '1.4em', - marginBottom: '1.4em', paddingBottom: 0, position: 'relative', @@ -20,7 +38,15 @@ const InnerPanel: FC = ({ sx, ...muiBoxRestProps }) => { [sx], ); - return ; + return ( + + ); }; export default InnerPanel; diff --git a/striker-ui/components/Panels/InnerPanelBody.tsx b/striker-ui/components/Panels/InnerPanelBody.tsx index 3b7e694b..2ffa7e8d 100644 --- a/striker-ui/components/Panels/InnerPanelBody.tsx +++ b/striker-ui/components/Panels/InnerPanelBody.tsx @@ -1,17 +1,20 @@ -import { Box, BoxProps } from '@mui/material'; -import { FC } from 'react'; +import { Box, BoxProps, SxProps, Theme } from '@mui/material'; +import { FC, useMemo } from 'react'; -const InnerPanelBody: FC = ({ sx, ...innerPanelBodyRestProps }) => ( - = ({ sx, ...innerPanelBodyRestProps }) => { + const combinedSx = useMemo>( + () => ({ + position: 'relative', + zIndex: 20, - ...sx, - }, - }} - /> -); + ...sx, + }), + [sx], + ); + + return ( + + ); +}; export default InnerPanelBody; diff --git a/striker-ui/components/SwitchWithLabel.tsx b/striker-ui/components/SwitchWithLabel.tsx index 89ce5d9c..7a26ddea 100644 --- a/striker-ui/components/SwitchWithLabel.tsx +++ b/striker-ui/components/SwitchWithLabel.tsx @@ -1,55 +1,67 @@ -import { Switch, SxProps, Theme } from '@mui/material'; -import { FC, useMemo } from 'react'; +import { + FormControlLabel as MUIFormControlLabel, + styled, + Switch as MUISwitch, +} from '@mui/material'; +import { FC, ReactElement, useMemo } from 'react'; import { GREY } from '../lib/consts/DEFAULT_THEME'; -import FlexBox from './FlexBox'; import { BodyText } from './Text'; +const SwitchFormControlLabel = styled(MUIFormControlLabel)({ + height: '3.5em', + marginLeft: 0, + width: '100%', +}); + const SwitchWithLabel: FC = ({ + baseInputProps, checked: isChecked, - flexBoxProps: { sx: flexBoxSx, ...restFlexBoxProps } = {}, + formControlLabelProps, id: switchId, label, name: switchName, onChange, switchProps, }) => { - const combinedFlexBoxSx = useMemo>( - () => ({ - '& > :first-child': { - flexGrow: 1, - }, - - ...flexBoxSx, - }), - [flexBoxSx], - ); - - const labelElement = useMemo( + const labelElement = useMemo( () => typeof label === 'string' ? ( - + {label} ) : ( - label + <>{label} ), [label], ); return ( - - {labelElement} - + + } + label={labelElement} + labelPlacement="start" + {...formControlLabelProps} + /> + - + ); }; diff --git a/striker-ui/hooks/useFormUtils.ts b/striker-ui/hooks/useFormUtils.ts index 70727b88..f0ae1ea3 100644 --- a/striker-ui/hooks/useFormUtils.ts +++ b/striker-ui/hooks/useFormUtils.ts @@ -1,7 +1,9 @@ import { MutableRefObject, useCallback, useMemo, useState } from 'react'; -import buildMapToMessageSetter from '../lib/buildMapToMessageSetter'; -import buildObjectStateSetterCallback from '../lib/buildObjectStateSetterCallback'; +import buildObjectStateSetterCallback, { + buildRegExpObjectStateSetterCallback, +} from '../lib/buildObjectStateSetterCallback'; +import { Message } from '../components/MessageBox'; import { MessageGroupForwardedRefContent } from '../components/MessageGroup'; const useFormUtils = < @@ -14,12 +16,48 @@ const useFormUtils = < ): FormUtils => { const [formValidity, setFormValidity] = useState>({}); - const setValidity = useCallback((key: keyof M, value: boolean) => { + const setMessage = useCallback( + (key: keyof M, message?: Message) => { + messageGroupRef?.current?.setMessage?.call(null, String(key), message); + }, + [messageGroupRef], + ); + + const setMessageRe = useCallback( + (re: RegExp, message?: Message) => { + messageGroupRef?.current?.setMessageRe?.call(null, re, message); + }, + [messageGroupRef], + ); + + const setValidity = useCallback((key: keyof M, value?: boolean) => { setFormValidity( buildObjectStateSetterCallback>(key, value), ); }, []); + const setValidityRe = useCallback((re: RegExp, value?: boolean) => { + setFormValidity( + buildRegExpObjectStateSetterCallback>(re, value), + ); + }, []); + + const unsetKey = useCallback( + (key: keyof M) => { + setMessage(key); + setValidity(key); + }, + [setMessage, setValidity], + ); + + const unsetKeyRe = useCallback( + (re: RegExp) => { + setMessageRe(re); + setValidityRe(re); + }, + [setMessageRe, setValidityRe], + ); + const buildFinishInputTestBatchFunction = useCallback( (key: keyof M) => (result: boolean) => { setValidity(key, result); @@ -35,24 +73,31 @@ const useFormUtils = < [setValidity], ); + const buildInputUnmountFunction = useCallback( + (key: keyof M) => () => { + unsetKey(key); + }, + [unsetKey], + ); + const isFormInvalid = useMemo( () => Object.values(formValidity).some((isInputValid) => !isInputValid), [formValidity], ); - const msgSetters = useMemo( - () => buildMapToMessageSetter(ids, messageGroupRef), - [ids, messageGroupRef], - ); - return { buildFinishInputTestBatchFunction, buildInputFirstRenderFunction, + buildInputUnmountFunction, formValidity, isFormInvalid, - msgSetters, setFormValidity, + setMessage, + setMessageRe, setValidity, + setValidityRe, + unsetKey, + unsetKeyRe, }; }; diff --git a/striker-ui/lib/buildMapToMessageSetter.ts b/striker-ui/lib/buildMapToMessageSetter.ts index 5152ab33..750f46fe 100644 --- a/striker-ui/lib/buildMapToMessageSetter.ts +++ b/striker-ui/lib/buildMapToMessageSetter.ts @@ -7,8 +7,8 @@ const buildMessageSetter = ( messageGroupRef: MutableRefObject, container?: MapToMessageSetter, key: string = id, -): MessageSetterFunction => { - const setter: MessageSetterFunction = (message?) => { +): MessageSetter => { + const setter: MessageSetter = (message?) => { messageGroupRef.current.setMessage?.call(null, id, message); }; @@ -47,4 +47,6 @@ const buildMapToMessageSetter = < return result; }; +export { buildMessageSetter }; + export default buildMapToMessageSetter; diff --git a/striker-ui/lib/buildObjectStateSetterCallback.ts b/striker-ui/lib/buildObjectStateSetterCallback.ts index 15170e85..40547c6d 100644 --- a/striker-ui/lib/buildObjectStateSetterCallback.ts +++ b/striker-ui/lib/buildObjectStateSetterCallback.ts @@ -1,9 +1,74 @@ +/** + * Checks whether specified `key` is unset in given object. Always returns + * `true` when overwrite is allowed. + */ +const checkUnset = ( + obj: S, + key: keyof S, + { isOverwrite = false }: { isOverwrite?: boolean } = {}, +): boolean => !(key in obj) || isOverwrite; + +const defaultObjectStatePropSetter = ( + ...[, result, key, value]: Parameters> +): ReturnType> => { + if (value !== undefined) { + result[key] = value; + } +}; + const buildObjectStateSetterCallback = - >(key: keyof S, value: S[keyof S]) => - ({ [key]: toReplace, ...restPrevious }: S): S => - ({ - ...restPrevious, - [key]: value, - } as S); + ( + key: keyof S, + value?: S[keyof S], + { + guard = () => true, + set = defaultObjectStatePropSetter, + }: BuildObjectStateSetterCallbackOptions = {}, + ): BuildObjectStateSetterCallbackReturnType => + (previous: S): S => { + const { [key]: toReplace, ...restPrevious } = previous; + const result = { ...restPrevious } as S; + + if (guard(previous, key, value)) { + set(previous, result, key, value); + } + + return result; + }; + +export const buildProtectedObjectStateSetterCallback = ( + key: keyof S, + value?: S[keyof S], + { + isOverwrite, + guard = (o, k) => checkUnset(o, k, { isOverwrite }), + set, + }: BuildObjectStateSetterCallbackOptions = {}, +): BuildObjectStateSetterCallbackReturnType => + buildObjectStateSetterCallback(key, value, { isOverwrite, guard, set }); + +export const buildRegExpObjectStateSetterCallback = + ( + re: RegExp, + value?: S[keyof S], + { + set = defaultObjectStatePropSetter, + }: Pick, 'set'> = {}, + ) => + (previous: S): S => { + const result: S = {} as S; + + Object.keys(previous).forEach((key) => { + const k = key as keyof S; + + if (re.test(key)) { + set(previous, result, k, value); + } else { + result[k] = previous[k]; + } + }); + + return result; + }; export default buildObjectStateSetterCallback; diff --git a/striker-ui/lib/consts/NETWORK_TYPES.ts b/striker-ui/lib/consts/NETWORK_TYPES.ts index 6720774e..a1954860 100644 --- a/striker-ui/lib/consts/NETWORK_TYPES.ts +++ b/striker-ui/lib/consts/NETWORK_TYPES.ts @@ -1,6 +1,8 @@ const NETWORK_TYPES: Record = { bcn: 'Back-Channel Network', ifn: 'Internet-Facing Network', + mn: 'Migration Network', + sn: 'Storage Network', }; export default NETWORK_TYPES; diff --git a/striker-ui/lib/test_input/buildDomainTestBatch.tsx b/striker-ui/lib/test_input/buildDomainTestBatch.tsx index dad7374a..9ed17475 100644 --- a/striker-ui/lib/test_input/buildDomainTestBatch.tsx +++ b/striker-ui/lib/test_input/buildDomainTestBatch.tsx @@ -13,6 +13,9 @@ const buildDomainTestBatch: BuildInputTestBatchFunction = ( isRequired, onFinishBatch, tests: [ + { + test: testNotBlank, + }, { onFailure: (...args) => { onDomainTestFailure( @@ -27,7 +30,6 @@ const buildDomainTestBatch: BuildInputTestBatchFunction = ( test: ({ compare, value }) => (compare[0] as boolean) || REP_DOMAIN.test(value as string), }, - { test: testNotBlank }, ], }); diff --git a/striker-ui/lib/test_input/buildIPAddressTestBatch.tsx b/striker-ui/lib/test_input/buildIPAddressTestBatch.tsx index 418fb322..9d80b8be 100644 --- a/striker-ui/lib/test_input/buildIPAddressTestBatch.tsx +++ b/striker-ui/lib/test_input/buildIPAddressTestBatch.tsx @@ -12,16 +12,18 @@ const buildIPAddressTestBatch: BuildInputTestBatchFunction = ( isRequired, onFinishBatch, tests: [ + { + test: testNotBlank, + }, { onFailure: (...args) => { onIPv4TestFailure( - `${inputName} should be a valid IPv4 address.`, + <>{inputName} should be a valid IPv4 address., ...args, ); }, test: ({ value }) => REP_IPV4.test(value as string), }, - { test: testNotBlank }, ], }); diff --git a/striker-ui/lib/test_input/buildIpCsvTestBatch.tsx b/striker-ui/lib/test_input/buildIpCsvTestBatch.tsx new file mode 100644 index 00000000..19cbb9cf --- /dev/null +++ b/striker-ui/lib/test_input/buildIpCsvTestBatch.tsx @@ -0,0 +1,33 @@ +import { REP_IPV4_CSV } from '../consts/REG_EXP_PATTERNS'; + +import testNotBlank from './testNotBlank'; + +const buildIpCsvTestBatch: BuildInputTestBatchFunction = ( + inputName, + onSuccess, + { isRequired, onFinishBatch, ...defaults } = {}, + onIpCsvTestFailure, +) => ({ + defaults: { ...defaults, onSuccess }, + isRequired, + onFinishBatch, + tests: [ + { + test: testNotBlank, + }, + { + onFailure: (...args) => { + onIpCsvTestFailure( + <> + {inputName} must be one or more valid IPv4 addresses separated by + comma(s); without trailing comma. + , + ...args, + ); + }, + test: ({ value }) => REP_IPV4_CSV.test(value as string), + }, + ], +}); + +export default buildIpCsvTestBatch; diff --git a/striker-ui/lib/test_input/buildPeacefulStringTestBatch.tsx b/striker-ui/lib/test_input/buildPeacefulStringTestBatch.tsx index 594f387e..e05e0fa2 100644 --- a/striker-ui/lib/test_input/buildPeacefulStringTestBatch.tsx +++ b/striker-ui/lib/test_input/buildPeacefulStringTestBatch.tsx @@ -13,6 +13,13 @@ const buildPeacefulStringTestBatch: BuildInputTestBatchFunction = ( isRequired, onFinishBatch, tests: [ + { + /** + * Not-blank test ensures no unnecessary error message is provided when + * input is not (yet) filled. + */ + test: testNotBlank, + }, { onFailure: (...args) => { onTestPeacefulStringFailureAppend( @@ -31,7 +38,6 @@ const buildPeacefulStringTestBatch: BuildInputTestBatchFunction = ( }, test: ({ value }) => REP_PEACEFUL_STRING.test(value as string), }, - { test: testNotBlank }, ], }); diff --git a/striker-ui/lib/test_input/buildUUIDTestBatch.tsx b/striker-ui/lib/test_input/buildUUIDTestBatch.tsx index d5173fbf..2779f113 100644 --- a/striker-ui/lib/test_input/buildUUIDTestBatch.tsx +++ b/striker-ui/lib/test_input/buildUUIDTestBatch.tsx @@ -12,13 +12,15 @@ const buildUUIDTestBatch: BuildInputTestBatchFunction = ( isRequired, onFinishBatch, tests: [ + { + test: testNotBlank, + }, { onFailure: (...args) => { - onUUIDTestFailure(`${inputName} must be a valid UUID.`, ...args); + onUUIDTestFailure(<>{inputName} must be a valid UUID., ...args); }, test: ({ value }) => REP_UUID.test(value as string), }, - { test: testNotBlank }, ], }); diff --git a/striker-ui/lib/test_input/index.ts b/striker-ui/lib/test_input/index.ts index 1f9e23cb..2d1ba8ee 100644 --- a/striker-ui/lib/test_input/index.ts +++ b/striker-ui/lib/test_input/index.ts @@ -1,5 +1,7 @@ import buildDomainTestBatch from './buildDomainTestBatch'; import buildIPAddressTestBatch from './buildIPAddressTestBatch'; +import buildIpCsvTestBatch from './buildIpCsvTestBatch'; +import buildNumberTestBatch from './buildNumberTestBatch'; import buildPeacefulStringTestBatch from './buildPeacefulStringTestBatch'; import buildUUIDTestBatch from './buildUUIDTestBatch'; import createTestInputFunction from './createTestInputFunction'; @@ -12,6 +14,8 @@ import testRange from './testRange'; export { buildDomainTestBatch, buildIPAddressTestBatch, + buildIpCsvTestBatch, + buildNumberTestBatch, buildPeacefulStringTestBatch, buildUUIDTestBatch, createTestInputFunction, diff --git a/striker-ui/lib/test_input/testInput.ts b/striker-ui/lib/test_input/testInput.ts index 835d40f6..9091ff84 100644 --- a/striker-ui/lib/test_input/testInput.ts +++ b/striker-ui/lib/test_input/testInput.ts @@ -101,15 +101,17 @@ const testInput: TestInputFunction = ({ displayMin = orSet(dDisplayMin, String(min)), } = testsToRun[id]; - if (!value && isOptional) { - return true; - } - const { cbFinishBatch, setTestCallbacks } = evalIsIgnoreOnCallbacks({ isIgnoreOnCallbacks, onFinishBatch, }); + if (!value && isOptional) { + cbFinishBatch?.call(null, true, id); + + return true; + } + const runTest: (test: InputTest) => boolean = ({ onFailure, onSuccess = dOnSuccess, diff --git a/striker-ui/pages/manage-element/index.tsx b/striker-ui/pages/manage-element/index.tsx index 54fdcb3a..d05f5275 100644 --- a/striker-ui/pages/manage-element/index.tsx +++ b/striker-ui/pages/manage-element/index.tsx @@ -8,6 +8,7 @@ import Grid from '../../components/Grid'; import handleAPIError from '../../lib/handleAPIError'; import Header from '../../components/Header'; import ManageFencePanel from '../../components/ManageFence'; +import ManageManifestPanel from '../../components/ManageManifest'; import ManageUpsPanel from '../../components/ManageUps'; import { Panel } from '../../components/Panels'; import PrepareHostForm from '../../components/PrepareHostForm'; @@ -20,12 +21,18 @@ import useIsFirstRender from '../../hooks/useIsFirstRender'; import useProtect from '../../hooks/useProtect'; import useProtectedState from '../../hooks/useProtectedState'; +const TAB_ID_PREPARE_HOST = 'prepare-host'; +const TAB_ID_PREPARE_NETWORK = 'prepare-network'; +const TAB_ID_MANAGE_FENCE = 'manage-fence'; +const TAB_ID_MANAGE_UPS = 'manage-ups'; +const TAB_ID_MANAGE_MANIFEST = 'manage-manifest'; + const MAP_TO_PAGE_TITLE: Record = { - 'prepare-host': 'Prepare Host', - 'prepare-network': 'Prepare Network', - 'manage-fence': 'Manage Fence Devices', - 'manage-ups': 'Manage UPSes', - 'manage-manifest': 'Manage Manifests', + [TAB_ID_PREPARE_HOST]: 'Prepare Host', + [TAB_ID_PREPARE_NETWORK]: 'Prepare Network', + [TAB_ID_MANAGE_FENCE]: 'Manage Fence Devices', + [TAB_ID_MANAGE_UPS]: 'Manage UPSes', + [TAB_ID_MANAGE_MANIFEST]: 'Manage Manifests', }; const PAGE_TITLE_LOADING = 'Loading'; const STEP_CONTENT_GRID_COLUMNS = { md: 8, sm: 6, xs: 1 }; @@ -70,7 +77,7 @@ const PrepareNetworkTabContent: FC = () => { > {hostOverviewPairs.map(([hostUUID, { shortHostName }]) => ( @@ -144,6 +151,19 @@ const ManageUpsTabContent: FC = () => ( /> ); +const ManageManifestContent: FC = () => ( + , + ...STEP_CONTENT_GRID_CENTER_COLUMN, + }, + }} + /> +); + const ManageElement: FC = () => { const { isReady, @@ -156,11 +176,11 @@ const ManageElement: FC = () => { useEffect(() => { if (isReady) { let step = getQueryParam(rawStep, { - fallbackValue: 'prepare-host', + fallbackValue: TAB_ID_PREPARE_HOST, }); if (!MAP_TO_PAGE_TITLE[step]) { - step = 'prepare-host'; + step = TAB_ID_PREPARE_HOST; } if (pageTitle === PAGE_TITLE_LOADING) { @@ -188,24 +208,28 @@ const ManageElement: FC = () => { orientation={{ xs: 'vertical', sm: 'horizontal' }} value={pageTabId} > - - - - + + + + + - + - + - + - + + + + ); }; diff --git a/striker-ui/types/APIManifest.d.ts b/striker-ui/types/APIManifest.d.ts new file mode 100644 index 00000000..ac704b50 --- /dev/null +++ b/striker-ui/types/APIManifest.d.ts @@ -0,0 +1,55 @@ +type APIManifestOverview = { + manifestName: string; + manifestUUID: string; +}; + +type APIManifestOverviewList = { + [manifestUUID: string]: APIManifestOverview; +}; + +type APIManifestDetail = ManifestAnId & { + hostConfig: ManifestHostConfig; + name: string; + networkConfig: ManifestNetworkConfig; + uuid?: string; +}; + +type APIManifestTemplateFence = { + fenceName: string; + fenceUUID: string; +}; + +type APIManifestTemplateUps = { + upsName: string; + upsUUID: string; +}; + +type APIManifestTemplateFenceList = { + [fenceUuid: string]: APIManifestTemplateFence; +}; + +type APIManifestTemplateUpsList = { + [upsUuid: string]: APIManifestTemplateUps; +}; + +type APIManifestTemplate = { + domain: string; + fences: APIManifestTemplateFenceList; + prefix: string; + sequence: number; + upses: APIManifestTemplateUpsList; +}; + +type APIBuildManifestRequestBody = Omit; + +type APIRunManifestRequestBody = { + description: string; + hosts: { + [hostId: string]: { + hostNumber: number; + hostType: string; + hostUuid: string; + }; + }; + password: string; +}; diff --git a/striker-ui/types/BuildObjectStateSetterCallback.d.ts b/striker-ui/types/BuildObjectStateSetterCallback.d.ts new file mode 100644 index 00000000..bdd142bc --- /dev/null +++ b/striker-ui/types/BuildObjectStateSetterCallback.d.ts @@ -0,0 +1,24 @@ +type BaseObject = Record; + +type ObjectStatePropGuard = ( + previous: S, + key: keyof S, + value?: S[keyof S], +) => boolean; + +type ObjectStatePropSetter = ( + previous: S, + result: S, + key: keyof S, + value?: S[keyof S], +) => void; + +type BuildObjectStateSetterCallbackOptions = { + guard?: ObjectStatePropGuard; + isOverwrite?: boolean; + set?: ObjectStatePropSetter; +}; + +type BuildObjectStateSetterCallbackReturnType = ( + previous: S, +) => S; diff --git a/striker-ui/types/ConfirmDialog.d.ts b/striker-ui/types/ConfirmDialog.d.ts index 24e94d11..f7e55de3 100644 --- a/striker-ui/types/ConfirmDialog.d.ts +++ b/striker-ui/types/ConfirmDialog.d.ts @@ -1,14 +1,19 @@ +type DivFormEventHandler = import('react').FormEventHandler; +type DivFormEventHandlerParameters = Parameters; + type ConfirmDialogOptionalProps = { actionCancelText?: string; closeOnProceed?: boolean; contentContainerProps?: import('../components/FlexBox').FlexBoxProps; dialogProps?: Partial; + disableProceed?: boolean; formContent?: boolean; + loading?: boolean; loadingAction?: boolean; onActionAppend?: ContainedButtonProps['onClick']; onProceedAppend?: ContainedButtonProps['onClick']; onCancelAppend?: ContainedButtonProps['onClick']; - onSubmitAppend?: import('react').FormEventHandler; + onSubmitAppend?: DivFormEventHandler; openInitially?: boolean; preActionArea?: import('react').ReactNode; proceedButtonProps?: ContainedButtonProps; diff --git a/striker-ui/types/FormUtils.d.ts b/striker-ui/types/FormUtils.d.ts index 1f106121..b65f506c 100644 --- a/striker-ui/types/FormUtils.d.ts +++ b/striker-ui/types/FormUtils.d.ts @@ -14,14 +14,31 @@ type InputFirstRenderFunctionBuilder = ( key: keyof M, ) => InputFirstRenderFunction; +type InputUnmountFunction = () => void; + +type InputUnmountFunctionBuilder = ( + key: keyof M, +) => InputUnmountFunction; + type FormUtils = { buildFinishInputTestBatchFunction: InputTestBatchFinishCallbackBuilder; buildInputFirstRenderFunction: InputFirstRenderFunctionBuilder; + buildInputUnmountFunction: InputUnmountFunctionBuilder; formValidity: FormValidity; isFormInvalid: boolean; - msgSetters: MapToMessageSetter; setFormValidity: import('react').Dispatch< import('react').SetStateAction> >; - setValidity: (key: keyof M, value: boolean) => void; + setMessage: ( + key: keyof M, + message?: import('../components/MessageBox').Message, + ) => void; + setMessageRe: ( + re: RegExp, + message?: import('../components/MessageBox').Message, + ) => void; + setValidity: (key: keyof M, value?: boolean) => void; + setValidityRe: (re: RegExp, value?: boolean) => void; + unsetKey: (key: keyof M) => void; + unsetKeyRe: (re: RegExp) => void; }; diff --git a/striker-ui/types/IconButton.d.ts b/striker-ui/types/IconButton.d.ts index af8ecf5a..208fae3a 100644 --- a/striker-ui/types/IconButton.d.ts +++ b/striker-ui/types/IconButton.d.ts @@ -1,16 +1,29 @@ type CreatableComponent = Parameters[0]; -type IconButtonPresetMapToStateIcon = 'edit' | 'visibility'; +type IconButtonPresetMapToStateIconBundle = + | 'add' + | 'close' + | 'edit' + | 'play' + | 'visibility'; -type IconButtonMapToStateIcon = Record; +type IconButtonStateIconBundle = { + iconType: CreatableComponent; + iconProps?: import('@mui/material').SvgIconProps; +}; + +type IconButtonMapToStateIconBundle = Record; type IconButtonVariant = 'contained' | 'normal'; +type IconButtonMouseEventHandler = + import('@mui/material').IconButtonProps['onClick']; + type IconButtonOptionalProps = { defaultIcon?: CreatableComponent; iconProps?: import('@mui/material').SvgIconProps; - mapPreset?: IconButtonPresetMapToStateIcon; - mapToIcon?: IconButtonMapToStateIcon; + mapPreset?: IconButtonPresetMapToStateIconBundle; + mapToIcon?: IconButtonMapToStateIconBundle; state?: string; variant?: IconButtonVariant; }; diff --git a/striker-ui/types/InnerPanel.d.ts b/striker-ui/types/InnerPanel.d.ts index 0f0b3adb..7e5e9353 100644 --- a/striker-ui/types/InnerPanel.d.ts +++ b/striker-ui/types/InnerPanel.d.ts @@ -1 +1,7 @@ -type InnerPanelProps = import('@mui/material').BoxProps; +type InnerPanelOptionalProps = { + headerMarginOffset?: number | string; + mv?: number | string; +}; + +type InnerPanelProps = InnerPanelOptionalProps & + import('@mui/material').BoxProps; diff --git a/striker-ui/types/ManageManifest.d.ts b/striker-ui/types/ManageManifest.d.ts new file mode 100644 index 00000000..bc2aa6c3 --- /dev/null +++ b/striker-ui/types/ManageManifest.d.ts @@ -0,0 +1,185 @@ +type ManifestAnId = { + domain: string; + prefix: string; + sequence: number; +}; + +type ManifestNetwork = { + networkGateway?: string; + networkMinIp: string; + networkNumber: number; + networkSubnetMask: string; + networkType: string; +}; + +type ManifestNetworkList = { + [networkId: string]: ManifestNetwork; +}; + +type ManifestNetworkConfig = { + dnsCsv: string; + /** Max Transmission Unit (MTU); unit: bytes */ + mtu: number; + networks: ManifestNetworkList; + ntpCsv: string; +}; + +type ManifestHostFenceList = { + [fenceId: string]: { + fenceName: string; + fencePort: string; + }; +}; + +type ManifestHostNetworkList = { + [networkId: string]: { + networkIp: string; + networkNumber: number; + networkType: string; + }; +}; + +type ManifestHostUpsList = { + [upsId: string]: { + isUsed: boolean; + upsName: string; + }; +}; + +type ManifestHost = { + fences?: ManifestHostFenceList; + hostName?: string; + hostNumber: number; + hostType: string; + ipmiIp?: string; + networks?: ManifestHostNetworkList; + upses?: ManifestHostUpsList; +}; + +type ManifestHostList = { + [hostId: string]: ManifestHost; +}; + +type ManifestHostConfig = { + hosts: ManifestHostList; +}; + +type ManifestFormInputHandler = ( + container: APIBuildManifestRequestBody, + input: HTMLInputElement, +) => void; + +type MapToManifestFormInputHandler = Record; + +/** ---------- Component types ---------- */ + +type AnIdInputGroupOptionalProps = { + previous?: Partial; +}; + +type AnIdInputGroupProps = + AnIdInputGroupOptionalProps & { + formUtils: FormUtils; + }; + +type AnNetworkEventHandlerPreviousArgs = { + networkId: string; +} & Pick; + +type AnNetworkCloseEventHandler = ( + args: AnNetworkEventHandlerPreviousArgs, + ...handlerArgs: Parameters +) => ReturnType; + +type AnNetworkTypeChangeEventHandler = ( + args: AnNetworkEventHandlerPreviousArgs, + ...handlerArgs: Parameters +) => ReturnType; + +type AnNetworkInputGroupOptionalProps = { + inputGatewayLabel?: string; + inputMinIpLabel?: string; + inputSubnetMaskLabel?: string; + onClose?: AnNetworkCloseEventHandler; + onNetworkTypeChange?: AnNetworkTypeChangeEventHandler; + previous?: { + gateway?: string; + minIp?: string; + subnetMask?: string; + }; + readonlyNetworkName?: boolean; + showCloseButton?: boolean; + showGateway?: boolean; +}; + +type AnNetworkInputGroupProps = + AnNetworkInputGroupOptionalProps & { + formUtils: FormUtils; + networkId: string; + networkNumber: number; + networkType: string; + networkTypeOptions: SelectItem[]; + }; + +type AnHostInputGroupOptionalProps = { + hostLabel?: string; + previous?: Pick; +}; + +type AnHostInputGroupProps = + AnHostInputGroupOptionalProps & { + formUtils: FormUtils; + hostId: string; + hostNumber: number; + hostType: string; + }; + +type AnNetworkConfigInputGroupOptionalProps = { + previous?: Partial; +}; + +type AnNetworkConfigInputGroupProps = + AnNetworkConfigInputGroupOptionalProps & { + formUtils: FormUtils; + networkListEntries: Array<[string, ManifestNetwork]>; + setNetworkList: import('react').Dispatch< + import('react').SetStateAction + >; + }; + +type AnHostConfigInputGroupOptionalProps = { + knownFences?: APIManifestTemplateFenceList; + knownUpses?: APIManifestTemplateUpsList; + previous?: Partial; +}; + +type AnHostConfigInputGroupProps = + AnHostConfigInputGroupOptionalProps & { + formUtils: FormUtils; + networkListEntries: Array<[string, ManifestNetwork]>; + }; + +type AddManifestInputGroupOptionalProps = Pick< + AnHostConfigInputGroupOptionalProps, + 'knownFences' | 'knownUpses' +> & { + previous?: Partial & { + hostConfig?: Partial; + networkConfig?: Partial; + }; +}; + +type AddManifestInputGroupProps = + AddManifestInputGroupOptionalProps & { + formUtils: FormUtils; + }; + +type EditManifestInputGroupProps = + AddManifestInputGroupProps; + +type RunManifestInputGroupOptionalProps = { + knownHosts?: APIHostOverviewList; +}; + +type RunManifestInputGroupProps = + RunManifestInputGroupOptionalProps & AddManifestInputGroupProps; diff --git a/striker-ui/types/MapToMessageSetter.d.ts b/striker-ui/types/MapToMessageSetter.d.ts index 537f2644..cf781b81 100644 --- a/striker-ui/types/MapToMessageSetter.d.ts +++ b/striker-ui/types/MapToMessageSetter.d.ts @@ -1,5 +1,5 @@ type MapToMessageSetter = { - [MessageSetterID in keyof T]: MessageSetterFunction; + [MessageSetterID in keyof T]: MessageSetter; }; type InputIds = ReadonlyArray | MapToInputTestID; diff --git a/striker-ui/types/MessageSetterFunction.d.ts b/striker-ui/types/MessageSetterFunction.d.ts index 40c5da0e..7d04f76c 100644 --- a/striker-ui/types/MessageSetterFunction.d.ts +++ b/striker-ui/types/MessageSetterFunction.d.ts @@ -1,3 +1,3 @@ -type MessageSetterFunction = ( +type MessageSetter = ( message?: import('../components/MessageBox').Message, ) => void; diff --git a/striker-ui/types/Select.d.ts b/striker-ui/types/Select.d.ts index 6b656304..fd678f4d 100644 --- a/striker-ui/types/Select.d.ts +++ b/striker-ui/types/Select.d.ts @@ -1,3 +1,8 @@ +type SelectChangeEventHandler = Exclude< + import('@mui/material').SelectProps['onChange'], + undefined +>; + type SelectOptionalProps = { onClearIndicatorClick?: import('@mui/material').IconButtonProps['onClick']; }; diff --git a/striker-ui/types/SwitchWithLabel.d.ts b/striker-ui/types/SwitchWithLabel.d.ts index 4015119c..27026d61 100644 --- a/striker-ui/types/SwitchWithLabel.d.ts +++ b/striker-ui/types/SwitchWithLabel.d.ts @@ -1,5 +1,6 @@ type SwitchWithLabelOptionalProps = { - flexBoxProps?: import('../components/FlexBox').FlexBoxProps; + baseInputProps?: import('@mui/material').InputBaseComponentProps; + formControlLabelProps?: import('@mui/material').FormControlLabelProps; switchProps?: import('@mui/material').SwitchProps; };