diff --git a/backend/WebUI/api_webui.go b/backend/WebUI/api_webui.go index 9f8f7c16..b2f00ce4 100644 --- a/backend/WebUI/api_webui.go +++ b/backend/WebUI/api_webui.go @@ -40,6 +40,7 @@ const ( userDataColl = "userData" tenantDataColl = "tenantData" identityDataColl = "subscriptionData.identityData" + profileDataColl = "profileData" // store profile data ) var jwtKey = "" // for generating JWT @@ -1952,3 +1953,196 @@ func OptionsSubscribers(c *gin.Context) { c.JSON(http.StatusNoContent, gin.H{}) } + +// Delete profile by profileName +func DeleteProfile(c *gin.Context) { + setCorsHeader(c) + logger.ProcLog.Infoln("Delete One Profile Data") + + profileName := c.Param("profileName") + pf, err := mongoapi.RestfulAPIGetOne(profileDataColl, bson.M{"profileName": profileName}) + if err != nil { + logger.ProcLog.Errorf("DeleteProfile err: %+v", err) + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + if len(pf) == 0 { + c.JSON(http.StatusNotFound, gin.H{"cause": profileName + " does not exist"}) + return + } + + if err = dbProfileOperation(profileName, "delete", nil); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": profileName + " has already been deleted"}) +} + +// Get profile list +func GetProfiles(c *gin.Context) { + setCorsHeader(c) + logger.ProcLog.Infoln("Get All Profiles List") + + _, err := GetTenantId(c) + if err != nil { + logger.ProcLog.Errorln(err.Error()) + c.JSON(http.StatusBadRequest, gin.H{"cause": "Illegal Token"}) + return + } + + pfs := make([]string, 0) + profileList, err := mongoapi.RestfulAPIGetMany(profileDataColl, bson.M{}) + if err != nil { + logger.ProcLog.Errorf("GetProfiles err: %+v", err) + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + for _, profile := range profileList { + profileName := profile["profileName"] + + pfs = append(pfs, profileName.(string)) + } + c.JSON(http.StatusOK, pfs) +} + +// Get profile by profileName +func GetProfile(c *gin.Context) { + setCorsHeader(c) + logger.ProcLog.Infoln("Get One Profile Data") + + profileName := c.Param("profileName") + + profile, err := mongoapi.RestfulAPIGetOne(profileDataColl, bson.M{"profileName": profileName}) + if err != nil { + logger.ProcLog.Errorf("GetProfile err: %+v", err) + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + var pf Profile + err = json.Unmarshal(mapToByte(profile), &pf) + if err != nil { + logger.ProcLog.Errorf("JSON Unmarshal err: %+v", err) + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + c.JSON(http.StatusOK, pf) +} + +// Post profile +func PostProfile(c *gin.Context) { + setCorsHeader(c) + logger.ProcLog.Infoln("Post One Profile Data") + + tokenStr := c.GetHeader("Token") + _, err := ParseJWT(tokenStr) + if err != nil { + logger.ProcLog.Errorln(err.Error()) + c.JSON(http.StatusBadRequest, gin.H{"cause": "Illegal Token"}) + return + } + + var profile Profile + if err = c.ShouldBindJSON(&profile); err != nil { + logger.ProcLog.Errorf("PostProfile err: %+v", err) + c.JSON(http.StatusBadRequest, gin.H{"cause": "JSON format incorrect"}) + return + } + + tenantData, err := mongoapi.RestfulAPIGetOne(tenantDataColl, bson.M{"tenantName": "admin"}) + if err != nil { + logger.ProcLog.Errorf("GetProfile err: %+v", err) + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + profile.TenantId = tenantData["tenantId"].(string) + + pf, err := mongoapi.RestfulAPIGetOne(profileDataColl, bson.M{"profileName": profile.ProfileName}) + if err != nil { + logger.ProcLog.Errorf("GetProfile err: %+v", err) + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + if len(pf) != 0 { + c.JSON(http.StatusConflict, gin.H{"cause": profile.ProfileName + " already exists"}) + return + } + + logger.ProcLog.Infof("PostProfile: %+v", profile.ProfileName) + if err = dbProfileOperation(profile.ProfileName, "post", &profile); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + c.JSON(http.StatusOK, profile) +} + +// Put profile by profileName +func PutProfile(c *gin.Context) { + setCorsHeader(c) + logger.ProcLog.Infoln("Put One Profile Data") + + profileName := c.Param("profileName") + + var profile Profile + if err := c.ShouldBindJSON(&profile); err != nil { + logger.ProcLog.Errorf("PutProfile err: %+v", err) + c.JSON(http.StatusBadRequest, gin.H{"cause": "JSON format incorrect"}) + return + } + + pf, err := mongoapi.RestfulAPIGetOne(profileDataColl, bson.M{"profileName": profile.ProfileName}) + if err != nil { + logger.ProcLog.Errorf("PutProfile err: %+v", err) + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + if len(pf) == 0 { + c.JSON(http.StatusNotFound, gin.H{"cause": profileName + " does not exist"}) + return + } + + tenantData, err := mongoapi.RestfulAPIGetOne(tenantDataColl, bson.M{"tenantName": "admin"}) + if err != nil { + logger.ProcLog.Errorf("GetProfile err: %+v", err) + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + profile.TenantId = tenantData["tenantId"].(string) + + logger.ProcLog.Infof("PutProfile: %+v", profile.ProfileName) + if err = dbProfileOperation(profileName, "put", &profile); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"cause": err.Error()}) + return + } + c.JSON(http.StatusOK, profile) +} + +func dbProfileOperation(profileName string, method string, profile *Profile) (err error) { + err = nil + filter := bson.M{"profileName": profileName} + + // Replace all data with new one + if method == "put" { + if err = mongoapi.RestfulAPIDeleteOne(profileDataColl, filter); err != nil { + logger.ProcLog.Errorf("PutSubscriberByID err: %+v", err) + } + } else if method == "delete" { + if err = mongoapi.RestfulAPIDeleteOne(profileDataColl, filter); err != nil { + logger.ProcLog.Errorf("DeleteSubscriberByID err: %+v", err) + } + } + + // Deal with error & early return + if err != nil { + return err + } + + // Insert data + if method == "post" || method == "put" { + profileBsonM := toBsonM(profile) + if _, err = mongoapi.RestfulAPIPost(profileDataColl, filter, profileBsonM); err != nil { + logger.ProcLog.Errorf("PutSubscriberByID err: %+v", err) + } + } + + return err +} diff --git a/backend/WebUI/model_profile_data.go b/backend/WebUI/model_profile_data.go new file mode 100644 index 00000000..54043d77 --- /dev/null +++ b/backend/WebUI/model_profile_data.go @@ -0,0 +1,16 @@ +package WebUI + +import "github.com/free5gc/openapi/models" + +type Profile struct { + ProfileName string `json:"profileName"` + TenantId string `json:"tenantId"` + AccessAndMobilitySubscriptionData models.AccessAndMobilitySubscriptionData `json:"AccessAndMobilitySubscriptionData"` + SessionManagementSubscriptionData []models.SessionManagementSubscriptionData `json:"SessionManagementSubscriptionData"` + SmfSelectionSubscriptionData models.SmfSelectionSubscriptionData `json:"SmfSelectionSubscriptionData"` + AmPolicyData models.AmPolicyData `json:"AmPolicyData"` + SmPolicyData models.SmPolicyData `json:"SmPolicyData"` + FlowRules []FlowRule `json:"FlowRules"` + QosFlows []QosFlow `json:"QosFlows"` + ChargingDatas []ChargingData +} diff --git a/backend/WebUI/routers.go b/backend/WebUI/routers.go index d513b10c..bf3c25da 100644 --- a/backend/WebUI/routers.go +++ b/backend/WebUI/routers.go @@ -250,4 +250,39 @@ var routes = Routes{ "/verify-staticip", VerifyStaticIP, }, + + { + "Delete Profile", + http.MethodDelete, + "/profile/:profileName", + DeleteProfile, + }, + + { + "Get Profile List", + http.MethodGet, + "/profile", + GetProfiles, + }, + + { + "Get Profile", + http.MethodGet, + "/profile/:profileName", + GetProfile, + }, + + { + "Post Profile", + http.MethodPost, + "/profile", + PostProfile, + }, + + { + "Put Profile", + http.MethodPut, + "/profile/:profileName", + PutProfile, + }, } diff --git a/backend/webui_service/middleware.go b/backend/webui_service/middleware.go index 864569fd..343018c4 100644 --- a/backend/webui_service/middleware.go +++ b/backend/webui_service/middleware.go @@ -30,6 +30,7 @@ func verifyDestPath(requestedURI string) string { "status", "analysis", "subscriber", + "profile", "tenant", "charging", "login", diff --git a/free5gc-Webconsole.postman_collection.json b/free5gc-Webconsole.postman_collection.json index 82febe9a..31176b71 100644 --- a/free5gc-Webconsole.postman_collection.json +++ b/free5gc-Webconsole.postman_collection.json @@ -1,9 +1,9 @@ { "info": { - "_postman_id": "227e7242-725b-4caa-b1bd-4eec7dc4f424", + "_postman_id": "26b086b1-ac04-4a3d-b3ff-3154cc0acb98", "name": "free5gc-Webconsole", "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json", - "_exporter_id": "27798737" + "_exporter_id": "38955132" }, "item": [ { @@ -1570,6 +1570,99 @@ "body": "[\n {\n \"chargingMethod\": \"Offline\",\n \"dnn\": \"\",\n \"filter\": \"\",\n \"quota\": \"0\",\n \"ratingGroup\": 5,\n \"servingPlmnId\": \"20893\",\n \"snssai\": \"01010203\",\n \"ueId\": \"imsi-208930000000001\",\n \"unitCost\": \"1\"\n },\n {\n \"chargingMethod\": \"Offline\",\n \"dnn\": \"internet\",\n \"filter\": \"9.9.9.9/32\",\n \"qosRef\": 1,\n \"quota\": \"0\",\n \"ratingGroup\": 6,\n \"servingPlmnId\": \"20893\",\n \"snssai\": \"01010203\",\n \"ueId\": \"imsi-208930000000001\",\n \"unitCost\": \"1\"\n },\n {\n \"chargingMethod\": \"Offline\",\n \"dnn\": \"\",\n \"filter\": \"\",\n \"quota\": \"0\",\n \"ratingGroup\": 1,\n \"servingPlmnId\": \"20893\",\n \"snssai\": \"01010203\",\n \"ueId\": \"imsi-208930000000002\",\n \"unitCost\": \"1\"\n },\n {\n \"chargingMethod\": \"Offline\",\n \"dnn\": \"internet\",\n \"filter\": \"1.1.1.1/32\",\n \"qosRef\": 1,\n \"quota\": \"0\",\n \"ratingGroup\": 2,\n \"servingPlmnId\": \"20893\",\n \"snssai\": \"01010203\",\n \"ueId\": \"imsi-208930000000002\",\n \"unitCost\": \"1\"\n }\n]" } ] + }, + { + "name": "Delete Profile", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": "http://{{WEB_URL}}/api/profile/{{PROFILE_NAME}}" + }, + "response": [] + }, + { + "name": "Add a Profile", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"profileName\": \"test\",\n \"AccessAndMobilitySubscriptionData\": {\n \"gpsis\": [\n \"msisdn-0900000000\"\n ],\n \"nssai\": {\n \"defaultSingleNssais\": [\n {\n \"sst\": 1,\n \"sd\": \"010203\",\n \"isDefault\": true\n },\n {\n \"sst\": 1,\n \"sd\": \"112233\",\n \"isDefault\": true\n }\n ],\n \"singleNssais\": []\n },\n \"subscribedUeAmbr\": {\n \"downlink\": \"2 Gbps\",\n \"uplink\": \"1 Gbps\"\n }\n },\n \"SessionManagementSubscriptionData\": [\n {\n \"singleNssai\": {\n \"sst\": 1,\n \"sd\": \"010203\"\n },\n \"dnnConfigurations\": {\n \"internet\": {\n \"sscModes\": {\n \"defaultSscMode\": \"SSC_MODE_1\",\n \"allowedSscModes\": [\n \"SSC_MODE_2\",\n \"SSC_MODE_3\"\n ]\n },\n \"pduSessionTypes\": {\n \"defaultSessionType\": \"IPV4\",\n \"allowedSessionTypes\": [\n \"IPV4\"\n ]\n },\n \"sessionAmbr\": {\n \"uplink\": \"200 Mbps\",\n \"downlink\": \"100 Mbps\"\n },\n \"5gQosProfile\": {\n \"5qi\": 9,\n \"arp\": {\n \"priorityLevel\": 8\n },\n \"priorityLevel\": 8\n }\n },\n \"internet2\": {\n \"sscModes\": {\n \"defaultSscMode\": \"SSC_MODE_1\",\n \"allowedSscModes\": [\n \"SSC_MODE_2\",\n \"SSC_MODE_3\"\n ]\n },\n \"pduSessionTypes\": {\n \"defaultSessionType\": \"IPV4\",\n \"allowedSessionTypes\": [\n \"IPV4\"\n ]\n },\n \"sessionAmbr\": {\n \"uplink\": \"200 Mbps\",\n \"downlink\": \"100 Mbps\"\n },\n \"5gQosProfile\": {\n \"5qi\": 9,\n \"arp\": {\n \"priorityLevel\": 8\n },\n \"priorityLevel\": 8\n }\n }\n }\n },\n {\n \"singleNssai\": {\n \"sst\": 1,\n \"sd\": \"112233\"\n },\n \"dnnConfigurations\": {\n \"internet\": {\n \"sscModes\": {\n \"defaultSscMode\": \"SSC_MODE_1\",\n \"allowedSscModes\": [\n \"SSC_MODE_2\",\n \"SSC_MODE_3\"\n ]\n },\n \"pduSessionTypes\": {\n \"defaultSessionType\": \"IPV4\",\n \"allowedSessionTypes\": [\n \"IPV4\"\n ]\n },\n \"sessionAmbr\": {\n \"uplink\": \"200 Mbps\",\n \"downlink\": \"100 Mbps\"\n },\n \"5gQosProfile\": {\n \"5qi\": 9,\n \"arp\": {\n \"priorityLevel\": 8\n },\n \"priorityLevel\": 8\n }\n },\n \"internet2\": {\n \"sscModes\": {\n \"defaultSscMode\": \"SSC_MODE_1\",\n \"allowedSscModes\": [\n \"SSC_MODE_2\",\n \"SSC_MODE_3\"\n ]\n },\n \"pduSessionTypes\": {\n \"defaultSessionType\": \"IPV4\",\n \"allowedSessionTypes\": [\n \"IPV4\"\n ]\n },\n \"sessionAmbr\": {\n \"uplink\": \"200 Mbps\",\n \"downlink\": \"100 Mbps\"\n },\n \"5gQosProfile\": {\n \"5qi\": 9,\n \"arp\": {\n \"priorityLevel\": 8\n },\n \"priorityLevel\": 8\n }\n }\n }\n }\n ],\n \"SmfSelectionSubscriptionData\": {\n \"subscribedSnssaiInfos\": {\n \"01010203\": {\n \"dnnInfos\": [\n {\n \"dnn\": \"internet\"\n },\n {\n \"dnn\": \"internet2\"\n }\n ]\n },\n \"01112233\": {\n \"dnnInfos\": [\n {\n \"dnn\": \"internet\"\n },\n {\n \"dnn\": \"internet2\"\n }\n ]\n }\n }\n },\n \"AmPolicyData\": {\n \"subscCats\": [\n \"free5gc\"\n ]\n },\n \"SmPolicyData\": {\n \"smPolicySnssaiData\": {\n \"01010203\": {\n \"snssai\": {\n \"sst\": 1,\n \"sd\": \"010203\"\n },\n \"smPolicyDnnData\": {\n \"internet\": {\n \"dnn\": \"internet\"\n },\n \"internet2\": {\n \"dnn\": \"internet2\"\n }\n }\n },\n \"01112233\": {\n \"snssai\": {\n \"sst\": 1,\n \"sd\": \"112233\"\n },\n \"smPolicyDnnData\": {\n \"internet\": {\n \"dnn\": \"internet\"\n },\n \"internet2\": {\n \"dnn\": \"internet2\"\n }\n }\n }\n }\n },\n \"FlowRules\": [\n {\n \"snssai\": \"01010203\",\n \"dnn\": \"internet\",\n \"filter\": \"1.1.1.1/32\",\n \"qfi\": 5\n }\n ],\n \"QosFlows\": [\n {\n \"qfi\": 5, \n \"5qi\": 5,\n \"snssai\": \"01010203\",\n \"dnn\": \"internet\",\n \"gbrUL\": \"\",\n \"gbrDL\": \"\",\n \"mbrUL\": \"200 Mbps\",\n \"mbrDL\": \"100 Mbps\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "http://{{WEB_URL}}/api/profile" + }, + "response": [] + }, + { + "name": "Modify a Profile", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"profileName\": \"test\",\n \"AccessAndMobilitySubscriptionData\": {\n \"gpsis\": [\n \"msisdn-0900000000\"\n ],\n \"nssai\": {\n \"defaultSingleNssais\": [\n {\n \"sst\": 1,\n \"sd\": \"010203\",\n \"isDefault\": true\n },\n {\n \"sst\": 1,\n \"sd\": \"112233\",\n \"isDefault\": true\n }\n ],\n \"singleNssais\": []\n },\n \"subscribedUeAmbr\": {\n \"downlink\": \"2 Gbps\",\n \"uplink\": \"1 Gbps\"\n }\n },\n \"SessionManagementSubscriptionData\": [\n {\n \"singleNssai\": {\n \"sst\": 1,\n \"sd\": \"010203\"\n },\n \"dnnConfigurations\": {\n \"internet\": {\n \"sscModes\": {\n \"defaultSscMode\": \"SSC_MODE_1\",\n \"allowedSscModes\": [\n \"SSC_MODE_2\",\n \"SSC_MODE_3\"\n ]\n },\n \"pduSessionTypes\": {\n \"defaultSessionType\": \"IPV4\",\n \"allowedSessionTypes\": [\n \"IPV4\"\n ]\n },\n \"sessionAmbr\": {\n \"uplink\": \"200 Mbps\",\n \"downlink\": \"100 Mbps\"\n },\n \"5gQosProfile\": {\n \"5qi\": 9,\n \"arp\": {\n \"priorityLevel\": 8\n },\n \"priorityLevel\": 8\n }\n },\n \"internet2\": {\n \"sscModes\": {\n \"defaultSscMode\": \"SSC_MODE_1\",\n \"allowedSscModes\": [\n \"SSC_MODE_2\",\n \"SSC_MODE_3\"\n ]\n },\n \"pduSessionTypes\": {\n \"defaultSessionType\": \"IPV4\",\n \"allowedSessionTypes\": [\n \"IPV4\"\n ]\n },\n \"sessionAmbr\": {\n \"uplink\": \"200 Mbps\",\n \"downlink\": \"100 Mbps\"\n },\n \"5gQosProfile\": {\n \"5qi\": 9,\n \"arp\": {\n \"priorityLevel\": 8\n },\n \"priorityLevel\": 8\n }\n }\n }\n },\n {\n \"singleNssai\": {\n \"sst\": 1,\n \"sd\": \"112233\"\n },\n \"dnnConfigurations\": {\n \"internet\": {\n \"sscModes\": {\n \"defaultSscMode\": \"SSC_MODE_1\",\n \"allowedSscModes\": [\n \"SSC_MODE_2\",\n \"SSC_MODE_3\"\n ]\n },\n \"pduSessionTypes\": {\n \"defaultSessionType\": \"IPV4\",\n \"allowedSessionTypes\": [\n \"IPV4\"\n ]\n },\n \"sessionAmbr\": {\n \"uplink\": \"200 Mbps\",\n \"downlink\": \"100 Mbps\"\n },\n \"5gQosProfile\": {\n \"5qi\": 9,\n \"arp\": {\n \"priorityLevel\": 8\n },\n \"priorityLevel\": 8\n }\n },\n \"internet2\": {\n \"sscModes\": {\n \"defaultSscMode\": \"SSC_MODE_1\",\n \"allowedSscModes\": [\n \"SSC_MODE_2\",\n \"SSC_MODE_3\"\n ]\n },\n \"pduSessionTypes\": {\n \"defaultSessionType\": \"IPV4\",\n \"allowedSessionTypes\": [\n \"IPV4\"\n ]\n },\n \"sessionAmbr\": {\n \"uplink\": \"200 Mbps\",\n \"downlink\": \"100 Mbps\"\n },\n \"5gQosProfile\": {\n \"5qi\": 9,\n \"arp\": {\n \"priorityLevel\": 8\n },\n \"priorityLevel\": 8\n }\n }\n }\n }\n ],\n \"SmfSelectionSubscriptionData\": {\n \"subscribedSnssaiInfos\": {\n \"01010203\": {\n \"dnnInfos\": [\n {\n \"dnn\": \"internet\"\n },\n {\n \"dnn\": \"internet2\"\n }\n ]\n },\n \"01112233\": {\n \"dnnInfos\": [\n {\n \"dnn\": \"internet\"\n },\n {\n \"dnn\": \"internet2\"\n }\n ]\n }\n }\n },\n \"AmPolicyData\": {\n \"subscCats\": [\n \"free5gc\"\n ]\n },\n \"SmPolicyData\": {\n \"smPolicySnssaiData\": {\n \"01010203\": {\n \"snssai\": {\n \"sst\": 1,\n \"sd\": \"010203\"\n },\n \"smPolicyDnnData\": {\n \"internet\": {\n \"dnn\": \"internet\"\n },\n \"internet2\": {\n \"dnn\": \"internet2\"\n }\n }\n },\n \"01112233\": {\n \"snssai\": {\n \"sst\": 1,\n \"sd\": \"112233\"\n },\n \"smPolicyDnnData\": {\n \"internet\": {\n \"dnn\": \"internet\"\n },\n \"internet2\": {\n \"dnn\": \"internet2\"\n }\n }\n }\n }\n },\n \"FlowRules\": [\n {\n \"snssai\": \"01010203\",\n \"dnn\": \"internet\",\n \"filter\": \"permit out 17 from 192.168.0.0/24 8000 to 60.60.0.0/24 \",\n \"qfi\": 5\n }, {\n \"snssai\": \"01010203\",\n \"dnn\": \"internet\",\n \"filter\": \"permit out 17 from 192.169.0.0/24 8000 to 60.60.0.0/24 \",\n \"qfi\": 1\n }\n ],\n \"QosFlows\": [\n {\n \"qfi\": 5, \n \"5qi\": 5,\n \"snssai\": \"01010203\",\n \"dnn\": \"internet\",\n \"gbrUL\": \"\",\n \"gbrDL\": \"\",\n \"mbrUL\": \"200 Mbps\",\n \"mbrDL\": \"100 Mbps\"\n }, {\n \"qfi\": 1, \n \"5qi\": 1,\n \"snssai\": \"01010203\",\n \"dnn\": \"internet\",\n \"gbrUL\": \"200 Mbps\",\n \"gbrDL\": \"100 Mbps\",\n \"mbrUL\": \"200 Mbps\",\n \"mbrDL\": \"100 Mbps\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "http://{{WEB_URL}}/api/profile/{{PROFILE_NAME}}" + }, + "response": [] + }, + { + "name": "Get all Profiles", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": "http://{WEB_URL}}/api/profile" + }, + "response": [] + }, + { + "name": "Get a Profile", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": "http://{{WEB_URL}}/api/profile/{{PROFILE_NAME}}" + }, + "response": [] } ], "event": [ @@ -1613,6 +1706,11 @@ { "key": "ChargingMethod", "value": "Offline" + }, + { + "key": "PROFILE_NAME", + "value": "test", + "type": "default" } ] } \ No newline at end of file diff --git a/frontend/openapi-generator-docker.sh b/frontend/openapi-generator-docker.sh new file mode 100755 index 00000000..c5bea708 --- /dev/null +++ b/frontend/openapi-generator-docker.sh @@ -0,0 +1,11 @@ +#! /bin/bash + +# prerequisites +# - docker + +# use Docker to run OpenAPI Generator +docker run --rm -v $PWD:/local openapitools/openapi-generator-cli generate -i /local/webconsole.yaml -g typescript-axios -o /local/src/api + +# replace Time with Date in the file +sed 's/: Time/: Date/g' /local/src/api/api.ts > /local/src/api/api.ts.mod +mv /local/src/api/api.ts.mod /local/src/api/api.ts # rename the replaced file to the original file \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 011f934d..de49da4f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,9 @@ import ChangePassword from "./pages/ChangePassword"; import ChargingTable from "./pages/Charging/ChargingTable"; import { ProtectedRoute } from "./ProtectedRoute"; import { LoginContext, User } from "./LoginContext"; +import ProfileList from "./pages/ProfileList"; +import ProfileCreate from "./pages/ProfileCreate"; +import ProfileRead from "./pages/ProfileRead"; export default function App() { const [user, setUser] = useState(() => { @@ -182,6 +185,38 @@ export default function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> diff --git a/frontend/src/ListItems.tsx b/frontend/src/ListItems.tsx index 2a91cfec..4df1b3cb 100644 --- a/frontend/src/ListItems.tsx +++ b/frontend/src/ListItems.tsx @@ -7,6 +7,7 @@ import PhoneAndroid from "@mui/icons-material/PhoneAndroid"; import FontDownload from "@mui/icons-material/FontDownload"; import SupervisorAccountOutlinedIcon from "@mui/icons-material/SupervisorAccountOutlined"; import AttachMoneyOutlinedIcon from "@mui/icons-material/AttachMoneyOutlined"; +import PersonIcon from "@mui/icons-material/Person"; import { Link } from "react-router-dom"; import { LoginContext } from "./LoginContext"; @@ -43,6 +44,14 @@ export const MainListItems = () => { + + + + + + + + diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index fb4e6d16..136dd396 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -699,6 +699,86 @@ export interface PermanentKey { */ 'encryptionAlgorithm': number; } +/** + * + * @export + * @interface Profile + */ +export interface Profile { + /** + * + * @type {string} + * @memberof Profile + */ + 'profileName': string; + /** + * + * @type {AccessAndMobilitySubscriptionData} + * @memberof Profile + */ + 'AccessAndMobilitySubscriptionData': AccessAndMobilitySubscriptionData; + /** + * + * @type {Array} + * @memberof Profile + */ + 'SessionManagementSubscriptionData': Array; + /** + * + * @type {SmfSelectionSubscriptionData} + * @memberof Profile + */ + 'SmfSelectionSubscriptionData': SmfSelectionSubscriptionData; + /** + * + * @type {AmPolicyData} + * @memberof Profile + */ + 'AmPolicyData': AmPolicyData; + /** + * + * @type {SmPolicyData} + * @memberof Profile + */ + 'SmPolicyData': SmPolicyData; + /** + * + * @type {Array} + * @memberof Profile + */ + 'FlowRules': Array; + /** + * + * @type {Array} + * @memberof Profile + */ + 'QosFlows': Array; + /** + * + * @type {Array} + * @memberof Profile + */ + 'ChargingDatas': Array; +} +/** + * + * @export + * @interface ProfileListIE + */ +export interface ProfileListIE { + /** + * + * @type {string} + * @memberof ProfileListIE + */ + 'profileName'?: string; + /** + * + * @type {string} + * @memberof ProfileListIE + */ + 'gpsi'?: string; +} /** * * @export @@ -1161,6 +1241,46 @@ export interface User { */ export const WebconsoleApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * Returns an array of profile. + * @summary Get all profiles + * @param {number} [limit] + * @param {number} [page] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiProfileGet: async (limit?: number, page?: number, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/profile`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (limit !== undefined) { + localVarQueryParameter['limit'] = limit; + } + + if (page !== undefined) { + localVarQueryParameter['page'] = page; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Returns an array of subscriber. * @summary Get all subscribers @@ -1211,6 +1331,20 @@ export const WebconsoleApiAxiosParamCreator = function (configuration?: Configur export const WebconsoleApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = WebconsoleApiAxiosParamCreator(configuration) return { + /** + * Returns an array of profile. + * @summary Get all profiles + * @param {number} [limit] + * @param {number} [page] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiProfileGet(limit?: number, page?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiProfileGet(limit, page, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['WebconsoleApi.apiProfileGet']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Returns an array of subscriber. * @summary Get all subscribers @@ -1235,6 +1369,17 @@ export const WebconsoleApiFp = function(configuration?: Configuration) { export const WebconsoleApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = WebconsoleApiFp(configuration) return { + /** + * Returns an array of profile. + * @summary Get all profiles + * @param {number} [limit] + * @param {number} [page] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiProfileGet(limit?: number, page?: number, options?: RawAxiosRequestConfig): AxiosPromise> { + return localVarFp.apiProfileGet(limit, page, options).then((request) => request(axios, basePath)); + }, /** * Returns an array of subscriber. * @summary Get all subscribers @@ -1243,7 +1388,7 @@ export const WebconsoleApiFactory = function (configuration?: Configuration, bas * @param {*} [options] Override http request option. * @throws {RequiredError} */ - apiSubscriberGet(limit?: number, page?: number, options?: any): AxiosPromise> { + apiSubscriberGet(limit?: number, page?: number, options?: RawAxiosRequestConfig): AxiosPromise> { return localVarFp.apiSubscriberGet(limit, page, options).then((request) => request(axios, basePath)); }, }; @@ -1256,6 +1401,19 @@ export const WebconsoleApiFactory = function (configuration?: Configuration, bas * @extends {BaseAPI} */ export class WebconsoleApi extends BaseAPI { + /** + * Returns an array of profile. + * @summary Get all profiles + * @param {number} [limit] + * @param {number} [page] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WebconsoleApi + */ + public apiProfileGet(limit?: number, page?: number, options?: RawAxiosRequestConfig) { + return WebconsoleApiFp(this.configuration).apiProfileGet(limit, page, options).then((request) => request(this.axios, this.basePath)); + } + /** * Returns an array of subscriber. * @summary Get all subscribers diff --git a/frontend/src/hooks/profile-form.tsx b/frontend/src/hooks/profile-form.tsx new file mode 100644 index 00000000..68ae1ff0 --- /dev/null +++ b/frontend/src/hooks/profile-form.tsx @@ -0,0 +1,43 @@ +import { ReactNode } from "react"; +import { Profile } from "../api"; +import { FormProvider, UseFormProps, useForm, useFormContext } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { defaultProfileDTO, profileDTOSchema, type ProfileDTO } from "../lib/dtos/profile"; + +const ProfileFormOptions = { + mode: "onBlur", + reValidateMode: "onChange", + resolver: zodResolver(profileDTOSchema), + defaultValues: defaultProfileDTO(), +} satisfies UseFormProps; + +export const ProfileFormProvider = ({ children }: { children: ReactNode }) => { + const method = useForm(ProfileFormOptions); + return {children}; +}; + +export const useProfileForm = () => { + const { + register, + handleSubmit, + watch, + getValues, + setValue, + setFocus, + reset, + control, + formState: { errors: validationErrors }, + } = useFormContext(); + + return { + register, + validationErrors, + handleSubmit, + watch, + getValues, + control, + setValue, + setFocus, + reset, + }; +}; diff --git a/frontend/src/lib/dtos/profile.test.ts b/frontend/src/lib/dtos/profile.test.ts new file mode 100644 index 00000000..f1fd2ed4 --- /dev/null +++ b/frontend/src/lib/dtos/profile.test.ts @@ -0,0 +1,285 @@ +import { test, describe } from "vitest"; +import { Profile } from "../../api"; +import { + FlowsMapperImpl, + ProfileMapperImpl, + ProfileDTO, + defaultProfileDTO, + defaultDnnConfig, + defaultUpSecurity, + upSecurityDTOSchema, + dnnConfigurationDTOSchema, +} from "./profile"; +import assert from "node:assert"; + +const defaultProfile = (): Profile => ({ + profileName: "profile-1", + AccessAndMobilitySubscriptionData: { + subscribedUeAmbr: { + uplink: "1 Gbps", + downlink: "2 Gbps", + }, + nssai: { + defaultSingleNssais: [ + { + sst: 1, + sd: "010203", + }, + ], + singleNssais: [ + { + sst: 1, + sd: "112233", + }, + ], + }, + }, + SessionManagementSubscriptionData: [ + { + singleNssai: { + sst: 1, + sd: "010203", + }, + dnnConfigurations: { + internet: { + pduSessionTypes: { + defaultSessionType: "IPV4", + allowedSessionTypes: ["IPV4"], + }, + sscModes: { + defaultSscMode: "SSC_MODE_1", + allowedSscModes: ["SSC_MODE_2", "SSC_MODE_3"], + }, + "5gQosProfile": { + "5qi": 9, + arp: { + priorityLevel: 8, + preemptCap: "", + preemptVuln: "", + }, + priorityLevel: 8, + }, + sessionAmbr: { + uplink: "1000 Mbps", + downlink: "1000 Mbps", + }, + staticIpAddress: [], + }, + }, + }, + { + singleNssai: { + sst: 1, + sd: "112233", + }, + dnnConfigurations: { + internet: { + pduSessionTypes: { + defaultSessionType: "IPV4", + allowedSessionTypes: ["IPV4"], + }, + sscModes: { + defaultSscMode: "SSC_MODE_1", + allowedSscModes: ["SSC_MODE_2", "SSC_MODE_3"], + }, + "5gQosProfile": { + "5qi": 8, + arp: { + priorityLevel: 8, + preemptCap: "", + preemptVuln: "", + }, + priorityLevel: 8, + }, + sessionAmbr: { + uplink: "1000 Mbps", + downlink: "1000 Mbps", + }, + staticIpAddress: [], + }, + }, + }, + ], + SmfSelectionSubscriptionData: { + subscribedSnssaiInfos: { + "01010203": { + dnnInfos: [ + { + dnn: "internet", + }, + ], + }, + "01112233": { + dnnInfos: [ + { + dnn: "internet", + }, + ], + }, + }, + }, + AmPolicyData: { + subscCats: ["free5gc"], + }, + SmPolicyData: { + smPolicySnssaiData: { + "01010203": { + snssai: { + sst: 1, + sd: "010203", + }, + smPolicyDnnData: { + internet: { + dnn: "internet", + }, + }, + }, + "01112233": { + snssai: { + sst: 1, + sd: "112233", + }, + smPolicyDnnData: { + internet: { + dnn: "internet", + }, + }, + }, + }, + }, + FlowRules: [ + { + filter: "1.1.1.1/32", + precedence: 128, + snssai: "01010203", + dnn: "internet", + qosRef: 1, + }, + { + filter: "1.1.1.1/32", + precedence: 127, + snssai: "01112233", + dnn: "internet", + qosRef: 2, + }, + ], + QosFlows: [ + { + snssai: "01010203", + dnn: "internet", + qosRef: 1, + "5qi": 8, + mbrUL: "208 Mbps", + mbrDL: "208 Mbps", + gbrUL: "108 Mbps", + gbrDL: "108 Mbps", + }, + { + snssai: "01112233", + dnn: "internet", + qosRef: 2, + "5qi": 7, + mbrUL: "407 Mbps", + mbrDL: "407 Mbps", + gbrUL: "207 Mbps", + gbrDL: "207 Mbps", + }, + ], + ChargingDatas: [ + { + snssai: "01010203", + dnn: "", + filter: "", + chargingMethod: "Offline", + quota: "100000", + unitCost: "1", + }, + { + snssai: "01010203", + dnn: "internet", + qosRef: 1, + filter: "1.1.1.1/32", + chargingMethod: "Offline", + quota: "100000", + unitCost: "1", + }, + { + snssai: "01112233", + dnn: "", + filter: "", + chargingMethod: "Online", + quota: "100000", + unitCost: "1", + }, + { + snssai: "01112233", + dnn: "internet", + qosRef: 2, + filter: "1.1.1.1/32", + chargingMethod: "Online", + quota: "5000", + unitCost: "1", + }, + ], +}); + +describe("ProfileDTO", () => { + describe("default profile", () => { + test("build", ({ expect }) => { + const profileBuilder = new ProfileMapperImpl(new FlowsMapperImpl()); + const profile = profileBuilder.mapFromDto(defaultProfileDTO()); + assert.deepEqual(JSON.parse(JSON.stringify(profile)), defaultProfile()); + }); + + test("parse", ({ expect }) => { + const profileBuilder = new ProfileMapperImpl(new FlowsMapperImpl()); + const profile = profileBuilder.mapFromProfile(defaultProfile()); + assert.deepEqual(JSON.parse(JSON.stringify(profile)), defaultProfileDTO()); + }); + }); + + describe("profile with up security", () => { + const profileWithUpSecurity: Profile = defaultProfile(); + profileWithUpSecurity.SessionManagementSubscriptionData[0].dnnConfigurations!["internet"].upSecurity = { + upIntegr: "1 Gbps", + upConfid: "2 Gbps", + }; + + const profileWithUpSecurityDTO: ProfileDTO = defaultProfileDTO(); + profileWithUpSecurityDTO.SnssaiConfigurations[0].dnnConfigurations["internet"].upSecurity = { + upIntegr: "1 Gbps", + upConfid: "2 Gbps", + }; + + test("build", ({ expect }) => { + const profileBuilder = new ProfileMapperImpl(new FlowsMapperImpl()); + const profile = profileBuilder.mapFromDto(profileWithUpSecurityDTO); + assert.deepEqual(JSON.parse(JSON.stringify(profile)), profileWithUpSecurity); + }); + + test("parse", () => { + const profileBuilder = new ProfileMapperImpl(new FlowsMapperImpl()); + const profile = profileBuilder.mapFromProfile(profileWithUpSecurity); + assert.deepEqual( + JSON.parse(JSON.stringify(profile)), + JSON.parse(JSON.stringify(profileWithUpSecurityDTO)), + ); + }); + }); + + describe("default values", () => { + test("defaultDnnConfig should match schema", ({ expect }) => { + const result = defaultDnnConfig(); + expect(() => { + dnnConfigurationDTOSchema.parse(result); + }).not.toThrow(); + }); + + test("defaultUpSecurity should match schema", ({ expect }) => { + const result = defaultUpSecurity(); + expect(() => { + upSecurityDTOSchema.parse(result); + }).not.toThrow(); + }); + }); +}); diff --git a/frontend/src/lib/dtos/profile.ts b/frontend/src/lib/dtos/profile.ts new file mode 100644 index 00000000..2817f96e --- /dev/null +++ b/frontend/src/lib/dtos/profile.ts @@ -0,0 +1,548 @@ +import { z } from "zod"; +import { + FlowRules, + QosFlows, + ChargingData, + SubscribedUeAmbr, + Nssai, + SessionManagementSubscriptionData, + DnnConfiguration, + UpSecurity, + Profile, +} from "../../api" +import { DEFAULT_5QI } from "../const"; + +interface AmbrDTO { + uplink: string; + downlink: string; +} + +export const ambrDTOSchema = z.object({ + uplink: z.string(), + downlink: z.string(), +}) + +interface ChargingDataDTO { + chargingMethod: "Online" | "Offline"; + quota: string; + unitCost: string; +} + +export const chargingDataDTOSchema = z.object({ + chargingMethod: z.enum(["Online", "Offline"]), + quota: z.string(), + unitCost: z.string(), +}) + +interface FlowRulesDTO { + filter: string; + precedence: number; + "5qi": number; + gbrUL: string; + gbrDL: string; + mbrUL: string; + mbrDL: string; + chargingData: ChargingDataDTO; +} + +export const flowRulesDTOSchema = z.object({ + filter: z.string(), + precedence: z.number(), + "5qi": z.number(), + gbrUL: z.string(), + gbrDL: z.string(), + mbrUL: z.string(), + mbrDL: z.string(), + chargingData: chargingDataDTOSchema, +}) + +interface UpSecurityDTO { + upIntegr: string; + upConfid: string; +} + +export const upSecurityDTOSchema = z.object({ + upIntegr: z.string(), + upConfid: z.string(), +}) + +interface DnnConfigurationDTO { + default5qi: number; + sessionAmbr: AmbrDTO; + enableStaticIpv4Address: boolean; + staticIpv4Address?: string; + flowRules: FlowRulesDTO[]; + upSecurity?: UpSecurityDTO; +} + +export const dnnConfigurationDTOSchema = z.object({ + default5qi: z.number(), + sessionAmbr: ambrDTOSchema, + enableStaticIpv4Address: z.boolean(), + staticIpv4Address: z.string().optional(), + flowRules: z.array(flowRulesDTOSchema), + upSecurity: upSecurityDTOSchema.optional(), +}) + +interface SnssaiConfigurationDTO { + sst: number; + sd: string; + isDefault: boolean; + chargingData: ChargingDataDTO; + dnnConfigurations: { [key: string]: DnnConfigurationDTO }; +} + +export const snssaiConfigurationDTOSchema = z.object({ + sst: z.number(), + sd: z.string(), + isDefault: z.boolean(), + chargingData: chargingDataDTOSchema, + dnnConfigurations: z.record(dnnConfigurationDTOSchema), +}) + +interface ProfileDTO { + profileName: string; + subscribedUeAmbr: AmbrDTO; + SnssaiConfigurations: SnssaiConfigurationDTO[]; +} + +export const profileDTOSchema = z.object({ + profileName: z.string(), + subscribedUeAmbr: ambrDTOSchema, + SnssaiConfigurations: z.array(snssaiConfigurationDTOSchema), +}) + +interface FlowsDTO { + flowRules: FlowRules[]; + qosFlows: QosFlows[]; + chargingDatas: ChargingData[]; +} + +interface FlowsMapper { + map(profile: ProfileDTO): FlowsDTO; +} + +class FlowsMapperImpl implements FlowsMapper { + refNumber: number = 1; + flowRules: FlowRules[] = []; + qosFlows: QosFlows[] = []; + chargingDatas: ChargingData[] = []; + + private buildDnns(profile: ProfileDTO): { + snssai: string; + dnn: string; + sliceCharingData: ChargingDataDTO; + flowRules: FlowRulesDTO[]; + }[] { + return profile.SnssaiConfigurations.reduce( + (acc, s) => { + const snssai = s.sst.toString().padStart(2, "0") + s.sd; + const dnns = Object.entries(s.dnnConfigurations).map(([dnn, dnnConfig]) => ({ + snssai: snssai, + dnn: dnn, + sliceCharingData: s.chargingData, + flowRules: dnnConfig.flowRules, + })); + dnns.forEach((dnn) => acc.push(dnn)); + return acc; + }, + [] as { + snssai: string; + dnn: string; + sliceCharingData: ChargingDataDTO; + flowRules: FlowRulesDTO[]; + }[], + ); + } + + map(profile: ProfileDTO): FlowsDTO { + const dnns = this.buildDnns(profile); + + dnns.forEach((dnn) => { + const snssai = dnn.snssai; + + this.chargingDatas.push({ + ...dnn.sliceCharingData, + snssai: snssai, + dnn: "", + filter: "", + }); + + dnn.flowRules.forEach((flow) => { + const qosRef = this.refNumber++; + + this.flowRules.push({ + filter: flow.filter, + precedence: flow.precedence, + snssai: snssai, + dnn: dnn.dnn, + qosRef, + }); + + this.qosFlows.push({ + snssai: snssai, + dnn: dnn.dnn, + qosRef, + "5qi": flow["5qi"], + mbrUL: flow.mbrUL, + mbrDL: flow.mbrDL, + gbrUL: flow.gbrUL, + gbrDL: flow.gbrDL, + }); + + this.chargingDatas.push({ + ...flow.chargingData, + snssai: snssai, + dnn: dnn.dnn, + filter: flow.filter, + qosRef, + }); + }); + }); + + return { + flowRules: this.flowRules, + qosFlows: this.qosFlows, + chargingDatas: this.chargingDatas, + }; + } +} + +interface ProfileMapper { + mapFromDto(profile: ProfileDTO): Profile; + mapFromProfile(profile: Profile): ProfileDTO; +} + +class ProfileMapperImpl implements ProfileMapper { + constructor(private readonly flowsBuilder: FlowsMapper) {} + + mapFromDto(profile: ProfileDTO): Profile { + const flows = this.flowsBuilder.map(profile); + + return { + profileName: profile.profileName, + AccessAndMobilitySubscriptionData: { + subscribedUeAmbr: this.buildSubscriberAmbr(profile.subscribedUeAmbr), + nssai: { + defaultSingleNssais: profile.SnssaiConfigurations.filter((s) => s.isDefault).map( + (s) => this.buildNssai(s)), + singleNssais: profile.SnssaiConfigurations.filter((s) => !s.isDefault).map( + (s) => this.buildNssai(s)), + } + }, + SessionManagementSubscriptionData: profile.SnssaiConfigurations.map((s) => + this.buildSessionManagementSubscriptionData(s), + ), + + SmfSelectionSubscriptionData: { + subscribedSnssaiInfos: Object.fromEntries( + profile.SnssaiConfigurations.map((s) => [ + s.sst.toString().padStart(2, "0") + s.sd, + { + dnnInfos: Object.keys(s.dnnConfigurations).map((dnn) => ({ + dnn: dnn, + })), + }, + ]), + ), + }, + AmPolicyData: { + subscCats: ["free5gc"], + }, + SmPolicyData: { + smPolicySnssaiData: Object.fromEntries( + profile.SnssaiConfigurations.map((s) => [ + s.sst.toString().padStart(2, "0") + s.sd, + { + snssai: this.buildNssai(s), + smPolicyDnnData: Object.fromEntries( + Object.keys(s.dnnConfigurations).map((dnn) => [dnn, { dnn: dnn }]), + ), + }, + ]), + ), + }, + FlowRules: flows.flowRules, + QosFlows: flows.qosFlows, + ChargingDatas: flows.chargingDatas, + } + } + + mapFromProfile(profile: Profile): ProfileDTO { + return { + profileName: profile.profileName, + subscribedUeAmbr: { + uplink: profile.AccessAndMobilitySubscriptionData.subscribedUeAmbr?.uplink ?? "", + downlink: profile.AccessAndMobilitySubscriptionData.subscribedUeAmbr?.downlink ?? "", + }, + SnssaiConfigurations: profile.SessionManagementSubscriptionData.map((s) => ({ + sst: s.singleNssai.sst, + sd: s.singleNssai.sd ?? "", + isDefault: this.snssaiIsDefault(s.singleNssai, profile), + chargingData: this.findSliceChargingData(s.singleNssai, profile), + dnnConfigurations: Object.fromEntries( + Object.entries(s.dnnConfigurations ?? {}).map(([key, value]) => [ + key, + { + default5qi: value["5gQosProfile"]?.["5qi"] ?? DEFAULT_5QI, + sessionAmbr: { + uplink: value.sessionAmbr?.uplink ?? "", + downlink: value.sessionAmbr?.downlink ?? "", + }, + enableStaticIpv4Address: value.staticIpAddress?.length !== 0, + flowRules: this.parseDnnFlowRules(s.singleNssai, key, profile), + upSecurity: value.upSecurity, + } satisfies DnnConfigurationDTO, + ]), + ), + })), + } + } + + private snssaiIsDefault(nssai: Nssai, profile: Profile): boolean { + return ( + profile.AccessAndMobilitySubscriptionData.nssai?.defaultSingleNssais?.some( + (n) => n.sst === nssai.sst && n.sd === nssai.sd, + ) ?? false + ); + } + + private findSliceChargingData(nssai: Nssai, profile: Profile): ChargingDataDTO { + const chargingData = profile.ChargingDatas.find((c) => { + if (c.dnn !== "" || c.filter !== "") { + return false; + } + + return c.snssai === nssai.sst.toString().padStart(2, "0") + nssai.sd; + }); + + return { + chargingMethod: chargingData?.chargingMethod === "Online" ? "Online" : "Offline", + quota: chargingData?.quota ?? "", + unitCost: chargingData?.unitCost ?? "", + }; + } + + private parseDnnFlowRules(snssai: Nssai, dnn: string, profile: Profile): FlowRulesDTO[] { + const qosFlows = profile.QosFlows.filter( + (f) => f.dnn === dnn && f.snssai === snssai.sst.toString().padStart(2, "0") + snssai.sd, + ); + + return qosFlows.map((f) => { + const flowRule = profile.FlowRules.find((r) => r.qosRef === f.qosRef); + const chargingData = profile.ChargingDatas.find((c) => c.qosRef === f.qosRef); + + return { + filter: flowRule?.filter ?? "", + precedence: flowRule?.precedence ?? 0, + "5qi": f["5qi"] ?? DEFAULT_5QI, + gbrUL: f.gbrUL ?? "", + gbrDL: f.gbrDL ?? "", + mbrUL: f.mbrUL ?? "", + mbrDL: f.mbrDL ?? "", + chargingData: { + chargingMethod: chargingData?.chargingMethod === "Online" ? "Online" : "Offline", + quota: chargingData?.quota ?? "", + unitCost: chargingData?.unitCost ?? "", + }, + } + }) + } + + buildSubscriberAmbr(data: AmbrDTO): SubscribedUeAmbr{ + return { + uplink: data.uplink, + downlink: data.downlink, + } + } + + buildNssai(data: SnssaiConfigurationDTO): Nssai { + return { + sst: data.sst, + sd: data.sd, + } + } + + buildSessionManagementSubscriptionData(data: SnssaiConfigurationDTO): SessionManagementSubscriptionData { + return { + singleNssai: this.buildNssai(data), + dnnConfigurations: Object.fromEntries( + Object.entries(data.dnnConfigurations).map(([key, value]) => [ + key, + this.buildDnnConfiguration(value), + ]), + ), + } + } + + buildDnnConfiguration(data: DnnConfigurationDTO): DnnConfiguration { + return { + pduSessionTypes: { + defaultSessionType: "IPV4", + allowedSessionTypes: ["IPV4"], + }, + sscModes: { + defaultSscMode: "SSC_MODE_1", + allowedSscModes: ["SSC_MODE_2", "SSC_MODE_3"], + }, + "5gQosProfile": { + "5qi": data.default5qi, + arp: { + priorityLevel: 8, + preemptCap: "", + preemptVuln: "", + }, + priorityLevel: 8, + }, + sessionAmbr: this.buildSubscriberAmbr(data.sessionAmbr), + staticIpAddress: data.enableStaticIpv4Address ? [{ ipv4Addr: data.staticIpv4Address }] : [], + upSecurity: this.buildUpSecurity(data.upSecurity), + } + } + + buildUpSecurity(data: UpSecurityDTO | undefined): UpSecurity | undefined { + if (!data) { + return undefined; + } + + return { + upIntegr: data.upIntegr, + upConfid: data.upConfid, + } + } +} + +export const defaultProfileDTO = (): ProfileDTO => ({ + profileName: "profile-1", + subscribedUeAmbr: { + uplink: "1 Gbps", + downlink: "2 Gbps", + }, + SnssaiConfigurations: [ + { + sst: 1, + sd: "010203", + isDefault: true, + chargingData: { + chargingMethod: "Offline", + quota: "100000", + unitCost: "1", + }, + dnnConfigurations: { + internet: { + enableStaticIpv4Address: false, + default5qi: 9, + sessionAmbr: { + uplink: "1000 Mbps", + downlink: "1000 Mbps", + }, + flowRules: [ + { + filter: "1.1.1.1/32", + precedence: 128, + "5qi": 8, + gbrUL: "108 Mbps", + gbrDL: "108 Mbps", + mbrUL: "208 Mbps", + mbrDL: "208 Mbps", + chargingData: { + chargingMethod: "Offline", + quota: "100000", + unitCost: "1", + }, + }, + ], + }, + }, + }, + { + sst: 1, + sd: "112233", + isDefault: false, + chargingData: { + chargingMethod: "Online", + quota: "100000", + unitCost: "1", + }, + dnnConfigurations: { + internet: { + enableStaticIpv4Address: false, + default5qi: 8, + sessionAmbr: { + uplink: "1000 Mbps", + downlink: "1000 Mbps", + }, + flowRules: [ + { + filter: "1.1.1.1/32", + precedence: 127, + "5qi": 7, + gbrUL: "207 Mbps", + gbrDL: "207 Mbps", + mbrUL: "407 Mbps", + mbrDL: "407 Mbps", + chargingData: { + chargingMethod: "Online", + quota: "5000", + unitCost: "1", + }, + }, + ], + }, + }, + }, + ], +}) + +export const defaultSnssaiConfiguration = (): SnssaiConfigurationDTO => ({ + sst: 1, + sd: "", + isDefault: false, + chargingData: { + chargingMethod: "Offline", + quota: "100000", + unitCost: "1", + }, + dnnConfigurations: { internet: defaultDnnConfig() }, +}); + +export const defaultDnnConfig = (): DnnConfigurationDTO => ({ + default5qi: DEFAULT_5QI, + sessionAmbr: { + uplink: "1000 Mbps", + downlink: "1000 Mbps", + }, + enableStaticIpv4Address: false, + staticIpv4Address: "", + flowRules: [defaultFlowRule()], + upSecurity: undefined, +}); + +export const defaultFlowRule = (): FlowRulesDTO => ({ + filter: "1.1.1.1/32", + precedence: 128, + "5qi": 9, + gbrUL: "208 Mbps", + gbrDL: "208 Mbps", + mbrUL: "108 Mbps", + mbrDL: "108 Mbps", + chargingData: { + chargingMethod: "Online", + quota: "10000", + unitCost: "1", + }, +}); + +export const defaultUpSecurity = (): UpSecurityDTO => ({ + upIntegr: "NOT_NEEDED", + upConfid: "NOT_NEEDED", +}); + +export { + type ProfileDTO, + type FlowsDTO, + type SnssaiConfigurationDTO, + type DnnConfigurationDTO, + ProfileMapperImpl, + FlowsMapperImpl, +} diff --git a/frontend/src/pages/Component/ChargingCfg.tsx b/frontend/src/pages/Component/ChargingCfg.tsx new file mode 100644 index 00000000..397443af --- /dev/null +++ b/frontend/src/pages/Component/ChargingCfg.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { + Box, + Grid, + Table, + TableBody, + TableCell, + TableRow, +} from "@mui/material"; +import { ChargingData } from "../../api/api"; + +const ChargingCfg = ({ + chargingData, +}: { + chargingData: ChargingData; +}) => { + const isOnlineCharging = chargingData.chargingMethod === "Online"; + + return ( + + + +

Charging Config

+
+
+ + + + Charging Method + {chargingData.chargingMethod} + + + {isOnlineCharging && ( + + + Quota + {chargingData.quota} + + + )} + + + Unit Cost + {chargingData.unitCost} + + +
+
+ ); +}; + +export default ChargingCfg; \ No newline at end of file diff --git a/frontend/src/pages/Component/FlowRule.tsx b/frontend/src/pages/Component/FlowRule.tsx new file mode 100644 index 00000000..3e302f9e --- /dev/null +++ b/frontend/src/pages/Component/FlowRule.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { + Box, + Card, + Table, + TableBody, + TableRow, + TableCell, +} from "@mui/material"; +import { QosFlows, Nssai } from "../../api/api"; + +const FlowRule = ({ + dnn, + flow, + data, + chargingConfig, + qosFlow, +}: { + dnn: string; + flow: any; + data: any; + chargingConfig: (dnn: string, snssai: Nssai, filter: string | undefined) => JSX.Element | undefined; + qosFlow: (sstSd: string, dnn: string, qosRef: number | undefined) => QosFlows | undefined; +}) => { + const flowKey = toHex(flow.sst) + flow.sd; + + return ( +
+ +

Flow Rules

+ + + + + IP Filter + {flow.filter} + + + Precedence + {flow.precedence} + + + 5QI + {qosFlow(flowKey, dnn, flow.qosRef)?.["5qi"]} + + + Uplink GBR + {qosFlow(flowKey, dnn, flow.qosRef!)?.gbrUL} + + + Downlink GBR + {qosFlow(flowKey, dnn, flow.qosRef!)?.gbrDL} + + + Uplink MBR + {qosFlow(flowKey, dnn, flow.qosRef!)?.mbrUL} + + + Downlink MBR + {qosFlow(flowKey, dnn, flow.qosRef!)?.mbrDL} + + + Charging Characteristics + {chargingConfig(dnn, flow.snssai, flow.filter)} + + +
+
+
+
+ ); +}; + +const toHex = (v: number | undefined): string => { + return ("00" + v?.toString(16).toUpperCase()).substr(-2); +}; + +export default FlowRule; diff --git a/frontend/src/pages/Component/UpSecurity.tsx b/frontend/src/pages/Component/UpSecurity.tsx new file mode 100644 index 00000000..f2fd42ae --- /dev/null +++ b/frontend/src/pages/Component/UpSecurity.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { + Box, + Card, + Table, + TableBody, + TableRow, + TableCell, +} from "@mui/material"; +import { DnnConfiguration } from "../../api/api"; + +const UpSecurity = ({ + dnn, +}: { + dnn: DnnConfiguration; +}) => { + if (dnn.upSecurity === undefined) { + return
; + } + + const security = dnn.upSecurity; + return ( +
+ +

UP Security

+ + + + + Integrity of UP Security + {security.upIntegr} + + + Confidentiality of UP Security + {security.upConfid} + + +
+
+
+
+ ); +}; + +export default UpSecurity; \ No newline at end of file diff --git a/frontend/src/pages/ProfileCreate/FormCharingConfig.tsx b/frontend/src/pages/ProfileCreate/FormCharingConfig.tsx new file mode 100644 index 00000000..a9ef19af --- /dev/null +++ b/frontend/src/pages/ProfileCreate/FormCharingConfig.tsx @@ -0,0 +1,201 @@ +import { + FormControl, + InputLabel, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableRow, + TextField, +} from "@mui/material"; +import { useProfileForm } from "../../hooks/profile-form"; +import { Controller } from "react-hook-form"; + +interface FormCharginConfigProps { + snssaiIndex: number; + dnn?: string; + filterIndex?: number; +} + +function FormSliceChargingConfig({ snssaiIndex }: FormCharginConfigProps) { + const { register, validationErrors, watch, control } = useProfileForm(); + + const isOnlineCharging = + watch(`SnssaiConfigurations.${snssaiIndex}.chargingData.chargingMethod`) === "Online"; + + return ( + + + + + + Charging Method + ( + + )} + /> + + + + + + + + + + + + +
+ ); +} + +function FormFlowChargingConfig({ snssaiIndex, dnn, filterIndex }: FormCharginConfigProps) { + const { register, validationErrors, watch, control } = useProfileForm(); + + if (dnn === undefined) { + throw new Error("dnn is undefined"); + } + if (filterIndex === undefined) { + throw new Error("filterIndex is undefined"); + } + + const isOnlineCharging = + watch( + `SnssaiConfigurations.${snssaiIndex}.dnnConfigurations.${dnn}.flowRules.${filterIndex}.chargingData.chargingMethod`, + ) === "Online"; + + return ( + + + + + + Charging Method + ( + + )} + /> + + + + + + + + + + +
+ ); +} + +export default function FormChargingConfig(props: FormCharginConfigProps) { + if (props.dnn === undefined) { + return ; + } + + return ; +} diff --git a/frontend/src/pages/ProfileCreate/FormFlowRule.tsx b/frontend/src/pages/ProfileCreate/FormFlowRule.tsx new file mode 100644 index 00000000..35ccc2aa --- /dev/null +++ b/frontend/src/pages/ProfileCreate/FormFlowRule.tsx @@ -0,0 +1,267 @@ +import { + Button, + Box, + Card, + Grid, + Table, + TableBody, + TableCell, + TableRow, + TextField, +} from "@mui/material"; +import type { Nssai } from "../../api"; +import { useProfileForm } from "../../hooks/profile-form"; +import { toHex } from "../../lib/utils"; +import FormChargingConfig from "./FormCharingConfig"; +import { useFieldArray } from "react-hook-form"; +import { defaultFlowRule } from "../../lib/dtos/profile"; + +interface FormFlowRuleProps { + snssaiIndex: number; + dnn: string; + snssai: Nssai; +} + +export default function FormFlowRule({ snssaiIndex, dnn, snssai }: FormFlowRuleProps) { + const { register, validationErrors, control } = useProfileForm(); + const { + fields: flowRules, + append: appendFlowRule, + remove: removeFlowRule, + } = useFieldArray({ + control, + name: `SnssaiConfigurations.${snssaiIndex}.dnnConfigurations.${dnn}.flowRules`, + }); + + const flowKey = toHex(snssai.sst) + snssai.sd; + const idPrefix = flowKey + "-" + dnn + "-"; + + return ( + <> + {flowRules.map((flow, index) => ( +
+ + + +

Flow Rules {index + 1}

+
+ + + + + +
+ + + + + + + + + + + + + + + {/* Keep layout aligned*/} + + + + + + + + + + + + + + + + +
+ +
+
+
+ ))} + + + + + + + + +
+ + ); +} diff --git a/frontend/src/pages/ProfileCreate/FormUpSecurity.tsx b/frontend/src/pages/ProfileCreate/FormUpSecurity.tsx new file mode 100644 index 00000000..7441bf25 --- /dev/null +++ b/frontend/src/pages/ProfileCreate/FormUpSecurity.tsx @@ -0,0 +1,161 @@ +import { + Button, + Box, + Card, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableRow, + SelectChangeEvent, +} from "@mui/material"; +import { Controller } from "react-hook-form"; +import { useProfileForm } from "../../hooks/profile-form"; +import { defaultUpSecurity } from "../../lib/dtos/profile"; + +interface FormUpSecurityProps { + sessionIndex: number; + dnnKey: string; +} + +function NoUpSecurity(props: FormUpSecurityProps) { + const { watch, setValue } = useProfileForm(); + + const dnnConfig = watch( + `SnssaiConfigurations.${props.sessionIndex}.dnnConfigurations.${props.dnnKey}`, + ); + + const onUpSecurity = () => { + dnnConfig.upSecurity = defaultUpSecurity(); + + setValue( + `SnssaiConfigurations.${props.sessionIndex}.dnnConfigurations.${props.dnnKey}`, + dnnConfig, + ); + }; + + return ( +
+ + + + + + + + +
+
+ ); +} + +export default function FormUpSecurity(props: FormUpSecurityProps) { + const { register, validationErrors, watch, control, getValues, setValue } = useProfileForm(); + + const dnnConfig = watch( + `SnssaiConfigurations.${props.sessionIndex}.dnnConfigurations.${props.dnnKey}`, + ); + + if (!(dnnConfig.upSecurity !== undefined)) { + return ; + } + + const onUpSecurityDelete = () => { + setValue(`SnssaiConfigurations.${props.sessionIndex}.dnnConfigurations.${props.dnnKey}`, { + ...dnnConfig, + upSecurity: undefined, + }); + }; + + return ( +
+ + + +

UP Security

+
+ + + + + +
+ + + + + + + + Integrity of UP Security + ( + + )} + /> + + + + + + + + + + Confidentiality of UP Security + ( + + )} + /> + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/ProfileCreate/ProfileFormBasic.tsx b/frontend/src/pages/ProfileCreate/ProfileFormBasic.tsx new file mode 100644 index 00000000..3aaf69af --- /dev/null +++ b/frontend/src/pages/ProfileCreate/ProfileFormBasic.tsx @@ -0,0 +1,28 @@ +import { useProfileForm } from "../../hooks/profile-form"; +import { Card, Table, TableBody, TableCell, TableRow, TextField } from "@mui/material"; + +export default function ProfileFormBasic() { + const { register, validationErrors } = useProfileForm(); + + return ( + + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/pages/ProfileCreate/ProfileFormSessions.tsx b/frontend/src/pages/ProfileCreate/ProfileFormSessions.tsx new file mode 100644 index 00000000..6d1dca21 --- /dev/null +++ b/frontend/src/pages/ProfileCreate/ProfileFormSessions.tsx @@ -0,0 +1,373 @@ +import { + Button, + Box, + Card, + Checkbox, + Grid, + Table, + TableBody, + TableCell, + TableRow, + TextField, + Switch, +} from "@mui/material"; +import { useProfileForm } from "../../hooks/profile-form"; +import { toHex } from "../../lib/utils"; +import FormChargingConfig from "./FormCharingConfig"; +import FormFlowRule from "./FormFlowRule"; +import FormUpSecurity from "./FormUpSecurity"; +import axios from "../../axios"; +import { Controller, useFieldArray } from "react-hook-form"; +import { defaultDnnConfig, defaultSnssaiConfiguration } from "../../lib/dtos/profile"; +import { useState } from "react"; + +interface VerifyScope { + supi: string; + sd: string; + sst: number; + dnn: string; + ipaddr: string; +} + +interface VerifyResult { + ipaddr: string; + valid: boolean; + cause: string; +} + +const handleVerifyStaticIp = (sd: string, sst: number, dnn: string, ipaddr: string) => { + const scope: VerifyScope = { + supi: "", + sd: sd, + sst: sst, + dnn: dnn, + ipaddr: ipaddr, + }; + axios.post("/api/verify-staticip", scope).then((res) => { + const result = res.data as VerifyResult; + console.log(result); + if (result["valid"] === true) { + alert("OK\n" + result.ipaddr); + } else { + alert("NO!\nCause: " + result["cause"]); + } + }); +}; + +export default function ProfileFormSessions() { + const { register, validationErrors, watch, control, setFocus } = useProfileForm(); + + const { + fields: snssaiConfigurations, + append: appendSnssaiConfiguration, + remove: removeSnssaiConfiguration, + update: updateSnssaiConfiguration, + } = useFieldArray({ + control, + name: "SnssaiConfigurations", + }); + + const [dnnName, setDnnName] = useState(Array(snssaiConfigurations.length).fill("")); + + const handleChangeDNN = ( + event: React.ChangeEvent, + index: number, + ): void => { + setDnnName((dnnName) => dnnName.map((name, i) => (index === i ? event.target.value : name))); + }; + + const onDnnAdd = (index: number) => { + const name = dnnName[index]; + if (name === undefined || name === "") { + return; + } + + const snssaiConfig = watch(`SnssaiConfigurations.${index}`); + updateSnssaiConfiguration(index, { + ...snssaiConfig, + dnnConfigurations: { + ...snssaiConfig.dnnConfigurations, + [name]: defaultDnnConfig(), + }, + }); + + setTimeout(() => { + /* IMPORTANT: setFocus after rerender */ + setFocus(`SnssaiConfigurations.${index}.dnnConfigurations.${name}.sessionAmbr.uplink`); + }); + + // restore input field + setDnnName((dnnName) => dnnName.map((name, i) => (index === i ? "" : name))); + }; + + const onDnnDelete = (index: number, dnn: string, slice: string) => { + const snssaiConfig = watch(`SnssaiConfigurations.${index}`); + const newDnnConfigurations = { ...snssaiConfig.dnnConfigurations }; + delete newDnnConfigurations[dnn]; + + updateSnssaiConfiguration(index, { + ...snssaiConfig, + dnnConfigurations: newDnnConfigurations, + }); + }; + + return ( + <> + {snssaiConfigurations?.map((row, index) => ( +
+ + +

S-NSSAI Configuragtion ({toHex(row.sst) + row.sd})

+
+ + + + + +
+ + + + + + + + + + + + + + + Default S-NSSAI + + } + /> + + + +
+ + + + {Object.keys(row.dnnConfigurations).map((dnn) => ( +
+ + + +

DNN Configurations

+
+ + + + + +
+ + + + + + {dnn} + + + + + + + + + + + + + + + + +
+ + + + + + + + + {/* + + */} + + + + + +
+ + + + +
+
+
+ ))} + + + + handleChangeDNN(ev, index)} + /> + + + + + + + + +
+
+ ))} + +
+ + + + + ); +} diff --git a/frontend/src/pages/ProfileCreate/ProfileFormUeAmbr.tsx b/frontend/src/pages/ProfileCreate/ProfileFormUeAmbr.tsx new file mode 100644 index 00000000..9464a0a9 --- /dev/null +++ b/frontend/src/pages/ProfileCreate/ProfileFormUeAmbr.tsx @@ -0,0 +1,43 @@ +import { Card, Table, TableBody, TableCell, TableRow, TextField } from "@mui/material"; +import { useProfileForm } from "../../hooks/profile-form"; + +export default function ProfileFormUeAmbr() { + const { register, validationErrors } = useProfileForm(); + + return ( + + + + + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/pages/ProfileCreate/index.tsx b/frontend/src/pages/ProfileCreate/index.tsx new file mode 100644 index 00000000..a34b215c --- /dev/null +++ b/frontend/src/pages/ProfileCreate/index.tsx @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import { useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import axios from "../../axios"; + +import Dashboard from "../../Dashboard"; +import { Button, Grid } from "@mui/material"; +import { ProfileFormProvider, useProfileForm } from "../../hooks/profile-form"; +import ProfileFormBasic from "./ProfileFormBasic"; +import ProfileFormUeAmbr from "./ProfileFormUeAmbr"; +import ProfileFormSessions from "./ProfileFormSessions"; +import { ProfileMapperImpl, FlowsMapperImpl } from "../../lib/dtos/profile"; +function FormHOC(Component: React.ComponentType) { + return function (props: any) { + return ( + + + + ); + }; +} + +export default FormHOC(ProfileCreate); + +function ProfileCreate() { + const { profileName } = useParams<{ profileName: string }>(); + + const isNewProfile = profileName === undefined; + const navigation = useNavigate(); + const [loading, setLoading] = useState(false); + + const { handleSubmit, getValues, reset } = useProfileForm(); + + if (!isNewProfile) { + useEffect(() => { + setLoading(true); + + axios + .get("/api/profile/" + profileName) + .then((res) => { + const profileMapper = new ProfileMapperImpl(new FlowsMapperImpl()); + const profile = profileMapper.mapFromProfile(res.data); + reset(profile); + }) + .finally(() => { + setLoading(false); + }); + }, [profileName]); + } + + if (loading) { + return
Loading...
; + } + + const onCreate = () => { + console.log("trace: onCreate"); + + const data = getValues(); + + if (data.SnssaiConfigurations.length === 0) { + alert("Please add at least one S-NSSAI"); + return; + } + + const profileMapper = new ProfileMapperImpl(new FlowsMapperImpl()); + const profile = profileMapper.mapFromDto(data); + + axios + .post("/api/profile", profile) + .then(() => { + navigation("/profile"); + }) + .catch((err) => { + if (err.response) { + const msg = "Status: " + err.response.status; + if (err.response.data.cause) { + alert(msg + ", cause: " + err.response.data.cause); + } else if (err.response.data) { + alert(msg + ", data:" + err.response.data); + } else { + alert(msg); + } + } else { + alert(err.message); + } + console.log(err); + return; + }); + }; + + const onUpdate = () => { + console.log("trace: onUpdate"); + + const data = getValues(); + const profileMapper = new ProfileMapperImpl(new FlowsMapperImpl()); + const profile = profileMapper.mapFromDto(data); + + axios + .put("/api/profile/" + profile.profileName, profile) + .then(() => { + navigation("/profile/" + profile.profileName); + }) + .catch((err) => { + if (err.response) { + const msg = "Status: " + err.response.status; + if (err.response.data.cause) { + alert(msg + ", cause: " + err.response.data.cause); + } else if (err.response.data) { + alert(msg + ", data:" + err.response.data); + } else { + alert(msg); + } + } + }); + }; + + const formSubmitFn = isNewProfile ? onCreate : onUpdate; + const formSubmitText = isNewProfile ? "CREATE" : "UPDATE"; + + return ( + {}}> +
{ + console.log("form error: ", err); + })} + > + + + +

Profile UE AMBR

+ + + + +
+ + + + +
+ ); +} + diff --git a/frontend/src/pages/ProfileList.tsx b/frontend/src/pages/ProfileList.tsx new file mode 100644 index 00000000..738eaed1 --- /dev/null +++ b/frontend/src/pages/ProfileList.tsx @@ -0,0 +1,214 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { config } from "../constants/config"; +import Dashboard from "../Dashboard"; +import axios from "../axios"; +import { + Alert, + Box, + Button, + Grid, + Snackbar, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TablePagination, + TableRow, + TextField, +} from "@mui/material"; +import { ReportProblemRounded } from "@mui/icons-material"; + +interface Props { + refresh: boolean; + setRefresh: (v: boolean) => void; +} + +function ProfileList(props: Props) { + const navigation = useNavigate(); + const [data, setData] = useState([]); + const [limit, setLimit] = useState(50); + const [page, setPage] = useState(0); + const [searchTerm, setSearchTerm] = useState(""); + const [isLoadError, setIsLoadError] = useState(false); + const [isDeleteError, setIsDeleteError] = useState(false); + + useEffect(() => { + axios + .get("/api/profile") + .then((res) => { + setData(res.data); + }) + .catch((e) => { + setIsLoadError(true); + }); + }, [props.refresh, limit, page]); + + if (isLoadError) { + return ( + + + Something went wrong + + ); + } + + const handlePageChange = ( + _event: React.MouseEvent | null, + newPage?: number, + ) => { + if (newPage !== null) { + setPage(newPage!); + } + }; + + const handleLimitChange = (event: React.ChangeEvent) => { + setLimit(Number(event.target.value)); + }; + + const count = () => { + return 0; + }; + + const pager = () => { + if (config.enablePagination) { + return ( + + ); + } else { + return
; + } + }; + + const onCreate = () => { + navigation("/profile/create"); + }; + + const onDelete = (profileName: string) => { + const result = window.confirm("Delete profile?"); + if (!result) { + return; + } + axios + .delete("/api/profile/" + profileName) + .then((res) => { + props.setRefresh(!props.refresh); + }) + .catch((err) => { + setIsDeleteError(true); + console.error(err.response.data.message); + }); + }; + + const handleModify = (profile: string) => { + navigation("/profile/" + profile); + }; + + const filteredData = data.filter((profile) => + profile.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + const handleSearch = (event: React.ChangeEvent) => { + setSearchTerm(event.target.value); + }; + + if (data.length === 0) { + return ( + <> +
+
+ No Profiles +
+
+ + + +
+ + ); + } + + return ( + <> +
+ + + + + Name + Delete + View + + + + {filteredData.map((row, index) => ( + + {row.toString()} + + + + + + + + ))} + +
+ {pager()} + + + + + setIsDeleteError(false)} + > + Failed to delete profile + + + ); +} + +function WithDashboard(Component: React.ComponentType) { + return function (props: any) { + const [refresh, setRefresh] = useState(false); + + return ( + setRefresh(!refresh)}> + setRefresh(v)} /> + + ); + }; +} + +export default WithDashboard(ProfileList); diff --git a/frontend/src/pages/ProfileRead.tsx b/frontend/src/pages/ProfileRead.tsx new file mode 100644 index 00000000..667a98f7 --- /dev/null +++ b/frontend/src/pages/ProfileRead.tsx @@ -0,0 +1,248 @@ +import React from "react"; +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; + +import axios from "../axios"; +import { Nssai, Profile, QosFlows, DnnConfiguration } from "../api/api"; + +import Dashboard from "../Dashboard"; +import { + Button, + Box, + Card, + Checkbox, + Grid, + Table, + TableBody, + TableCell, + TableRow, +} from "@mui/material"; +import FlowRule from "./Component/FlowRule"; +import ChargingCfg from "./Component/ChargingCfg"; +import UpSecurity from "./Component/UpSecurity"; + +export default function ProfileRead() { + const { profileName } = useParams<{ profileName: string }>(); + const navigation = useNavigate(); + + const [data, setData] = useState(null); + + function toHex(v: number | undefined): string { + return ("00" + v?.toString(16).toUpperCase()).substr(-2); + } + + useEffect(() => { + axios.get("/api/profile/" + profileName).then((res) => { + setData(res.data); + }); + }, [profileName]); + + const handleEdit = () => { + navigation("/profile/create/" + profileName); + }; + + const isDefaultNssai = (nssai: Nssai | undefined) => { + if (nssai === undefined || data == null) { + return false; + } else { + for ( + let i = 0; + i < data.AccessAndMobilitySubscriptionData.nssai!.defaultSingleNssais!.length; + i++ + ) { + const defaultNssai = data.AccessAndMobilitySubscriptionData.nssai!.defaultSingleNssais![i]; + if (defaultNssai.sd === nssai.sd && defaultNssai.sst === nssai.sst) { + return true; + } + } + return false; + } + }; + + const qosFlow = ( + sstSd: string, + dnn: string, + qosRef: number | undefined, + ): QosFlows | undefined => { + if (data != null) { + for (const qos of data.QosFlows) { + if (qos.snssai === sstSd && qos.dnn === dnn && qos.qosRef === qosRef) { + return qos; + } + } + } + return undefined; + }; + + const chargingConfig = (dnn: string, snssai: Nssai, filter: string | undefined) => { + const flowKey = toHex(snssai.sst) + snssai.sd; + for (const chargingData of data?.ChargingDatas ?? []) { + if ( + chargingData.snssai === flowKey && + chargingData.dnn === dnn && + chargingData.filter === filter + ) { + return ; + } + } + }; + + const flowRule = (dnn: string, snssai: Nssai) => { + const flowKey = toHex(snssai.sst) + snssai.sd; + if (data?.FlowRules === undefined) { + return
; + } + return data.FlowRules.filter((flow) => flow.dnn === dnn && flow.snssai === flowKey).map( + (flow) => ( + + ), + ); + }; + + const upSecurity = (dnn: DnnConfiguration | undefined) => { + if (dnn === undefined || dnn.upSecurity === undefined) { + return
; + } + return ; + }; + + return ( + {}}> + + + + + Profile Name + {data?.profileName} + + +
+
+ +

Subscribed UE AMBR

+ + + + + Uplink + + {data?.AccessAndMobilitySubscriptionData.subscribedUeAmbr?.uplink} + + + + + + Downlink + + {data?.AccessAndMobilitySubscriptionData.subscribedUeAmbr?.downlink} + + + +
+
+ + {/* S-NSSAI Configurations */} + {data?.SessionManagementSubscriptionData?.map((row, index) => ( +
+

S-NSSAI Configuration

+ + + + + SST + {row.singleNssai?.sst} + + + + + SD + {row.singleNssai?.sd} + + + + + Default S-NSSAI + + + + + +
+ {row.dnnConfigurations && + Object.keys(row.dnnConfigurations!).map((dnn) => ( +
+ +

DNN Configurations

+ + + + + Data Network Name + {dnn} + + + + + Uplink AMBR + + {row.dnnConfigurations![dnn].sessionAmbr?.uplink} /{" "} + {row.dnnConfigurations![dnn].sessionAmbr?.downlink} + + + + + + Downlink AMBR + + {row.dnnConfigurations![dnn].sessionAmbr?.downlink} + + + + + + Default 5QI + + {row.dnnConfigurations![dnn]["5gQosProfile"]?.["5qi"]} + + + + + + Static IPv4 Address + + {row.dnnConfigurations![dnn]["staticIpAddress"] == null + ? "Not Set" + : row.dnnConfigurations![dnn]["staticIpAddress"]?.length == 0 + ? "" + : row.dnnConfigurations![dnn]["staticIpAddress"]![0].ipv4Addr!} + + + +
+ {flowRule(dnn, row.singleNssai!)} + {upSecurity(row.dnnConfigurations![dnn])} + {chargingConfig("", row.singleNssai!, "")} +
+
+
+ ))} +
+
+ ))} + +
+ + + +
+ ); +} diff --git a/frontend/src/pages/SubscriberCreate/index.tsx b/frontend/src/pages/SubscriberCreate/index.tsx index fecae841..8f687e1d 100644 --- a/frontend/src/pages/SubscriberCreate/index.tsx +++ b/frontend/src/pages/SubscriberCreate/index.tsx @@ -5,13 +5,13 @@ import { useNavigate, useParams } from "react-router-dom"; import axios from "../../axios"; import Dashboard from "../../Dashboard"; -import { Button, Grid } from "@mui/material"; +import { Button, Grid, FormControl, InputLabel, Select, MenuItem } from "@mui/material"; import { SubscriberFormProvider, useSubscriptionForm } from "../../hooks/subscription-form"; import SubscriberFormBasic from "./SubscriberFormBasic"; import SubscriberFormUeAmbr from "./SubscriberFormUeAmbr"; import SubscriberFormSessions from "./SubscriberFormSessions"; -import { FlowsMapperImpl, SubscriptionMapperImpl } from "../../lib/dtos/subscription"; - +import { FlowsMapperImpl as SubscriptionFlowsMapperImpl, SubscriptionMapperImpl } from "../../lib/dtos/subscription"; +import { FlowsMapperImpl as ProfileFlowsMapperImpl, ProfileMapperImpl } from "../../lib/dtos/profile"; function FormHOC(Component: React.ComponentType) { return function (props: any) { return ( @@ -33,9 +33,20 @@ function SubscriberCreate() { const isNewSubscriber = id === undefined && plmn === undefined; const navigation = useNavigate(); const [loading, setLoading] = useState(false); - + const [profiles, setProfiles] = useState([]); + const [selectedProfile, setSelectedProfile] = useState(''); const { handleSubmit, getValues, reset } = useSubscriptionForm(); + useEffect(() => { + axios.get('/api/profile') + .then((res) => { + setProfiles(res.data); + }) + .catch((e) => { + console.log(e.message); + }); + }, []); + if (!isNewSubscriber) { useEffect(() => { setLoading(true); @@ -43,7 +54,7 @@ function SubscriberCreate() { axios .get("/api/subscriber/" + id + "/" + plmn) .then((res) => { - const subscriberMapper = new SubscriptionMapperImpl(new FlowsMapperImpl()); + const subscriberMapper = new SubscriptionMapperImpl(new SubscriptionFlowsMapperImpl()); const subscription = subscriberMapper.mapFromSubscription(res.data); reset(subscription); }) @@ -77,7 +88,7 @@ function SubscriberCreate() { return; } - const subscriberMapper = new SubscriptionMapperImpl(new FlowsMapperImpl()); + const subscriberMapper = new SubscriptionMapperImpl(new SubscriptionFlowsMapperImpl()); const subscription = subscriberMapper.mapFromDto(data); // Iterate subscriber data number. @@ -113,7 +124,7 @@ function SubscriberCreate() { console.log("trace: onUpdate"); const data = getValues(); - const subscriberMapper = new SubscriptionMapperImpl(new FlowsMapperImpl()); + const subscriberMapper = new SubscriptionMapperImpl(new SubscriptionFlowsMapperImpl()); const subscription = subscriberMapper.mapFromDto(data); axios @@ -140,6 +151,47 @@ function SubscriberCreate() { const formSubmitFn = isNewSubscriber ? onCreate : onUpdate; const formSubmitText = isNewSubscriber ? "CREATE" : "UPDATE"; + const handleProfileChange = (event: any) => { + const profileName = event.target.value; + setSelectedProfile(profileName); + + if (profileName) { + setLoading(true); + axios.get("/api/profile/" + profileName) + .then((res) => { + const profileMapper = new ProfileMapperImpl(new ProfileFlowsMapperImpl()); + const profile = profileMapper.mapFromProfile(res.data); + + const currentValues = getValues(); + const basicInfo = { + userNumber: currentValues.userNumber, + ueId: currentValues.ueId, + plmnID: currentValues.plmnID, + gpsi: currentValues.gpsi, + auth: { + authenticationManagementField: currentValues.auth?.authenticationManagementField, + authenticationMethod: currentValues.auth?.authenticationMethod, + operatorCodeType: currentValues.auth?.operatorCodeType, + operatorCode: currentValues.auth?.operatorCode, + sequenceNumber: currentValues.auth?.sequenceNumber, + permanentKey: currentValues.auth?.permanentKey, + } + }; + + reset({ + ...basicInfo, + ...profile + }); + }) + .catch((e) => { + console.log(e.message); + }) + .finally(() => { + setLoading(false); + }); + } + }; + return ( {}}>
+ {profiles.length > 0 && ( + + + Select Profile + + + + )} +

Subscribed UE AMBR

diff --git a/frontend/src/pages/SubscriberRead.tsx b/frontend/src/pages/SubscriberRead.tsx index 18a2bf59..7aca0520 100644 --- a/frontend/src/pages/SubscriberRead.tsx +++ b/frontend/src/pages/SubscriberRead.tsx @@ -24,6 +24,9 @@ import { TableCell, TableRow, } from "@mui/material"; +import FlowRule from "./Component/FlowRule"; +import ChargingCfg from "./Component/ChargingCfg"; +import UpSecurity from "./Component/UpSecurity"; export default function SubscriberRead() { const { id, plmn } = useParams<{ @@ -132,143 +135,43 @@ export default function SubscriberRead() { } }; - const chargingConfig = (dnn: string | undefined, snssai: Nssai, filter: string | undefined) => { + const chargingConfig = (dnn: string, snssai: Nssai, filter: string | undefined) => { const flowKey = toHex(snssai.sst) + snssai.sd; for (const chargingData of data?.ChargingDatas ?? []) { - const isOnlineCharging = chargingData.chargingMethod === "Online"; - if ( chargingData.snssai === flowKey && chargingData.dnn === dnn && chargingData.filter === filter ) { - return ( - - - -

Charging Config

-
-
- - - Charging Method - {chargingData.chargingMethod} - - {isOnlineCharging ? ( - - Quota - {chargingData.quota} - - ) : ( - <> - )} - - Unit Cost - {chargingData.unitCost} - -
-
- ); + return ; } } }; const flowRule = (dnn: string, snssai: Nssai) => { - console.log("in flowRule"); - console.log(data?.FlowRules); const flowKey = toHex(snssai.sst) + snssai.sd; - if (data?.FlowRules !== undefined) { - return data.FlowRules.filter((flow) => flow.dnn === dnn && flow.snssai === flowKey).map( - (flow) => ( -
- -

Flow Rules

- - - - - IP Filter - {flow.filter} - - - - - Precedence - {flow.precedence} - - - - - 5QI - {qosFlow(flowKey, dnn, flow.qosRef)?.["5qi"]} - - - - - Uplink GBR - {qosFlow(flowKey, dnn, flow.qosRef!)?.gbrUL} - - - - - Downlink GBR - {qosFlow(flowKey, dnn, flow.qosRef!)?.gbrDL} - - - - - Uplink MBR - {qosFlow(flowKey, dnn, flow.qosRef!)?.mbrUL} - - - - - Downlink MBR - {qosFlow(flowKey, dnn, flow.qosRef!)?.mbrDL} - - - - {chargingConfig(dnn, snssai!, flow.filter)} - -
-
-
-
- ), - ); + if (data?.FlowRules === undefined) { + return
; } - return
; + return data.FlowRules.filter((flow) => flow.dnn === dnn && flow.snssai === flowKey).map( + (flow) => ( + + ), + ); }; const upSecurity = (dnn: DnnConfiguration | undefined) => { - if (dnn !== undefined && dnn!.upSecurity !== undefined) { - const security = dnn!.upSecurity!; - return ( -
- -

UP Security

- - - - - Integrity of UP Security - {security.upIntegr} - - - - - Confidentiality of UP Security - {security.upConfid} - - -
-
-
-
- ); - } else { + if (dnn === undefined || dnn.upSecurity === undefined) { return
; } + return ; }; return ( diff --git a/frontend/webconsole.yaml b/frontend/webconsole.yaml index b6c02fdc..cd0cbe71 100644 --- a/frontend/webconsole.yaml +++ b/frontend/webconsole.yaml @@ -36,6 +36,32 @@ paths: type: array items: $ref: "#/components/schemas/Subscriber" + /api/profile: + get: + tags: + - webconsole + summary: Get all profiles + description: Returns an array of profile. + parameters: + - in: query + name: limit + schema: + type: integer + format: int64 + - in: query + name: page + schema: + type: integer + format: int64 + responses: + "200": + description: Returns an array of profile. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ProfileListIE" components: schemas: # Meta object @@ -67,6 +93,15 @@ components: type: string gpsi: type: string + # ProfileListIE + # github.com/free5gc/webconsole/backend/WebUI/model_profile_list_ie.go:ProfileListIE + ProfileListIE: + type: object + properties: + profileName: + type: string + gpsi: + type: string # PduSession # github.com/free5gc/amf/internal/sbi/producer/oam.go:PduSession PduSession: @@ -712,3 +747,44 @@ components: type: string DlVol: type: string + # Profile + # github.com/free5gc/webconsole/backend/WebUI/model_profile_data.go:ProfileData + Profile: + type: object + required: + - profileName + - AccessAndMobilitySubscriptionData + - SessionManagementSubscriptionData + - SmfSelectionSubscriptionData + - AmPolicyData + - SmPolicyData + - FlowRules + - QosFlows + - ChargingDatas + properties: + profileName: + type: string + AccessAndMobilitySubscriptionData: + $ref: "#/components/schemas/AccessAndMobilitySubscriptionData" + SessionManagementSubscriptionData: + type: array + items: + $ref: "#/components/schemas/SessionManagementSubscriptionData" + SmfSelectionSubscriptionData: + $ref: "#/components/schemas/SmfSelectionSubscriptionData" + AmPolicyData: + $ref: "#/components/schemas/AmPolicyData" + SmPolicyData: + $ref: "#/components/schemas/SmPolicyData" + FlowRules: + type: array + items: + $ref: "#/components/schemas/FlowRules" + QosFlows: + type: array + items: + $ref: "#/components/schemas/QosFlows" + ChargingDatas: + type: array + items: + $ref: "#/components/schemas/ChargingData" diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 72d10b87..14cd9cd8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3767,6 +3767,7 @@ __metadata: react-hook-form: "npm:^7.51.5" react-router-dom: "npm:^6.22.2" typescript: "npm:^5.3.3" + vite: "npm:^5.2.14" vitest: "npm:^2.0.2" web-vitals: "npm:^3.5.2" zod: "npm:^3.23.8" @@ -5094,6 +5095,49 @@ __metadata: languageName: node linkType: hard +"vite@npm:^5.2.14": + version: 5.4.10 + resolution: "vite@npm:5.4.10" + dependencies: + esbuild: "npm:^0.21.3" + fsevents: "npm:~2.3.3" + postcss: "npm:^8.4.43" + rollup: "npm:^4.20.0" + peerDependencies: + "@types/node": ^18.0.0 || >=20.0.0 + less: "*" + lightningcss: ^1.21.0 + sass: "*" + sass-embedded: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/4ef4807d2fd166a920de244dbcec791ba8a903b017a7d8e9f9b4ac40d23f8152c1100610583d08f542b47ca617a0505cfc5f8407377d610599d58296996691ed + languageName: node + linkType: hard + "vitest@npm:^2.0.2": version: 2.0.2 resolution: "vitest@npm:2.0.2" diff --git a/run.sh b/run.sh new file mode 100755 index 00000000..75b9ba20 --- /dev/null +++ b/run.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +######################################################## +# +# This script is used to build and run the free5gc-Webconsole +# +# For quickly developing used +# +########## + +cd frontend + +# check yarn install +if [ ! -d "node_modules" ]; then + echo "node_modules not found, installing..." + yarn install +else + echo "node_modules found, skipping installation" +fi + +# yarn build +echo "building frontend..." +yarn build + +# copy build to public +echo "copying build to public..." +rm -rf ../public +cp -R build ../public +cd .. + +# run server +echo "running server..." +go run server.go