Merge pull request #343 from ylei-tsubame/manage-manifest

Web UI: add manifests manager
main
Digimer 2 years ago committed by GitHub
commit bdbba0d59d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 57
      Anvil/Tools/Striker.pm
  2. 19
      striker-ui-api/src/lib/accessModule.ts
  3. 1
      striker-ui-api/src/lib/consts/SERVER_PATHS.ts
  4. 20
      striker-ui-api/src/lib/disassembleEntityId.ts
  5. 8
      striker-ui-api/src/lib/disassembleHostName.ts
  6. 2
      striker-ui-api/src/lib/getShortHostName.ts
  7. 1
      striker-ui-api/src/lib/request_handlers/command/index.ts
  8. 196
      striker-ui-api/src/lib/request_handlers/command/runManifest.ts
  9. 2
      striker-ui-api/src/lib/request_handlers/host/buildQueryHostDetail.ts
  10. 2
      striker-ui-api/src/lib/request_handlers/host/getHost.ts
  11. 285
      striker-ui-api/src/lib/request_handlers/manifest/buildManifest.ts
  12. 29
      striker-ui-api/src/lib/request_handlers/manifest/createManifest.ts
  13. 37
      striker-ui-api/src/lib/request_handlers/manifest/deleteManifest.ts
  14. 34
      striker-ui-api/src/lib/request_handlers/manifest/getManifest.ts
  15. 266
      striker-ui-api/src/lib/request_handlers/manifest/getManifestDetail.ts
  16. 119
      striker-ui-api/src/lib/request_handlers/manifest/getManifestTemplate.ts
  17. 6
      striker-ui-api/src/lib/request_handlers/manifest/index.ts
  18. 34
      striker-ui-api/src/lib/request_handlers/manifest/updateManifest.ts
  19. 2
      striker-ui-api/src/routes/command.ts
  20. 2
      striker-ui-api/src/routes/index.ts
  21. 23
      striker-ui-api/src/routes/manifest.ts
  22. 112
      striker-ui-api/src/types/APIManifest.d.ts
  23. 90
      striker-ui-api/src/types/GetAnvilDataFunction.d.ts
  24. 65
      striker-ui/components/ConfirmDialog.tsx
  25. 56
      striker-ui/components/IconButton/IconButton.tsx
  26. 21
      striker-ui/components/InputWithRef.tsx
  27. 1
      striker-ui/components/ManageFence/CommonFenceInputGroup.tsx
  28. 88
      striker-ui/components/ManageManifest/AddManifestInputGroup.tsx
  29. 130
      striker-ui/components/ManageManifest/AnHostConfigInputGroup.tsx
  30. 404
      striker-ui/components/ManageManifest/AnHostInputGroup.tsx
  31. 135
      striker-ui/components/ManageManifest/AnIdInputGroup.tsx
  32. 388
      striker-ui/components/ManageManifest/AnNetworkConfigInputGroup.tsx
  33. 345
      striker-ui/components/ManageManifest/AnNetworkInputGroup.tsx
  34. 39
      striker-ui/components/ManageManifest/EditManifestInputGroup.tsx
  35. 572
      striker-ui/components/ManageManifest/ManageManifestPanel.tsx
  36. 454
      striker-ui/components/ManageManifest/RunManifestInputGroup.tsx
  37. 3
      striker-ui/components/ManageManifest/index.tsx
  38. 4
      striker-ui/components/ManageUps/AddUpsInputGroup.tsx
  39. 14
      striker-ui/components/ManageUps/CommonUpsInputGroup.tsx
  40. 23
      striker-ui/components/MessageGroup.tsx
  41. 24
      striker-ui/components/NetworkInitForm.tsx
  42. 5
      striker-ui/components/OutlinedInputWithLabel.tsx
  43. 34
      striker-ui/components/Panels/InnerPanel.tsx
  44. 27
      striker-ui/components/Panels/InnerPanelBody.tsx
  45. 58
      striker-ui/components/SwitchWithLabel.tsx
  46. 63
      striker-ui/hooks/useFormUtils.ts
  47. 6
      striker-ui/lib/buildMapToMessageSetter.ts
  48. 77
      striker-ui/lib/buildObjectStateSetterCallback.ts
  49. 2
      striker-ui/lib/consts/NETWORK_TYPES.ts
  50. 4
      striker-ui/lib/test_input/buildDomainTestBatch.tsx
  51. 6
      striker-ui/lib/test_input/buildIPAddressTestBatch.tsx
  52. 33
      striker-ui/lib/test_input/buildIpCsvTestBatch.tsx
  53. 8
      striker-ui/lib/test_input/buildPeacefulStringTestBatch.tsx
  54. 6
      striker-ui/lib/test_input/buildUUIDTestBatch.tsx
  55. 4
      striker-ui/lib/test_input/index.ts
  56. 10
      striker-ui/lib/test_input/testInput.ts
  57. 56
      striker-ui/pages/manage-element/index.tsx
  58. 55
      striker-ui/types/APIManifest.d.ts
  59. 24
      striker-ui/types/BuildObjectStateSetterCallback.d.ts
  60. 7
      striker-ui/types/ConfirmDialog.d.ts
  61. 21
      striker-ui/types/FormUtils.d.ts
  62. 21
      striker-ui/types/IconButton.d.ts
  63. 8
      striker-ui/types/InnerPanel.d.ts
  64. 185
      striker-ui/types/ManageManifest.d.ts
  65. 2
      striker-ui/types/MapToMessageSetter.d.ts
  66. 2
      striker-ui/types/MessageSetterFunction.d.ts
  67. 5
      striker-ui/types/Select.d.ts
  68. 3
      striker-ui/types/SwitchWithLabel.d.ts

@ -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 = '<?xml version="1.0" encoding="UTF-8"?>
<install_manifest name="'.$anvil_name.'" domain="'.$anvil->data->{cgi}{domain}{value}.'">
<networks mtu="'.$anvil->data->{cgi}{mtu}{value}.'" dns="'.$anvil->data->{cgi}{dns}{value}.'" ntp="'.$anvil->data->{cgi}{ntp}{value}.'">
<install_manifest name="'.$anvil_name.'" domain="'.$domain.'">
<networks mtu="'.$network_mtu.'" dns="'.$network_dns.'" ntp="'.$network_ntp.'">
';
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 $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";
$manifest_xml .= ' <network name="'.$network_name.'" network="'.$anvil->data->{cgi}{$network_key}{value}.'" subnet="'.$anvil->data->{cgi}{$subnet_key}{value}.'" gateway="'.$anvil->data->{cgi}{$gateway_key}{value}.'" />'."\n";
my $gateway_value = $parameter->{$gateway_key} // $anvil->data->{cgi}{$gateway_key}{value};
$manifest_xml .= ' <network name="'.$network_name.'" network="'.$network_value.'" subnet="'.$subnet_value.'" gateway="'.$gateway_value.'" />'."\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_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_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_value = ($parameter->{$fence_key} // $anvil->data->{cgi}{$fence_key}{value}) // "";
$machines->{$machine}{fence}{$fence_name} = $fence_value;
}
}
}

@ -148,6 +148,22 @@ const getAnvilData = <HashType>(
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,

@ -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': {},

@ -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 };
};

@ -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(/[.].*$/, '');

@ -1,2 +0,0 @@
export const getShortHostName = (hostName: string) =>
hostName.replace(/[.].*$/, '');

@ -1,4 +1,5 @@
export * from './getHostSSH';
export * from './poweroffHost';
export * from './rebootHost';
export * from './runManifest';
export * from './updateSystem';

@ -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<string, boolean | undefined> = {};
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<string, string> | undefined;
try {
anParams = Object.values(hostList).reduce<Record<string, string>>(
(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();
};

@ -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;

@ -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) => {

@ -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<string, number>;
networks: Record<string, string>;
}>(
(
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<Record<string, string>>(
(
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;
};

@ -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<string, string> = {};
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);
};

@ -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();
};

@ -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;
},
);

@ -0,0 +1,266 @@
import { RequestHandler } from 'express';
import { getAnvilData } from '../../accessModule';
import { getEntityParts } from '../../disassembleEntityId';
import { stderr, stdout } from '../../shell';
const handleSortEntries = <T extends [string, unknown]>(
[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 = <T extends [string, unknown]>(
[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<ManifestDetailHostList>(
(
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<ManifestDetailFenceList>(
(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<ManifestDetailHostNetworkList>(
(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<ManifestDetailUpsList>((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<ManifestDetailNetworkList>(
(
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);
};

@ -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<ManifestTemplate, 'fences' | 'sequence' | 'upses'>
>(
(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);
};

@ -0,0 +1,6 @@
export * from './createManifest';
export * from './deleteManifest';
export * from './getManifest';
export * from './getManifestDetail';
export * from './getManifestTemplate';
export * from './updateManifest';

@ -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<string, string> = {};
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);
};

@ -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;

@ -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<Record<string, Router>> = {
file: fileRouter,
host: hostRouter,
job: jobRouter,
manifest: manifestRouter,
'network-interface': networkInterfaceRouter,
server: serverRouter,
'ssh-key': sshKeyRouter,

@ -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;

@ -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<ManifestDetail, 'name'>;
type RunManifestRequestBody = {
debug?: number;
description: string;
hosts: ManifestExecutionHostList;
password: string;
};

@ -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<string, string>;
} & 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;

@ -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<boolean>(openInitially);
@ -141,6 +146,7 @@ const ConfirmDialog = forwardRef<
const proceedButtonElement = useMemo(
() => (
<ContainedButton
disabled={proceedButtonDisabled}
onClick={proceedButtonClickEventHandler}
type={proceedButtonType}
{...restProceedButtonProps}
@ -159,6 +165,7 @@ const ConfirmDialog = forwardRef<
[
actionProceedText,
proceedButtonClickEventHandler,
proceedButtonDisabled,
proceedButtonSx,
proceedButtonType,
proceedColour,
@ -196,16 +203,52 @@ const ConfirmDialog = forwardRef<
),
[titleText],
);
const combinedScrollBoxSx = useMemo<SxProps<Theme> | undefined>(
() =>
isScrollContent
? {
const combinedScrollBoxSx = useMemo(() => {
let result: SxProps<Theme> | 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,
};
}
: undefined,
[isScrollContent, scrollBoxSx],
return result;
}, [isFormContent, isScrollContent, scrollBoxSx]);
const contentAreaElement = useMemo(
() =>
isLoading ? (
<Spinner />
) : (
<>
<Box {...restScrollBoxProps} sx={combinedScrollBoxSx}>
{contentElement}
</Box>
{preActionArea}
{actionAreaElement}
</>
),
[
actionAreaElement,
combinedScrollBoxSx,
contentElement,
isLoading,
preActionArea,
restScrollBoxProps,
],
);
useImperativeHandle(
@ -232,11 +275,7 @@ const ConfirmDialog = forwardRef<
onSubmit={contentContainerSubmitEventHandler}
{...contentContainerProps}
>
<Box {...restScrollBoxProps} sx={combinedScrollBoxSx}>
{contentElement}
</Box>
{preActionArea}
{actionAreaElement}
{contentAreaElement}
</FlexBox>
</MUIDialog>
);

@ -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<IconButtonProps> = ({
iconProps,
mapPreset,
mapToIcon: externalMapToIcon,
state,
state = 'none',
variant = 'contained',
...restIconButtonProps
}) => {
const mapToIcon = useMemo<IconButtonMapToStateIcon | undefined>(
const mapToIcon = useMemo<IconButtonMapToStateIconBundle | undefined>(
() => externalMapToIcon ?? (mapPreset && MAP_TO_MAP_PRESET[mapPreset]),
[externalMapToIcon, mapPreset],
);
@ -81,19 +100,22 @@ const IconButton: FC<IconButtonProps> = ({
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],

@ -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<TypeName>['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<MapToInputType[TypeName]>(initValue);
const [isChangedByUser, setIsChangedByUser] = useState<boolean>(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,

@ -24,7 +24,6 @@ const MAP_TO_INPUT_BUILDER: MapToInputBuilder = {
input={
<SwitchWithLabel
checked={isChecked}
flexBoxProps={{ width: '100%' }}
id={id}
label={label}
name={name}

@ -0,0 +1,88 @@
import { ReactElement, useMemo, useState } from 'react';
import AnHostConfigInputGroup from './AnHostConfigInputGroup';
import AnIdInputGroup, {
INPUT_ID_AI_DOMAIN,
INPUT_ID_AI_PREFIX,
INPUT_ID_AI_SEQUENCE,
} from './AnIdInputGroup';
import AnNetworkConfigInputGroup, {
INPUT_ID_ANC_DNS,
INPUT_ID_ANC_MTU,
INPUT_ID_ANC_NTP,
} from './AnNetworkConfigInputGroup';
import FlexBox from '../FlexBox';
const DEFAULT_NETWORK_LIST: ManifestNetworkList = {
bcn1: {
networkMinIp: '10.201.0.0',
networkNumber: 1,
networkSubnetMask: '255.255.0.0',
networkType: 'bcn',
},
sn1: {
networkMinIp: '10.101.0.0',
networkNumber: 1,
networkSubnetMask: '255.255.0.0',
networkType: 'sn',
},
ifn1: {
networkMinIp: '',
networkNumber: 1,
networkSubnetMask: '',
networkType: 'ifn',
},
};
const AddManifestInputGroup = <
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: {
hostConfig: previousHostConfig,
networkConfig: previousNetworkConfig = {},
...previousAnId
} = {},
}: AddManifestInputGroupProps<M>): ReactElement => {
const { networks: previousNetworkList = DEFAULT_NETWORK_LIST } =
previousNetworkConfig;
const [networkList, setNetworkList] =
useState<ManifestNetworkList>(previousNetworkList);
const networkListEntries = useMemo(
() => Object.entries(networkList),
[networkList],
);
return (
<FlexBox>
<AnIdInputGroup formUtils={formUtils} previous={previousAnId} />
<AnNetworkConfigInputGroup
formUtils={formUtils}
networkListEntries={networkListEntries}
previous={previousNetworkConfig}
setNetworkList={setNetworkList}
/>
<AnHostConfigInputGroup
formUtils={formUtils}
knownFences={knownFences}
knownUpses={knownUpses}
networkListEntries={networkListEntries}
previous={previousHostConfig}
/>
</FlexBox>
);
};
export default AddManifestInputGroup;

@ -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 = <M extends MapToInputTestID>({
formUtils,
knownFences = {},
knownUpses = {},
networkListEntries,
previous: { hosts: previousHostList = DEFAULT_HOST_LIST } = {},
}: AnHostConfigInputGroupProps<M>): 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<GridLayout>(
() =>
hostListEntries.reduce<GridLayout>(
(previous, [hostId, previousHostArgs]) => {
const {
fences: previousFenceList = {},
hostNumber,
hostType,
ipmiIp,
networks: previousNetworkList = {},
upses: previousUpsList = {},
}: ManifestHost = previousHostArgs;
const fences = knownFenceListValues.reduce<ManifestHostFenceList>(
(fenceList, { fenceName }) => {
const { [fenceName]: { fencePort = '' } = {} } =
previousFenceList;
fenceList[fenceName] = { fenceName, fencePort };
return fenceList;
},
{},
);
const networks = networkListEntries.reduce<ManifestHostNetworkList>(
(networkList, [networkId, { networkNumber, networkType }]) => {
const { [networkId]: { networkIp = '' } = {} } =
previousNetworkList;
networkList[networkId] = {
networkIp,
networkNumber,
networkType,
};
return networkList;
},
{},
);
const upses = knownUpsListValues.reduce<ManifestHostUpsList>(
(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: (
<AnHostInputGroup
formUtils={formUtils}
hostId={hostId}
hostNumber={hostNumber}
hostType={hostType}
previous={{ fences, ipmiIp, networks, upses }}
/>
),
md: 3,
sm: 2,
};
return previous;
},
{},
),
[
formUtils,
hostListEntries,
knownFenceListValues,
knownUpsListValues,
networkListEntries,
],
);
return (
<Grid
columns={{ xs: 1, sm: 2, md: 3 }}
layout={hostListGridLayout}
spacing="1em"
/>
);
};
export default AnHostConfigInputGroup;

@ -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 = <M extends MapToInputTestID>({
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<M>): 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<GridLayout>(
(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: (
<InputWithRef
input={
<OutlinedInputWithLabel
baseInputProps={{
'data-handler': 'fence',
'data-host-id': hostId,
'data-fence-id': fenceId,
'data-fence-name': fenceName,
}}
id={inputId}
label={inputLabel}
value={fencePort}
/>
}
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<GridLayout>(
(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: (
<InputWithRef
input={
<OutlinedInputWithLabel
baseInputProps={{
'data-handler': 'network',
'data-host-id': hostId,
'data-network-id': networkId,
'data-network-number': networkNumber,
'data-network-type': networkType,
}}
id={inputId}
label={inputLabel}
value={networkIp}
/>
}
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<GridLayout>(
(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: (
<InputWithRef
input={
<SwitchWithLabel
baseInputProps={{
'data-handler': 'ups',
'data-host-id': hostId,
'data-ups-id': upsId,
'data-ups-name': upsName,
}}
checked={isUsed}
id={inputId}
label={inputLabel}
/>
}
valueType="boolean"
/>
),
};
return previous;
},
{},
),
[hostId, upsListEntries],
);
const upsListGrid = useMemo(
() =>
isShowUpsListGrid && (
<Grid
columns={GRID_COLUMNS}
layout={upsListGridLayout}
spacing={GRID_SPACING}
/>
),
[isShowUpsListGrid, upsListGridLayout],
);
return (
<InnerPanel mv={0}>
<InnerPanelHeader>
<BodyText>{hostLabel}</BodyText>
</InnerPanelHeader>
<InnerPanelBody>
<input
hidden
id={inputIdAHHost}
readOnly
data-handler="host"
data-host-id={hostId}
data-host-number={hostNumber}
data-host-type={hostType}
/>
<FlexBox>
<Grid
columns={GRID_COLUMNS}
layout={{
...networkListGridLayout,
[inputCellIdAHIpmiIp]: {
children: (
<InputWithRef
input={
<OutlinedInputWithLabel
baseInputProps={{
'data-handler': 'ipmi',
'data-host-id': hostId,
}}
id={inputIdAHIpmiIp}
label={INPUT_LABEL_AH_IPMI_IP}
value={previousIpmiIp}
/>
}
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}
</FlexBox>
</InnerPanelBody>
</InnerPanel>
);
};
export {
INPUT_ID_PREFIX_AN_HOST,
MAP_TO_AH_INPUT_HANDLER,
buildInputIdAHFencePort,
buildInputIdAHIpmiIp,
buildInputIdAHNetworkIp,
buildInputIdAHUpsPowerHost,
};
export default AnHostInputGroup;

@ -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<M>): ReactElement => (
<Grid
columns={{ xs: 1, sm: 2, md: 3 }}
layout={{
'an-id-input-cell-prefix': {
children: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_AI_PREFIX}
label={INPUT_LABEL_AI_PREFIX}
value={previousPrefix}
/>
}
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: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_AI_DOMAIN}
label={INPUT_LABEL_AI_DOMAIN}
value={previousDomain}
/>
}
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: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_AI_SEQUENCE}
label={INPUT_LABEL_AI_SEQUENCE}
value={previousSequence}
/>
}
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;

@ -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<M>): 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<SelectItem[]>(
() =>
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<ManifestNetwork> = {}): {
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<AnNetworkTypeChangeEventHandler>(
(
{ networkId: targetId, networkType: previousType },
{ target: { value } },
) => {
const newType = String(value);
let isIdMatch = false;
let newTypeNumber = 0;
const newList = networkListEntries.reduce<ManifestNetworkList>(
(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<AnNetworkCloseEventHandler>(
({ networkId: rmId, networkType: rmType }) => {
let isIdMatch = false;
let networkNumber = 0;
const newList = networkListEntries.reduce<ManifestNetworkList>(
(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<GridLayout>(() => {
let result: GridLayout = {};
result = networkListEntries.reduce<GridLayout>(
(
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: (
<AnNetworkInputGroup
formUtils={formUtils}
networkId={networkId}
networkNumber={networkNumber}
networkType={networkType}
networkTypeOptions={networkTypeOptions}
onClose={handleNetworkRemove}
onNetworkTypeChange={handleNetworkTypeChange}
previous={{
gateway: networkGateway,
minIp: networkMinIp,
subnetMask: networkSubnetMask,
}}
readonlyNetworkName={!isOptional}
showCloseButton={isOptional}
showGateway={isIfn}
/>
),
md: 3,
sm: 2,
};
return previous;
},
result,
);
return result;
}, [
formUtils,
networkListEntries,
networkTypeOptions,
handleNetworkRemove,
handleNetworkTypeChange,
]);
return (
<Grid
columns={{ xs: 1, sm: 2, md: 3 }}
layout={{
...networksGridLayout,
'an-network-config-cell-add-network': {
children: (
<IconButton
mapPreset="add"
onClick={() => {
const { network: newNet, networkId: newNetId } = buildNetwork();
setNetwork(newNetId, newNet);
}}
/>
),
display: 'flex',
justifyContent: 'center',
md: 3,
sm: 2,
},
'an-network-config-input-cell-dns': {
children: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_ANC_DNS}
label={INPUT_LABEL_ANC_DNS}
value={previousDnsCsv}
/>
}
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: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_ANC_NTP}
label={INPUT_LABEL_ANC_NTP}
value={previousNtpCsv}
/>
}
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: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_ANC_MTU}
inputProps={{ placeholder: '1500' }}
label={INPUT_LABEL_ANC_MTU}
value={previousMtu}
/>
}
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;

@ -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 = <M extends MapToInputTestID>({
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<M>): 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<ReactNode>(
() =>
isShowCloseButton && (
<IconButton
mapPreset="close"
iconProps={{ fontSize: 'small' }}
onClick={(...args) => {
onClose?.call(null, { networkId, networkType }, ...args);
}}
sx={{
padding: '.2em',
position: 'absolute',
right: '-.6rem',
top: '-.2rem',
}}
/>
),
[isShowCloseButton, networkId, networkType, onClose],
);
const inputGatewayElement = useMemo<ReactNode>(() => {
let result: ReactNode;
if (isShowGateway && inputIdGateway) {
result = (
<InputWithRef
input={
<OutlinedInputWithLabel
baseInputProps={{
'data-handler': 'gateway',
'data-network-id': networkId,
}}
id={inputIdGateway}
label={inputGatewayLabel}
value={previousGateway}
/>
}
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 (
<InnerPanel mv={0}>
<InnerPanelHeader>
<InputWithRef
input={
<SelectWithLabel
id={inputIdNetworkType}
isReadOnly={isReadonlyNetworkName}
onChange={(...args) => {
onNetworkTypeChange?.call(
null,
{ networkId, networkType },
...args,
);
}}
selectItems={networkTypeOptions}
selectProps={{
renderValue: () => networkName,
}}
value={networkType}
/>
}
/>
{closeButtonElement}
</InnerPanelHeader>
<InnerPanelBody>
<input
hidden
id={inputIdANNetwork}
readOnly
data-handler="network"
data-network-id={networkId}
data-network-number={networkNumber}
data-network-type={networkType}
/>
<Grid
layout={{
[inputCellIdIp]: {
children: (
<InputWithRef
input={
<OutlinedInputWithLabel
baseInputProps={{
'data-handler': 'minip',
'data-network-id': networkId,
}}
id={inputIdMinIp}
label={inputMinIpLabel}
value={previousIpAddress}
/>
}
inputTestBatch={buildIPAddressTestBatch(
`${networkName} ${inputMinIpLabel}`,
() => {
setMessage(inputIdMinIp);
},
{
onFinishBatch:
buildFinishInputTestBatchFunction(inputIdMinIp),
},
(message) => {
setMessage(inputIdMinIp, { children: message });
},
)}
onFirstRender={buildInputFirstRenderFunction(inputIdMinIp)}
onUnmount={buildInputUnmountFunction(inputIdMinIp)}
required
/>
),
},
[inputCellIdSubnetMask]: {
children: (
<InputWithRef
input={
<OutlinedInputWithLabel
baseInputProps={{
'data-handler': 'subnetmask',
'data-network-id': networkId,
}}
id={inputIdSubnetMask}
label={inputSubnetMaskLabel}
value={previousSubnetMask}
/>
}
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"
/>
</InnerPanelBody>
</InnerPanel>
);
};
export {
INPUT_ID_PREFIX_AN_NETWORK,
MAP_TO_AN_INPUT_HANDLER,
buildInputIdANGateway,
buildInputIdANMinIp,
buildInputIdANNetworkType,
buildInputIdANSubnetMask,
};
export default AnNetworkInputGroup;

@ -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<M>): ReactElement => (
<AddManifestInputGroup
formUtils={formUtils}
knownFences={knownFences}
knownUpses={knownUpses}
previous={previous}
/>
);
export default EditManifestInputGroup;

@ -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<APIBuildManifestRequestBody>(
(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<ConfirmDialogForwardedRefContent>({});
const addManifestFormDialogRef = useRef<ConfirmDialogForwardedRefContent>({});
const editManifestFormDialogRef = useRef<ConfirmDialogForwardedRefContent>(
{},
);
const runManifestFormDialogRef = useRef<ConfirmDialogForwardedRefContent>({});
const messageGroupRef = useRef<MessageGroupForwardedRefContent>({});
const [confirmDialogProps, setConfirmDialogProps] = useConfirmDialogProps();
const [hostOverviews, setHostOverviews] = useProtectedState<
APIHostOverviewList | undefined
>(undefined);
const [isEditManifests, setIsEditManifests] = useState<boolean>(false);
const [isLoadingHostOverviews, setIsLoadingHostOverviews] =
useProtectedState<boolean>(true);
const [isLoadingManifestDetail, setIsLoadingManifestDetail] =
useProtectedState<boolean>(true);
const [isLoadingManifestTemplate, setIsLoadingManifestTemplate] =
useProtectedState<boolean>(true);
const [isSubmittingForm, setIsSubmittingForm] =
useProtectedState<boolean>(false);
const [manifestDetail, setManifestDetail] = useProtectedState<
APIManifestDetail | undefined
>(undefined);
const [manifestTemplate, setManifestTemplate] = useProtectedState<
APIManifestTemplate | undefined
>(undefined);
const { data: manifestOverviews, isLoading: isLoadingManifestOverviews } =
periodicFetch<APIManifestOverviewList>(`${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<Partial<APIManifestDetail>>(
() => manifestDetail ?? {},
[manifestDetail],
);
const {
domain: mtemplateDomain,
fences: knownFences,
prefix: mtemplatePrefix,
sequence: mtemplateSequence,
upses: knownUpses,
} = useMemo<Partial<APIManifestTemplate>>(
() => manifestTemplate ?? {},
[manifestTemplate],
);
const submitForm = useCallback(
({
body,
getErrorMsg,
method,
successMsg,
url,
}: {
body: Record<string, unknown>;
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<ConfirmDialogProps>(
() => ({
actionProceedText: 'Add',
content: (
<AddManifestInputGroup
formUtils={formUtils}
knownFences={knownFences}
knownUpses={knownUpses}
previous={{
domain: mtemplateDomain,
prefix: mtemplatePrefix,
sequence: mtemplateSequence,
}}
/>
),
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<ConfirmDialogProps>(
() => ({
actionProceedText: 'Edit',
content: (
<EditManifestInputGroup
formUtils={formUtils}
knownFences={knownFences}
knownUpses={knownUpses}
previous={manifestDetail}
/>
),
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<ConfirmDialogProps>(
() => ({
actionProceedText: 'Run',
content: (
<RunManifestInputGroup
formUtils={runFormUtils}
knownFences={knownFences}
knownHosts={hostOverviews}
knownUpses={knownUpses}
previous={manifestDetail}
/>
),
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<APIManifestDetail>(`manifest/${manifestUuid}`)
.then(({ data }) => {
data.uuid = manifestUuid;
setManifestDetail(data);
})
.catch((error) => {
handleAPIError(error);
})
.finally(() => {
setIsLoadingManifestDetail(false);
finallyAppend?.call(null);
});
},
[setIsLoadingManifestDetail, setManifestDetail],
);
const listElement = useMemo(
() => (
<List
allowEdit
allowItemButton={isEditManifests}
edit={isEditManifests}
header
listEmpty="No manifest(s) registered."
listItems={manifestOverviews}
onAdd={() => {
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 }) => (
<FlexBox fullWidth row>
<IconButton
disabled={isEditManifests}
mapPreset="play"
onClick={() => {
setManifestDetail({
name: manifestName,
uuid: manifestUUID,
} as APIManifestDetail);
runManifestFormDialogRef.current.setOpen?.call(null, true);
getManifestDetail(manifestUUID);
}}
variant="normal"
/>
<BodyText>{manifestName}</BodyText>
</FlexBox>
)}
/>
),
[getManifestDetail, isEditManifests, manifestOverviews, setManifestDetail],
);
const panelContent = useMemo(
() =>
isLoadingHostOverviews ||
isLoadingManifestTemplate ||
isLoadingManifestOverviews ? (
<Spinner />
) : (
listElement
),
[
isLoadingHostOverviews,
isLoadingManifestOverviews,
isLoadingManifestTemplate,
listElement,
],
);
const messageArea = useMemo(
() => (
<MessageGroup
count={1}
defaultMessageType="warning"
ref={messageGroupRef}
/>
),
[],
);
if (isFirstRender) {
api
.get<APIManifestTemplate>('/manifest/template')
.then(({ data }) => {
setManifestTemplate(data);
})
.catch((error) => {
handleAPIError(error);
})
.finally(() => {
setIsLoadingManifestTemplate(false);
});
api
.get<APIHostOverviewList>('/host', { params: { types: 'node' } })
.then(({ data }) => {
setHostOverviews(data);
})
.catch((apiError) => {
handleAPIError(apiError);
})
.finally(() => {
setIsLoadingHostOverviews(false);
});
}
return (
<>
<Panel>
<PanelHeader>
<HeaderText>Manage manifests</HeaderText>
</PanelHeader>
{panelContent}
</Panel>
<FormDialog
{...addManifestFormDialogProps}
disableProceed={isFormInvalid}
loadingAction={isSubmittingForm}
preActionArea={messageArea}
ref={addManifestFormDialogRef}
scrollContent
/>
<FormDialog
{...editManifestFormDialogProps}
disableProceed={isFormInvalid}
loadingAction={isSubmittingForm}
preActionArea={messageArea}
ref={editManifestFormDialogRef}
scrollContent
/>
<FormDialog
{...runManifestFormDialogProps}
disableProceed={isRunFormInvalid}
loadingAction={isSubmittingForm}
preActionArea={messageArea}
ref={runManifestFormDialogRef}
scrollContent
/>
<ConfirmDialog
closeOnProceed
{...confirmDialogProps}
ref={confirmDialogRef}
/>
</>
);
};
export default ManageManifestPanel;

@ -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 = <M extends MapToInputTestID>({
formUtils: {
buildFinishInputTestBatchFunction,
buildInputFirstRenderFunction,
setMessage,
},
knownFences = {},
knownHosts = {},
knownUpses = {},
previous: { domain: anDomain, hostConfig = {}, networkConfig = {} } = {},
}: RunManifestInputGroupProps<M>): ReactElement => {
const passwordRef = useRef<InputForwardedRefContent<'string'>>({});
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<SelectItem>(([, { 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: <BodyText>{prettyId}</BodyText>,
};
const inputId = buildInputIdRMHost(hostId);
const inputLabel = `${prettyId} host`;
hosts[`run-manifest-host-cell-${hostId}`] = {
children: (
<InputWithRef
input={
<SelectWithLabel
id={inputId}
label={inputLabel}
selectItems={hostOptionList}
value=""
/>
}
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: (
<MonoText>
{hostName}.{anDomain}
</MonoText>
),
};
return previous;
},
{
headers: {
'run-manifest-column-header-cell-offset': {},
},
hosts: {
'run-manifest-host-cell-header': {
children: <BodyText>Uses host</BodyText>,
},
},
hostNames: {
'run-manifest-new-host-name-cell-header': {
children: <BodyText>New hostname</BodyText>,
},
},
},
),
[
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: <BodyText>{networkShortName}</BodyText>,
};
hostListEntries.forEach(([hostId, { networks = {} }]) => {
const {
[networkId]: { networkIp: ip = MANIFEST_PARAM_NONE } = {},
} = networks;
hostNetworks[`${idPrefix}-${hostId}-ip`] = {
children: <MonoText>{ip}</MonoText>,
};
});
const cellId = 'run-manifest-gateway-cell';
if (networkGateway && !gateway[cellId]) {
gateway[cellId] = {
children: <EndMono>{networkGateway}</EndMono>,
};
}
return previous;
},
{
gateway: {
'run-manifest-gateway-cell-header': {
children: <BodyText>Gateway</BodyText>,
},
},
hostNetworks: {},
},
),
[hostListEntries, networkListEntries],
);
const hostFenceRowList = useMemo(
() =>
knownFenceListEntries.reduce<GridLayout>(
(previous, [fenceUuid, { fenceName }]) => {
const idPrefix = `run-manifest-fence-cell-${fenceUuid}`;
previous[`${idPrefix}-header`] = {
children: <BodyText>Port on {fenceName}</BodyText>,
};
hostListEntries.forEach(([hostId, { fences = {} }]) => {
const { [fenceName]: { fencePort = MANIFEST_PARAM_NONE } = {} } =
fences;
previous[`${idPrefix}-${hostId}-port`] = {
children: <MonoText>{fencePort}</MonoText>,
};
});
return previous;
},
{},
),
[hostListEntries, knownFenceListEntries],
);
const hostUpsRowList = useMemo(
() =>
knownUpsListEntries.reduce<GridLayout>(
(previous, [upsUuid, { upsName }]) => {
const idPrefix = `run-manifest-ups-cell-${upsUuid}`;
previous[`${idPrefix}-header`] = {
children: <BodyText>Uses {upsName}</BodyText>,
};
hostListEntries.forEach(([hostId, { upses = {} }]) => {
const { [upsName]: { isUsed = false } = {} } = upses;
previous[`${idPrefix}-${hostId}-is-used`] = {
children: <MonoText>{isUsed ? 'yes' : 'no'}</MonoText>,
};
});
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 (
<FlexBox>
<Grid
columns={{ xs: 1, sm: 2 }}
layout={{
'run-manifest-input-cell-an-description': {
children: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_RM_AN_DESCRIPTION}
label={INPUT_LABEL_RM_AN_DESCRIPTION}
/>
}
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: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_RM_AN_PASSWORD}
label={INPUT_LABEL_RM_AN_PASSWORD}
type={INPUT_TYPES.password}
/>
}
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: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_RM_AN_CONFIRM_PASSWORD}
label={INPUT_LABEL_RM_AN_CONFIRM_PASSWORD}
type={INPUT_TYPES.password}
/>
}
required
{...confirmPasswordProps}
/>
),
},
}}
spacing="1em"
/>
<Grid
alignItems="center"
columns={{ xs: hostListEntries.length + 1 }}
layout={{
...hostHeaderRow,
...hostSelectRow,
...hostNewNameRow,
...hostNetworkRowList,
...hostFenceRowList,
...hostUpsRowList,
}}
columnSpacing="1em"
rowSpacing="0.4em"
/>
<Grid
columns={{ xs: 2 }}
layout={{
...defaultGatewayGridLayout,
'run-manifest-dns-csv-cell-header': {
children: <BodyText>DNS</BodyText>,
},
'run-manifest-dns-csv-cell': {
children: <EndMono>{dnsCsv}</EndMono>,
},
'run-manifest-ntp-csv-cell-header': {
children: <BodyText>NTP</BodyText>,
},
'run-manifest-ntp-csv-cell': {
children: <EndMono>{ntpCsv}</EndMono>,
},
'run-manifest-mtu-cell-header': {
children: <BodyText>MTU</BodyText>,
},
'run-manifest-mtu-cell': {
children: <EndMono>{mtu}</EndMono>,
},
}}
spacing="0.4em"
/>
</FlexBox>
);
};
export {
INPUT_ID_RM_AN_CONFIRM_PASSWORD,
INPUT_ID_RM_AN_DESCRIPTION,
INPUT_ID_RM_AN_PASSWORD,
buildInputIdRMHost,
};
export default RunManifestInputGroup;

@ -0,0 +1,3 @@
import ManageManifestPanel from './ManageManifestPanel';
export default ManageManifestPanel;

@ -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 = <
],
);
useEffect(() => {
if (isFirstRender) {
buildInputFirstRenderFunction(INPUT_ID_UPS_TYPE)({
isValid: Boolean(inputUpsTypeIdValue),
});
}
}, [buildInputFirstRenderFunction, inputUpsTypeIdValue, isFirstRender]);
return content;
};

@ -22,7 +22,7 @@ const CommonUpsInputGroup = <
formUtils: {
buildFinishInputTestBatchFunction,
buildInputFirstRenderFunction,
msgSetters,
setMessage,
},
previous: { upsIPAddress: previousIpAddress, upsName: previousUpsName } = {},
}: CommonUpsInputGroupProps<M>): 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)}

@ -62,7 +62,8 @@ const MessageGroup = forwardRef<
(key: string, message?: Message) => {
let length = 0;
const { [key]: unused, ...rest } = messages;
setMessages((previous) => {
const { [key]: unused, ...rest } = previous;
const result: Messages = rest;
if (message) {
@ -71,11 +72,12 @@ const MessageGroup = forwardRef<
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;
setMessages((previous) => {
const result: Messages = {};
Object.keys(messages).forEach((key: string) => {
Object.keys(previous).forEach((key: string) => {
if (re.test(key)) {
assignMessage?.call(null, result, key);
} else {
result[key] = messages[key];
result[key] = previous[key];
length += 1;
}
});
onSet?.call(null, length);
return result;
});
setMessages(result);
onSet?.call(null, length);
},
[messages, onSet],
[onSet],
);
const messageElements = useMemo(() => {

@ -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<string, string> = {
bcn: 'Back-Channel Network',
ifn: 'Internet-Facing Network',
sn: 'Storage Network',
};
const NODE_NETWORK_TYPES: Record<string, string> = {
...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 } =

@ -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<OutlinedInputWithLabelOptionalPropsWithDefault> &
OutlinedInputWithLabelOptionalPropsWithoutDefault = {
baseInputProps: undefined,
fillRow: false,
formControlProps: {},
helpMessageBoxProps: {},
@ -65,6 +68,7 @@ const OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS: Required<OutlinedInputWithLabelOp
};
const OutlinedInputWithLabel: FC<OutlinedInputWithLabelProps> = ({
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<OutlinedInputWithLabelProps> = ({
}
fullWidth={formControlProps.fullWidth}
id={id}
inputProps={baseInputProps}
label={label}
name={name}
onBlur={onBlur}

@ -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<InnerPanelProps> = ({ sx, ...muiBoxRestProps }) => {
const InnerPanel: FC<InnerPanelProps> = ({
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<SxProps<Theme>>(
() => ({
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<InnerPanelProps> = ({ sx, ...muiBoxRestProps }) => {
[sx],
);
return <MUIBox {...muiBoxRestProps} sx={combinedSx} />;
return (
<MUIBox
mb={mb}
ml={marginLeft}
mt={marginTop}
{...muiBoxRestProps}
sx={combinedSx}
/>
);
};
export default InnerPanel;

@ -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<BoxProps> = ({ sx, ...innerPanelBodyRestProps }) => (
<Box
{...{
...innerPanelBodyRestProps,
sx: {
padding: '.3em .7em',
const InnerPanelBody: FC<BoxProps> = ({ sx, ...innerPanelBodyRestProps }) => {
const combinedSx = useMemo<SxProps<Theme>>(
() => ({
position: 'relative',
zIndex: 20,
...sx,
},
}}
/>
);
}),
[sx],
);
return (
<Box padding=".3em .7em" {...innerPanelBodyRestProps} sx={combinedSx} />
);
};
export default InnerPanelBody;

@ -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<SwitchWithLabelProps> = ({
baseInputProps,
checked: isChecked,
flexBoxProps: { sx: flexBoxSx, ...restFlexBoxProps } = {},
formControlLabelProps,
id: switchId,
label,
name: switchName,
onChange,
switchProps,
}) => {
const combinedFlexBoxSx = useMemo<SxProps<Theme>>(
() => ({
'& > :first-child': {
flexGrow: 1,
},
...flexBoxSx,
}),
[flexBoxSx],
);
const labelElement = useMemo(
const labelElement = useMemo<ReactElement>(
() =>
typeof label === 'string' ? (
<BodyText inheritColour color={`${GREY}9F`}>
<BodyText inheritColour color={`${GREY}AF`}>
{label}
</BodyText>
) : (
label
<>{label}</>
),
[label],
);
return (
<FlexBox row {...restFlexBoxProps} sx={combinedFlexBoxSx}>
{labelElement}
<Switch
<>
<SwitchFormControlLabel
componentsProps={{ typography: { flexGrow: 1 } }}
control={
<MUISwitch
checked={isChecked}
edge="end"
id={switchId}
name={switchName}
onChange={onChange}
{...switchProps}
/>
</FlexBox>
}
label={labelElement}
labelPlacement="start"
{...formControlLabelProps}
/>
<input
checked={isChecked}
hidden
id={switchId}
readOnly
{...baseInputProps}
/>
</>
);
};

@ -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<M> => {
const [formValidity, setFormValidity] = useState<FormValidity<M>>({});
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<FormValidity<M>>(key, value),
);
}, []);
const setValidityRe = useCallback((re: RegExp, value?: boolean) => {
setFormValidity(
buildRegExpObjectStateSetterCallback<FormValidity<M>>(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<U, I, M>(ids, messageGroupRef),
[ids, messageGroupRef],
);
return {
buildFinishInputTestBatchFunction,
buildInputFirstRenderFunction,
buildInputUnmountFunction,
formValidity,
isFormInvalid,
msgSetters,
setFormValidity,
setMessage,
setMessageRe,
setValidity,
setValidityRe,
unsetKey,
unsetKeyRe,
};
};

@ -7,8 +7,8 @@ const buildMessageSetter = <T extends MapToInputTestID>(
messageGroupRef: MutableRefObject<MessageGroupForwardedRefContent>,
container?: MapToMessageSetter<T>,
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;

@ -1,9 +1,74 @@
/**
* Checks whether specified `key` is unset in given object. Always returns
* `true` when overwrite is allowed.
*/
const checkUnset = <S extends BaseObject>(
obj: S,
key: keyof S,
{ isOverwrite = false }: { isOverwrite?: boolean } = {},
): boolean => !(key in obj) || isOverwrite;
const defaultObjectStatePropSetter = <S extends BaseObject>(
...[, result, key, value]: Parameters<ObjectStatePropSetter<S>>
): ReturnType<ObjectStatePropSetter<S>> => {
if (value !== undefined) {
result[key] = value;
}
};
const buildObjectStateSetterCallback =
<S extends Record<string, unknown>>(key: keyof S, value: S[keyof S]) =>
({ [key]: toReplace, ...restPrevious }: S): S =>
({
...restPrevious,
[key]: value,
} as S);
<S extends BaseObject>(
key: keyof S,
value?: S[keyof S],
{
guard = () => true,
set = defaultObjectStatePropSetter,
}: BuildObjectStateSetterCallbackOptions<S> = {},
): BuildObjectStateSetterCallbackReturnType<S> =>
(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 = <S extends BaseObject>(
key: keyof S,
value?: S[keyof S],
{
isOverwrite,
guard = (o, k) => checkUnset(o, k, { isOverwrite }),
set,
}: BuildObjectStateSetterCallbackOptions<S> = {},
): BuildObjectStateSetterCallbackReturnType<S> =>
buildObjectStateSetterCallback(key, value, { isOverwrite, guard, set });
export const buildRegExpObjectStateSetterCallback =
<S extends BaseObject>(
re: RegExp,
value?: S[keyof S],
{
set = defaultObjectStatePropSetter,
}: Pick<BuildObjectStateSetterCallbackOptions<S>, '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;

@ -1,6 +1,8 @@
const NETWORK_TYPES: Record<string, string> = {
bcn: 'Back-Channel Network',
ifn: 'Internet-Facing Network',
mn: 'Migration Network',
sn: 'Storage Network',
};
export default NETWORK_TYPES;

@ -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 },
],
});

@ -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 },
],
});

@ -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;

@ -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 },
],
});

@ -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 },
],
});

@ -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,

@ -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,

@ -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<string, string> = {
'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 }]) => (
<Tab
key={`prepare-network-${hostUUID}`}
key={`${TAB_ID_PREPARE_NETWORK}-${hostUUID}`}
label={shortHostName}
value={hostUUID}
/>
@ -144,6 +151,19 @@ const ManageUpsTabContent: FC = () => (
/>
);
const ManageManifestContent: FC = () => (
<Grid
columns={STEP_CONTENT_GRID_COLUMNS}
layout={{
'managemanifest-left-column': {},
'managemanifest-center-column': {
children: <ManageManifestPanel />,
...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}
>
<Tab label="Prepare host" value="prepare-host" />
<Tab label="Prepare network" value="prepare-network" />
<Tab label="Manage fence devices" value="manage-fence" />
<Tab label="Manage UPSes" value="manage-ups" />
<Tab label="Prepare host" value={TAB_ID_PREPARE_HOST} />
<Tab label="Prepare network" value={TAB_ID_PREPARE_NETWORK} />
<Tab label="Manage fence devices" value={TAB_ID_MANAGE_FENCE} />
<Tab label="Manage UPSes" value={TAB_ID_MANAGE_UPS} />
<Tab label="Manage manifests" value={TAB_ID_MANAGE_MANIFEST} />
</Tabs>
</Panel>
<TabContent changingTabId={pageTabId} tabId="prepare-host">
<TabContent changingTabId={pageTabId} tabId={TAB_ID_PREPARE_HOST}>
<PrepareHostTabContent />
</TabContent>
<TabContent changingTabId={pageTabId} tabId="prepare-network">
<TabContent changingTabId={pageTabId} tabId={TAB_ID_PREPARE_NETWORK}>
<PrepareNetworkTabContent />
</TabContent>
<TabContent changingTabId={pageTabId} tabId="manage-fence">
<TabContent changingTabId={pageTabId} tabId={TAB_ID_MANAGE_FENCE}>
<ManageFenceTabContent />
</TabContent>
<TabContent changingTabId={pageTabId} tabId="manage-ups">
<TabContent changingTabId={pageTabId} tabId={TAB_ID_MANAGE_UPS}>
<ManageUpsTabContent />
</TabContent>
<TabContent changingTabId={pageTabId} tabId={TAB_ID_MANAGE_MANIFEST}>
<ManageManifestContent />
</TabContent>
</>
);
};

@ -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<APIManifestDetail, 'name' | 'uuid'>;
type APIRunManifestRequestBody = {
description: string;
hosts: {
[hostId: string]: {
hostNumber: number;
hostType: string;
hostUuid: string;
};
};
password: string;
};

@ -0,0 +1,24 @@
type BaseObject<T = unknown> = Record<number | string | symbol, T>;
type ObjectStatePropGuard<S extends BaseObject> = (
previous: S,
key: keyof S,
value?: S[keyof S],
) => boolean;
type ObjectStatePropSetter<S extends BaseObject> = (
previous: S,
result: S,
key: keyof S,
value?: S[keyof S],
) => void;
type BuildObjectStateSetterCallbackOptions<S extends BaseObject> = {
guard?: ObjectStatePropGuard<S>;
isOverwrite?: boolean;
set?: ObjectStatePropSetter<S>;
};
type BuildObjectStateSetterCallbackReturnType<S extends BaseObject> = (
previous: S,
) => S;

@ -1,14 +1,19 @@
type DivFormEventHandler = import('react').FormEventHandler<HTMLDivElement>;
type DivFormEventHandlerParameters = Parameters<DivFormEventHandler>;
type ConfirmDialogOptionalProps = {
actionCancelText?: string;
closeOnProceed?: boolean;
contentContainerProps?: import('../components/FlexBox').FlexBoxProps;
dialogProps?: Partial<import('@mui/material').DialogProps>;
disableProceed?: boolean;
formContent?: boolean;
loading?: boolean;
loadingAction?: boolean;
onActionAppend?: ContainedButtonProps['onClick'];
onProceedAppend?: ContainedButtonProps['onClick'];
onCancelAppend?: ContainedButtonProps['onClick'];
onSubmitAppend?: import('react').FormEventHandler<HTMLDivElement>;
onSubmitAppend?: DivFormEventHandler;
openInitially?: boolean;
preActionArea?: import('react').ReactNode;
proceedButtonProps?: ContainedButtonProps;

@ -14,14 +14,31 @@ type InputFirstRenderFunctionBuilder<M extends MapToInputTestID> = (
key: keyof M,
) => InputFirstRenderFunction;
type InputUnmountFunction = () => void;
type InputUnmountFunctionBuilder<M extends MapToInputTestID> = (
key: keyof M,
) => InputUnmountFunction;
type FormUtils<M extends MapToInputTestID> = {
buildFinishInputTestBatchFunction: InputTestBatchFinishCallbackBuilder<M>;
buildInputFirstRenderFunction: InputFirstRenderFunctionBuilder<M>;
buildInputUnmountFunction: InputUnmountFunctionBuilder<M>;
formValidity: FormValidity<M>;
isFormInvalid: boolean;
msgSetters: MapToMessageSetter<M>;
setFormValidity: import('react').Dispatch<
import('react').SetStateAction<FormValidity<M>>
>;
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;
};

@ -1,16 +1,29 @@
type CreatableComponent = Parameters<typeof import('react').createElement>[0];
type IconButtonPresetMapToStateIcon = 'edit' | 'visibility';
type IconButtonPresetMapToStateIconBundle =
| 'add'
| 'close'
| 'edit'
| 'play'
| 'visibility';
type IconButtonMapToStateIcon = Record<string, CreatableComponent>;
type IconButtonStateIconBundle = {
iconType: CreatableComponent;
iconProps?: import('@mui/material').SvgIconProps;
};
type IconButtonMapToStateIconBundle = Record<string, IconButtonStateIconBundle>;
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;
};

@ -1 +1,7 @@
type InnerPanelProps = import('@mui/material').BoxProps;
type InnerPanelOptionalProps = {
headerMarginOffset?: number | string;
mv?: number | string;
};
type InnerPanelProps = InnerPanelOptionalProps &
import('@mui/material').BoxProps;

@ -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<string, ManifestFormInputHandler>;
/** ---------- Component types ---------- */
type AnIdInputGroupOptionalProps = {
previous?: Partial<ManifestAnId>;
};
type AnIdInputGroupProps<M extends MapToInputTestID> =
AnIdInputGroupOptionalProps & {
formUtils: FormUtils<M>;
};
type AnNetworkEventHandlerPreviousArgs = {
networkId: string;
} & Pick<ManifestNetwork, 'networkType'>;
type AnNetworkCloseEventHandler = (
args: AnNetworkEventHandlerPreviousArgs,
...handlerArgs: Parameters<IconButtonMouseEventHandler>
) => ReturnType<IconButtonMouseEventHandler>;
type AnNetworkTypeChangeEventHandler = (
args: AnNetworkEventHandlerPreviousArgs,
...handlerArgs: Parameters<SelectChangeEventHandler>
) => ReturnType<SelectChangeEventHandler>;
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<M extends MapToInputTestID> =
AnNetworkInputGroupOptionalProps & {
formUtils: FormUtils<M>;
networkId: string;
networkNumber: number;
networkType: string;
networkTypeOptions: SelectItem[];
};
type AnHostInputGroupOptionalProps = {
hostLabel?: string;
previous?: Pick<ManifestHost, 'fences' | 'ipmiIp' | 'networks' | 'upses'>;
};
type AnHostInputGroupProps<M extends MapToInputTestID> =
AnHostInputGroupOptionalProps & {
formUtils: FormUtils<M>;
hostId: string;
hostNumber: number;
hostType: string;
};
type AnNetworkConfigInputGroupOptionalProps = {
previous?: Partial<ManifestNetworkConfig>;
};
type AnNetworkConfigInputGroupProps<M extends MapToInputTestID> =
AnNetworkConfigInputGroupOptionalProps & {
formUtils: FormUtils<M>;
networkListEntries: Array<[string, ManifestNetwork]>;
setNetworkList: import('react').Dispatch<
import('react').SetStateAction<ManifestNetworkList>
>;
};
type AnHostConfigInputGroupOptionalProps = {
knownFences?: APIManifestTemplateFenceList;
knownUpses?: APIManifestTemplateUpsList;
previous?: Partial<ManifestHostConfig>;
};
type AnHostConfigInputGroupProps<M extends MapToInputTestID> =
AnHostConfigInputGroupOptionalProps & {
formUtils: FormUtils<M>;
networkListEntries: Array<[string, ManifestNetwork]>;
};
type AddManifestInputGroupOptionalProps = Pick<
AnHostConfigInputGroupOptionalProps,
'knownFences' | 'knownUpses'
> & {
previous?: Partial<ManifestAnId> & {
hostConfig?: Partial<ManifestHostConfig>;
networkConfig?: Partial<ManifestNetworkConfig>;
};
};
type AddManifestInputGroupProps<M extends MapToInputTestID> =
AddManifestInputGroupOptionalProps & {
formUtils: FormUtils<M>;
};
type EditManifestInputGroupProps<M extends MapToInputTestID> =
AddManifestInputGroupProps<M>;
type RunManifestInputGroupOptionalProps = {
knownHosts?: APIHostOverviewList;
};
type RunManifestInputGroupProps<M extends MapToInputTestID> =
RunManifestInputGroupOptionalProps & AddManifestInputGroupProps<M>;

@ -1,5 +1,5 @@
type MapToMessageSetter<T extends MapToInputTestID> = {
[MessageSetterID in keyof T]: MessageSetterFunction;
[MessageSetterID in keyof T]: MessageSetter;
};
type InputIds<T> = ReadonlyArray<T> | MapToInputTestID;

@ -1,3 +1,3 @@
type MessageSetterFunction = (
type MessageSetter = (
message?: import('../components/MessageBox').Message,
) => void;

@ -1,3 +1,8 @@
type SelectChangeEventHandler = Exclude<
import('@mui/material').SelectProps['onChange'],
undefined
>;
type SelectOptionalProps = {
onClearIndicatorClick?: import('@mui/material').IconButtonProps['onClick'];
};

@ -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;
};

Loading…
Cancel
Save