From 7e40fcc2efe9fa71c968ed972b0f00ca466ed4d7 Mon Sep 17 00:00:00 2001 From: Helder Betiol <37706737+helderbetiol@users.noreply.github.com> Date: Mon, 12 Aug 2024 15:55:11 +0200 Subject: [PATCH] Add impact functionality (#504) * feat(api,app) add impact functionality * fix(app) test * feat(api) update swagger doc --- API/controllers/impact.go | 86 ++++ API/controllers/web.go | 161 +++++++- API/controllers/web_test.go | 50 +++ API/models/entity_get_hierarchy.go | 1 + API/models/impact.go | 113 ++++++ API/models/web_alerts.go | 79 ++++ API/models/web_projects.go | 1 + API/router/router.go | 16 + API/swagger.json | 185 ++++++++- API/test/integration/entities.go | 19 + API/test/utils/endpoints.go | 1 + APP/lib/common/api_backend.dart | 113 +++++- APP/lib/l10n/app_en.arb | 29 +- APP/lib/l10n/app_fr.arb | 28 +- APP/lib/main.dart | 3 + APP/lib/models/alert.dart | 39 ++ APP/lib/models/project.dart | 7 +- APP/lib/pages/alert_page.dart | 258 ++++++------ APP/lib/pages/impact_page.dart | 201 ++++++++++ APP/lib/pages/projects_page.dart | 159 +++++--- APP/lib/pages/select_page.dart | 51 ++- APP/lib/widgets/common/form_field.dart | 3 + APP/lib/widgets/impact/impact_graph_view.dart | 131 +++++++ APP/lib/widgets/impact/impact_popup.dart | 197 ++++++++++ APP/lib/widgets/impact/impact_view.dart | 371 ++++++++++++++++++ APP/lib/widgets/login/login_card.dart | 2 +- APP/lib/widgets/projects/project_card.dart | 17 +- .../settings_view/_advanced_find_field.dart | 53 +++ .../settings_view/tree_filter.dart | 4 +- .../select_objects/treeapp_controller.dart | 74 +++- APP/pubspec.lock | 8 + APP/pubspec.yaml | 1 + 32 files changed, 2221 insertions(+), 240 deletions(-) create mode 100644 API/controllers/impact.go create mode 100644 API/models/impact.go create mode 100644 API/models/web_alerts.go create mode 100644 APP/lib/models/alert.dart create mode 100644 APP/lib/pages/impact_page.dart create mode 100644 APP/lib/widgets/impact/impact_graph_view.dart create mode 100644 APP/lib/widgets/impact/impact_popup.dart create mode 100644 APP/lib/widgets/impact/impact_view.dart diff --git a/API/controllers/impact.go b/API/controllers/impact.go new file mode 100644 index 000000000..f72aa0932 --- /dev/null +++ b/API/controllers/impact.go @@ -0,0 +1,86 @@ +package controllers + +import ( + "fmt" + "net/http" + "p3/models" + u "p3/utils" + + "github.com/gorilla/mux" +) + +func getImpactFiltersFromQueryParams(r *http.Request) models.ImpactFilters { + var filters models.ImpactFilters + fmt.Println(r.URL.Query()) + decoder.Decode(&filters, r.URL.Query()) + fmt.Println(filters) + return filters +} + +// swagger:operation GET /api/impact/{id} Objects GetImpact +// Returns all objects that could directly or indirectly impacted +// by the object sent in the request. +// --- +// security: +// - bearer: [] +// produces: +// - application/json +// parameters: +// - name: id +// in: path +// description: 'ID of target object.' +// required: true +// type: string +// default: "siteA" +// - name: categories +// in: query +// description: 'Categories to include on indirect impact search. +// Can be repeated to create a list.' +// - name: ptypes +// in: query +// description: 'Physical types to include on indirect impact search. +// Can be repeated to create a list.' +// - name: vtypes +// in: query +// description: 'Virtual types to include on indirect impact search. +// Can be repeated to create a list.' +// responses: +// '200': +// description: 'Request is valid.' +// '500': +// description: Server error. + +func GetImpact(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: GetImpact ") + fmt.Println("******************************************************") + DispRequestMetaData(r) + + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + + filters := getImpactFiltersFromQueryParams(r) + + // Get id of impact target + id, canParse := mux.Vars(r)["id"] + if !canParse { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message("Error while parsing path parameters")) + u.ErrLog("Error while parsing path parameters", "GET ENTITY", "", r) + return + } + + data, err := models.GetImpact(id, user.Roles, filters) + if err != nil { + u.RespondWithError(w, err) + } else { + if r.Method == "OPTIONS" { + u.WriteOptionsHeader(w, "GET, HEAD") + } else { + u.Respond(w, u.RespDataWrapper("successfully got hierarchy", data)) + } + } +} diff --git a/API/controllers/web.go b/API/controllers/web.go index 209b3d898..0ab6ed437 100644 --- a/API/controllers/web.go +++ b/API/controllers/web.go @@ -171,7 +171,7 @@ func CreateOrUpdateProject(w http.ResponseWriter, r *http.Request) { // default: "1234" // responses: // '200': -// description: Project successfully updated. +// description: Project successfully removed. // '404': // description: Not Found. Invalid project ID. // '500': @@ -194,3 +194,162 @@ func DeleteProject(w http.ResponseWriter, r *http.Request) { } } } + +// swagger:operation POST /api/alerts FlutterApp CreateAlert +// Create a new alert +// --- +// security: +// - bearer: [] +// produces: +// - application/json +// parameters: +// - name: body +// in: body +// description: 'Mandatory: id, type. +// Optional: title, subtitle.' +// required: true +// format: object +// example: '{"id":"OBJID.WITH.ALERT","type":"minor", +// "title":"This is the title","subtitle":"More information"}' +// responses: +// '200': +// description: 'Alert successfully created.' +// '400': +// description: 'Bad Request. Invalid alert format.' +// '500': +// description: 'Internal server error.' + +func CreateAlert(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: CreateAlert ") + fmt.Println("******************************************************") + + alert := &models.Alert{} + err := json.NewDecoder(r.Body).Decode(alert) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message("Invalid request")) + return + } + + mErr := models.AddAlert(*alert) + + if mErr != nil { + u.RespondWithError(w, mErr) + } else { + if r.Method == "OPTIONS" { + u.WriteOptionsHeader(w, "GET, HEAD") + } else { + u.Respond(w, u.Message("successfully created alert")) + } + } +} + +// swagger:operation GET /api/alerts FlutterApp GetAlerts +// Get a list of all alerts +// --- +// security: +// - bearer: [] +// produces: +// - application/json +// responses: +// '200': +// description: 'Return all possible alerts.' +// '500': +// description: 'Internal server error.' + +func GetAlerts(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: GetAlerts ") + fmt.Println("******************************************************") + + projects, err := models.GetAlerts() + if err != nil { + u.RespondWithError(w, err) + } else { + if r.Method == "OPTIONS" { + u.WriteOptionsHeader(w, "GET, HEAD") + } else { + resp := map[string]interface{}{} + resp["alerts"] = projects + u.Respond(w, u.RespDataWrapper("successfully got alerts", resp)) + } + } +} + +// swagger:operation GET /api/alerts/{AlertID} FlutterApp GetAlert +// Get a list of all alerts +// --- +// security: +// - bearer: [] +// produces: +// - application/json +// parameters: +// - name: AlertID +// in: path +// description: 'ID of the alert to recover.' +// required: true +// type: string +// responses: +// '200': +// description: 'Return requested alert' +// '404': +// description: 'Alert not found' +// '500': +// description: 'Internal server error' + +func GetAlert(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: GetAlert ") + fmt.Println("******************************************************") + + alert, err := models.GetAlert(mux.Vars(r)["id"]) + if err != nil { + u.RespondWithError(w, err) + } else { + if r.Method == "OPTIONS" { + u.WriteOptionsHeader(w, "GET, HEAD") + } else { + u.Respond(w, u.RespDataWrapper("successfully got alert", alert)) + } + } +} + +// swagger:operation DELETE /api/alerts/{AlertID} FlutterApp DeleteAlert +// Delete an existing alert. +// --- +// security: +// - bearer: [] +// produces: +// - application/json +// parameters: +// - name: AlertID +// in: path +// description: 'ID of the alert to delete.' +// required: true +// type: string +// responses: +// '200': +// description: Alert successfully removed. +// '404': +// description: Not Found. Invalid alert ID. +// '500': +// description: Internal server error + +func DeleteAlert(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: DeleteAlert ") + fmt.Println("******************************************************") + + err := models.DeleteAlert(mux.Vars(r)["id"]) + + if err != nil { + u.RespondWithError(w, err) + } else { + if r.Method == "OPTIONS" { + u.WriteOptionsHeader(w, "GET, HEAD, DELETE") + } else { + u.Respond(w, u.Message("successfully removed alert")) + } + } +} diff --git a/API/controllers/web_test.go b/API/controllers/web_test.go index f5f17ce87..8269905f1 100644 --- a/API/controllers/web_test.go +++ b/API/controllers/web_test.go @@ -17,6 +17,7 @@ func init() { } var projectsEndpoint = test_utils.GetEndpoint("projects") +var alertsEndpoint = test_utils.GetEndpoint("alerts") func TestCreateProjectInvalidBody(t *testing.T) { e2e.TestInvalidBody(t, "POST", projectsEndpoint, "Invalid request") @@ -91,3 +92,52 @@ func TestDeleteProject(t *testing.T) { // if we try to delete again we get an error e2e.ValidateManagedRequest(t, "DELETE", projectsEndpoint+"/"+id, nil, http.StatusNotFound, "Project not found") } + +func TestCreateAlertInvalidBody(t *testing.T) { + e2e.TestInvalidBody(t, "POST", alertsEndpoint, "Invalid request") +} + +func TestCreateAlert(t *testing.T) { + requestBody, _ := json.Marshal(map[string]any{ + "id": "OBJID.WITH.ALERT", + "type": "minor", + "title": "This is the title", + "subtitle": "More information", + }) + + e2e.ValidateManagedRequest(t, "POST", alertsEndpoint, requestBody, http.StatusOK, "successfully created alert") +} + +func TestGetAlerts(t *testing.T) { + _, id := integration.CreateTestAlert(t, "temporaryAlert", false) + response := e2e.ValidateManagedRequest(t, "GET", alertsEndpoint, nil, http.StatusOK, "successfully got alerts") + + data, exists := response["data"].(map[string]interface{}) + assert.True(t, exists) + alerts, exists := data["alerts"].([]interface{}) + assert.True(t, exists) + assert.Equal(t, 2, len(alerts)) // temporaryAlert and OBJID.WITH.ALERT + + exists = slices.ContainsFunc(alerts, func(project interface{}) bool { + return project.((map[string]interface{}))["id"] == id + }) + assert.True(t, exists) +} + +func TestGetAlert(t *testing.T) { + _, id := integration.CreateTestAlert(t, "tempAlert", false) + response := e2e.ValidateManagedRequest(t, "GET", alertsEndpoint+"/"+id, nil, http.StatusOK, "successfully got alert") + + alert, exists := response["data"].(map[string]interface{}) + assert.True(t, exists) + assert.Equal(t, id, alert["id"]) +} + +func TestDeleteAlert(t *testing.T) { + _, id := integration.CreateTestAlert(t, "tempAlert2", true) + println("one") + e2e.ValidateManagedRequest(t, "DELETE", alertsEndpoint+"/"+id, nil, http.StatusOK, "successfully removed alert") + println("two") + // if we try to delete again we get an error + // e2e.ValidateManagedRequest(t, "DELETE", alertsEndpoint+"/"+id, nil, http.StatusNotFound, "Alert not found") +} diff --git a/API/models/entity_get_hierarchy.go b/API/models/entity_get_hierarchy.go index 8bbe462e9..d1a5e4d20 100644 --- a/API/models/entity_get_hierarchy.go +++ b/API/models/entity_get_hierarchy.go @@ -304,6 +304,7 @@ func getChildrenCollections(limit int, parentEntStr string) []int { // include AC, CABINET, CORRIDOR, PWRPNL and GROUP // beacause of ROOM and RACK possible children // but no need to search further than group + rangeEntities = []int{u.VIRTUALOBJ} endEnt = u.GROUP } diff --git a/API/models/impact.go b/API/models/impact.go new file mode 100644 index 000000000..6e2eab4bd --- /dev/null +++ b/API/models/impact.go @@ -0,0 +1,113 @@ +package models + +import ( + "fmt" + u "p3/utils" + "reflect" + "strings" + + "github.com/elliotchance/pie/v2" + "go.mongodb.org/mongo-driver/bson" +) + +type ImpactFilters struct { + Categories []string `schema:"categories"` + Ptypes []string `schema:"ptypes"` + Vtypes []string `schema:"vtypes"` +} + +func GetImpact(id string, userRoles map[string]Role, filters ImpactFilters) (map[string]any, *u.Error) { + // Get target object for impact analysis + target, err := GetObjectById(id, u.HIERARCHYOBJS_ENT, u.RequestFilters{}, userRoles) + if err != nil { + return nil, err + } + + // Get all children + allChildren, _, err := getChildren(target["category"].(string), target["id"].(string), 999, u.RequestFilters{}) + if err != nil { + return nil, err + } + fmt.Println(filters) + + // Find relations + directChildren := map[string]any{} + indirectChildren := map[string]any{} + clusterRelations := map[string][]string{} + targetLevel := strings.Count(id, ".") + for childId, childData := range allChildren { + childAttrs := childData.(map[string]any)["attributes"].(map[string]any) + if strings.Count(childId, ".") == targetLevel+1 { + // direct child + directChildren[childId] = childData + // check if linked to a cluster + setClusterRelation(childId, childAttrs, clusterRelations) + continue + } + setIndirectChildren(filters, childData.(map[string]any), childAttrs, indirectChildren, clusterRelations) + } + + // handle cluster relations + if err := clusterRelationsToIndirect(filters, clusterRelations, indirectChildren, userRoles); err != nil { + return nil, err + } + + // send response + data := map[string]any{"direct": directChildren, "indirect": indirectChildren, "relations": clusterRelations} + return data, nil +} + +func setClusterRelation(childId string, childAttrs map[string]any, clusterRelations map[string][]string) { + vconfig, hasVconfig := childAttrs["virtual_config"].(map[string]any) + if hasVconfig && vconfig["clusterId"] != nil { + clusterId := vconfig["clusterId"].(string) + if clusterId == "" { + return + } + clusterRelations[clusterId] = append(clusterRelations[clusterId], childId) + } +} + +func setIndirectChildren(filters ImpactFilters, childData, childAttrs, indirectChildren map[string]any, + clusterRelations map[string][]string) { + childId := childData["id"].(string) + vconfig, hasVconfig := childAttrs["virtual_config"].(map[string]any) + ptype, hasPtype := childAttrs["type"].(string) + if pie.Contains(filters.Categories, childData["category"].(string)) || + (hasPtype && pie.Contains(filters.Ptypes, ptype)) || + (hasVconfig && reflect.TypeOf(vconfig["type"]).Kind() == reflect.String && pie.Contains(filters.Vtypes, vconfig["type"].(string))) { + // indirect relation + indirectChildren[childId] = childData + // check if linked to a cluster + setClusterRelation(childId, childAttrs, clusterRelations) + } +} + +func clusterRelationsToIndirect(filters ImpactFilters, clusterRelations map[string][]string, indirectChildren map[string]any, userRoles map[string]Role) *u.Error { + if pie.Contains(filters.Vtypes, "application") { + for clusterId := range clusterRelations { + // check if linked cluster has apps + entData, err := GetManyObjects("virtual_obj", bson.M{}, u.RequestFilters{}, "id="+clusterId+".**.*&"+"(virtual_config.type=kube-app|virtual_config.type=application)", userRoles) + if err != nil { + return err + } else if len(entData) == 0 { + // no apps, show only cluster + indirectChildren[clusterId] = map[string]any{ + "category": "virtual_obj", + } + } else { + // show apps + for _, appData := range entData { + indirectChildren[appData["id"].(string)] = appData + } + } + } + } else if pie.Contains(filters.Vtypes, "cluster") { + for clusterId := range clusterRelations { + // no apps, show only cluster + indirectChildren[clusterId] = map[string]any{ + "category": "virtual_obj"} + } + } + return nil +} diff --git a/API/models/web_alerts.go b/API/models/web_alerts.go new file mode 100644 index 000000000..de09d1eb3 --- /dev/null +++ b/API/models/web_alerts.go @@ -0,0 +1,79 @@ +package models + +import ( + "p3/repository" + u "p3/utils" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +type Alert struct { + Id string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` +} + +const WEB_ALERT = "web_alert" + +// POST +func AddAlert(newAlert Alert) *u.Error { + // Add the new alert + ctx, cancel := u.Connect() + _, err := repository.GetDB().Collection(WEB_ALERT).InsertOne(ctx, newAlert) + if err != nil { + println(err.Error()) + return &u.Error{Type: u.ErrDBError, Message: err.Error()} + } + + defer cancel() + return nil +} + +// GET +func GetAlerts() ([]Alert, *u.Error) { + results := []Alert{} + filter := bson.D{} + ctx, cancel := u.Connect() + cursor, err := repository.GetDB().Collection(WEB_ALERT).Find(ctx, filter) + if err != nil { + return nil, &u.Error{Type: u.ErrDBError, Message: err.Error()} + } else if err = cursor.All(ctx, &results); err != nil { + return nil, &u.Error{Type: u.ErrInternal, Message: err.Error()} + } + + defer cancel() + return results, nil +} + +func GetAlert(id string) (Alert, *u.Error) { + alert := &Alert{} + filter := bson.M{"id": id} + ctx, cancel := u.Connect() + err := repository.GetDB().Collection(WEB_ALERT).FindOne(ctx, filter).Decode(alert) + if err != nil { + if err == mongo.ErrNoDocuments { + return *alert, &u.Error{Type: u.ErrNotFound, Message: "Alert does not exist"} + } + return *alert, &u.Error{Type: u.ErrDBError, Message: err.Error()} + } + + defer cancel() + return *alert, nil +} + +// DELETE +func DeleteAlert(alertId string) *u.Error { + ctx, cancel := u.Connect() + res, err := repository.GetDB().Collection(WEB_ALERT).DeleteOne(ctx, bson.M{"id": alertId}) + defer cancel() + + if err != nil { + return &u.Error{Type: u.ErrDBError, Message: err.Error()} + } else if res.DeletedCount <= 0 { + return &u.Error{Type: u.ErrNotFound, + Message: "Alert not found"} + } + return nil +} diff --git a/API/models/web_projects.go b/API/models/web_projects.go index 2780593c4..4b89d9403 100644 --- a/API/models/web_projects.go +++ b/API/models/web_projects.go @@ -27,6 +27,7 @@ type Project struct { ShowAvg bool `json:"showAvg"` ShowSum bool `json:"showSum"` IsPublic bool `json:"isPublic"` + IsImpact bool `json:"isImpact"` } // PROJECTS diff --git a/API/router/router.go b/API/router/router.go index 495bb57ae..affe777af 100644 --- a/API/router/router.go +++ b/API/router/router.go @@ -99,6 +99,18 @@ func Router(jwt func(next http.Handler) http.Handler) *mux.Router { router.HandleFunc("/api/projects/{id:[a-zA-Z0-9]{24}}", controllers.DeleteProject).Methods("DELETE", "OPTIONS") + router.HandleFunc("/api/alerts", + controllers.CreateAlert).Methods("POST") + + router.HandleFunc("/api/alerts", + controllers.GetAlerts).Methods("HEAD", "GET", "OPTIONS") + + router.HandleFunc("/api/alerts/{id}", + controllers.GetAlert).Methods("HEAD", "GET", "OPTIONS") + + router.HandleFunc("/api/alerts/{id}", + controllers.DeleteAlert).Methods("DELETE", "OPTIONS") + // For get or ls wih complex filters router.HandleFunc(GenericObjectsURL+"/search", controllers.HandleComplexFilters).Methods("POST", "HEAD", "OPTIONS", "DELETE") @@ -131,6 +143,10 @@ func Router(jwt func(next http.Handler) http.Handler) *mux.Router { router.HandleFunc("/api/{entity}s", controllers.GetAllEntities).Methods("HEAD", "GET") + // GET IMPACT + router.HandleFunc("/api/impact/{id}", + controllers.GetImpact).Methods("GET", "OPTIONS", "HEAD") + // CREATE ENTITY router.HandleFunc("/api/{entity}s", controllers.CreateEntity).Methods("POST") diff --git a/API/swagger.json b/API/swagger.json index 6b4148f72..02e8aa2d8 100644 --- a/API/swagger.json +++ b/API/swagger.json @@ -22,6 +22,139 @@ "host": "localhost:3001", "basePath": "/", "paths": { + "/api/alerts": { + "get": { + "security": [ + { + "bearer": [] + } + ], + "description": "Get a list of all alerts", + "produces": [ + "application/json" + ], + "tags": [ + "FlutterApp" + ], + "operationId": "GetAlerts", + "responses": { + "200": { + "description": "Return all possible alerts." + }, + "500": { + "description": "Internal server error." + } + } + }, + "post": { + "security": [ + { + "bearer": [] + } + ], + "description": "Create a new alert", + "produces": [ + "application/json" + ], + "tags": [ + "FlutterApp" + ], + "operationId": "CreateAlert", + "parameters": [ + { + "format": "object", + "example": "{\"id\":\"OBJID.WITH.ALERT\",\"type\":\"minor\", \"title\":\"This is the title\",\"subtitle\":\"More information\"}", + "description": "Mandatory: id, type. Optional: title, subtitle.", + "name": "body", + "in": "body", + "required": true + } + ], + "responses": { + "200": { + "description": "Alert successfully created." + }, + "400": { + "description": "Bad Request. Invalid alert format." + }, + "500": { + "description": "Internal server error." + } + } + } + }, + "/api/alerts/{AlertID}": { + "get": { + "security": [ + { + "bearer": [] + } + ], + "description": "Get a list of all alerts", + "produces": [ + "application/json" + ], + "tags": [ + "FlutterApp" + ], + "operationId": "GetAlert", + "parameters": [ + { + "type": "string", + "description": "ID of the alert to recover.", + "name": "AlertID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Return requested alert" + }, + "404": { + "description": "Alert not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "security": [ + { + "bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "FlutterApp" + ], + "summary": "Delete an existing alert.", + "operationId": "DeleteAlert", + "parameters": [ + { + "type": "string", + "description": "ID of the alert to delete.", + "name": "AlertID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Alert successfully removed." + }, + "404": { + "description": "Not Found. Invalid alert ID." + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/api/domains/bulk": { "post": { "security": [ @@ -190,6 +323,56 @@ } } }, + "/api/impact/{id}": { + "get": { + "security": [ + { + "bearer": [] + } + ], + "description": "Returns all objects that could directly or indirectly impacted\nby the object sent in the request.", + "produces": [ + "application/json" + ], + "tags": [ + "Objects" + ], + "operationId": "GetImpact", + "parameters": [ + { + "type": "string", + "default": "siteA", + "description": "ID of target object.", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Categories to include on indirect impact search. Can be repeated to create a list.", + "name": "categories", + "in": "query" + }, + { + "description": "Physical types to include on indirect impact search. Can be repeated to create a list.", + "name": "ptypes", + "in": "query" + }, + { + "description": "Virtual types to include on indirect impact search. Can be repeated to create a list.", + "name": "vtypes", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Request is valid." + }, + "500": { + "description": "Server error." + } + } + } + }, "/api/layers/{slug}/objects": { "get": { "security": [ @@ -626,7 +809,7 @@ ], "responses": { "200": { - "description": "Project successfully updated." + "description": "Project successfully removed." }, "404": { "description": "Not Found. Invalid project ID." diff --git a/API/test/integration/entities.go b/API/test/integration/entities.go index 6ba47e491..d64374e60 100644 --- a/API/test/integration/entities.go +++ b/API/test/integration/entities.go @@ -315,3 +315,22 @@ func CreateTestProject(t *testing.T, name string) (models.Project, string) { }) return project, projectId } + +func CreateTestAlert(t *testing.T, name string, isDelete bool) (models.Alert, string) { + // Creates a temporary project that will be deleted at the end of the test + alert := models.Alert{ + Id: name, + Type: "minor", + Title: "marked for maintenance", + } + err := models.AddAlert(alert) + assert.Nil(t, err) + + t.Cleanup(func() { + if !isDelete { + err := models.DeleteAlert(name) + assert.Nil(t, err) + } + }) + return alert, name +} diff --git a/API/test/utils/endpoints.go b/API/test/utils/endpoints.go index 15f1eb8b4..bd5e537c7 100644 --- a/API/test/utils/endpoints.go +++ b/API/test/utils/endpoints.go @@ -31,6 +31,7 @@ var endpoints = map[string]string{ "hierarchyAttributes": hierarchyEdnpoint + "/attributes", "tempunits": "/api/tempunits/%s", "projects": "/api/projects", + "alerts": "/api/alerts", } func GetEndpoint(endpointName string, pathParams ...any) string { diff --git a/APP/lib/common/api_backend.dart b/APP/lib/common/api_backend.dart index b1152b9c9..6bd3e8950 100644 --- a/APP/lib/common/api_backend.dart +++ b/APP/lib/common/api_backend.dart @@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:ogree_app/models/alert.dart'; import 'package:ogree_app/models/container.dart'; import 'package:ogree_app/models/domain.dart'; import 'package:ogree_app/models/netbox.dart'; @@ -355,11 +356,13 @@ Future, Exception>> fetchProjects(String userEmail, } } -Future> deleteProject(String id) async { - print("API delete Projects"); +Future> modifyProject(Project project) async { + print("API modify Projects"); try { - Uri url = Uri.parse('$apiUrl/api/projects/$id'); - final response = await http.delete(url, headers: getHeader(token)); + Uri url = Uri.parse('$apiUrl/api/projects/${project.id}'); + final response = + await http.put(url, body: project.toJson(), headers: getHeader(token)); + print(response); if (response.statusCode == 200) { return const Success(null); } else { @@ -371,12 +374,12 @@ Future> deleteProject(String id) async { } } -Future> modifyProject(Project project) async { - print("API modify Projects"); +Future> createProject(Project project) async { + print("API create Projects"); try { - Uri url = Uri.parse('$apiUrl/api/projects/${project.id}'); + Uri url = Uri.parse('$apiUrl/api/projects'); final response = - await http.put(url, body: project.toJson(), headers: getHeader(token)); + await http.post(url, body: project.toJson(), headers: getHeader(token)); print(response); if (response.statusCode == 200) { return const Success(null); @@ -389,12 +392,12 @@ Future> modifyProject(Project project) async { } } -Future> createProject(Project project) async { +Future> createAlert(Alert alert) async { print("API create Projects"); try { - Uri url = Uri.parse('$apiUrl/api/projects'); + Uri url = Uri.parse('$apiUrl/api/alerts'); final response = - await http.post(url, body: project.toJson(), headers: getHeader(token)); + await http.post(url, body: alert.toJson(), headers: getHeader(token)); print(response); if (response.statusCode == 200) { return const Success(null); @@ -407,6 +410,56 @@ Future> createProject(Project project) async { } } +Future, Exception>> fetchAlerts( + {http.Client? client}) async { + print("API get Alerts"); + client ??= http.Client(); + try { + Uri url = Uri.parse('$apiUrl/api/alerts'); + final response = await client.get(url, headers: getHeader(token)); + print(response.statusCode); + if (response.statusCode == 200) { + print(response); + print(response.body); + // Convert dynamic Map to expected type + Map data = json.decode(response.body); + data = (Map.from(data["data"])); + List alerts = []; + for (var alert in data["alerts"]) { + alerts.add(Alert.fromMap(alert)); + } + return Success(alerts); + } else { + return Failure(Exception( + wrapResponseMsg(response, message: 'Failed to load objects'))); + } + } on Exception catch (e) { + return Failure(e); + } +} + +Future> fetchAlert(String id, + {http.Client? client}) async { + print("API get Alert by id"); + client ??= http.Client(); + try { + Uri url = Uri.parse('$apiUrl/api/alerts/$id'); + final response = await client.get(url, headers: getHeader(token)); + print(response.statusCode); + if (response.statusCode == 200) { + // Convert dynamic Map to expected type + Map data = json.decode(response.body); + data = (Map.from(data["data"])); + return Success(Alert.fromMap(data)); + } else { + return Failure( + Exception(wrapResponseMsg(response, message: 'Failed to get alert'))); + } + } on Exception catch (e) { + return Failure(e); + } +} + Future> createObject( Map object, String category) async { print("API create Object"); @@ -462,7 +515,32 @@ Future, Exception>> fetchObjectChildren( String id) async { print("API fetch Object /all"); try { - Uri url = Uri.parse('$apiUrl/api/hierarchy_objects/$id/all'); + Uri url = Uri.parse('$apiUrl/api/hierarchy_objects/$id/all?limit=2'); + final response = await http.get(url, headers: getHeader(token)); + if (response.statusCode == 200 || response.statusCode == 201) { + Map data = json.decode(response.body); + return Success(Map.from(data["data"])); + } else { + final Map data = json.decode(response.body); + return Failure(Exception(data["message"].toString())); + } + } on Exception catch (e) { + return Failure(e); + } +} + +Future, Exception>> fetchObjectImpact(String id, + List categories, List ptypes, List vtypes) async { + print("API fetch Object Impact"); + String queryParam = listToQueryParam("", categories, "categories"); + queryParam = listToQueryParam(queryParam, ptypes, "ptypes"); + queryParam = listToQueryParam(queryParam, vtypes, "vtypes"); + try { + String urlStr = '$apiUrl/api/impact/$id'; + if (queryParam.isNotEmpty) { + urlStr = "$urlStr?$queryParam"; + } + Uri url = Uri.parse(urlStr); final response = await http.get(url, headers: getHeader(token)); if (response.statusCode == 200 || response.statusCode == 201) { Map data = json.decode(response.body); @@ -476,6 +554,17 @@ Future, Exception>> fetchObjectChildren( } } +String listToQueryParam(String currentParam, List list, String key) { + String param = currentParam; + for (String item in list) { + if (param.isNotEmpty) { + param = "$param&"; + } + param = "$param$key=$item"; + } + return param; +} + Future>, Exception>> fetchWithComplexFilter( String filter, Namespace namespace, AppLocalizations localeMsg) async { print("API fetch Complex Filter"); diff --git a/APP/lib/l10n/app_en.arb b/APP/lib/l10n/app_en.arb index c418e61f7..0cf8a4516 100644 --- a/APP/lib/l10n/app_en.arb +++ b/APP/lib/l10n/app_en.arb @@ -217,5 +217,32 @@ "temperatureAlert2": " is higher than usual. This could impact performance for your applications: \"my-frontend-app\" and \"my-backend-app\".", "oneAlert": "1 minor alert", - "virtualConfigTitle" : "Virtual Configuration:" + "virtualConfigTitle" : "Virtual Configuration:", + + "impactAnalysis": "Impact Analysis", + "noAlerts": "No alerts found. All is good", + "goToImpact": "Go to Impact Page", + "markMaintenance": "Mark for maintenance", + "markedMaintenance": "Marked for maintenance", + "markAllMaintenance": "Mark all for maintenance", + "markAllTip": "Mark all listed objects for maintenance", + "unmarkAll": "Unmark all", + "allUnmarked": "All objects unmarked for maintenance", + "unmarkAllTip": "Unmark all listed objects for maintenance", + "downloadAll": "Download all", + "downloadAllTip": "Download report withh all objects", + "areMarkedMaintenance": "The following are marked for maintenance:", + "isMarked": "is marked for maintenance", + "isUnmarked": "unmarked for maintenance", + "checkImpact": "Check the possible impacts of this maintenance", + "allMarked": "All objects successfully marked for maintenance", + "impacts": "impacts", + "graphView": "Graph View", + "successMarked": "successfully marked for maintenance", + "directly": "directly", + "directTip": "Objects directly associated with this target", + "indirectly": "indirectly", + "indirectTip": "Other objects depending on objects directly associated to this target", + "indirectOptions": "Search options for Indirect Impact", + "onlyPredefinedWarning": "Only the predefined date range and namespace are allowed for impact analysis" } \ No newline at end of file diff --git a/APP/lib/l10n/app_fr.arb b/APP/lib/l10n/app_fr.arb index 4537ca769..c72717de5 100644 --- a/APP/lib/l10n/app_fr.arb +++ b/APP/lib/l10n/app_fr.arb @@ -285,6 +285,32 @@ "temperatureAlert2": " est plus élevée que d'habitude. Cela peut avoir un impact sur les performances de vos applications : \"my-frontend-app\" et \"my-backend-app\".", "oneAlert": "1 alerte mineur", - "virtualConfigTitle" : "Virtual Config :" + "virtualConfigTitle" : "Virtual Config :", + "impactAnalysis": "Analyse d'impact", + "noAlerts": "Aucune alerte n'a été trouvée. Tout va bien", + "goToImpact": "Voir l'impact", + "markMaintenance": "Marquer en maintenance", + "markedMaintenance": "Marqué en maintenance", + "markAllMaintenance": "Tout marquer pour la maintenance", + "markAllTip": "Marquer tout les objets pour la maintenance", + "unmarkAll": "Tout démarquer", + "allUnmarked": "Tout les objets sont démarqués", + "unmarkAllTip": "Enlever le marquage de maintenance de tous les objets", + "downloadAll": "Téléchargement complet", + "downloadAllTip": "Télécharger rapport complet", + "areMarkedMaintenance": "Les éléments suivants sont marqués pour la maintenance :", + "isMarked": "est marqué pour la maintenance", + "isUnmarked": "demarqué pour la maintenance", + "checkImpact": "Verifiez le possible impact de cette maintenance", + "allMarked": "Tout les objets sont marqués pour la maintenance", + "impacts": "peut avoir un impact sur :", + "graphView": "Affichage en graphe", + "successMarked": "marqué pour la maintenance", + "directly": "direct", + "directTip": "Objets directement associé à l'objet choisi", + "indirectly": "indirect", + "indirectTip": "D'autres objets dépendant des objets directement associés à cette cible", + "indirectOptions": "Options de recherche pour l'impact indirect", + "onlyPredefinedWarning": "Seuls la plage de dates et le namespace déjà prédéfinis sont acceptés pour l'analyse d'impact." } \ No newline at end of file diff --git a/APP/lib/main.dart b/APP/lib/main.dart index 9cbd0e28f..d816929b8 100644 --- a/APP/lib/main.dart +++ b/APP/lib/main.dart @@ -51,6 +51,9 @@ class MyAppState extends State { useMaterial3: true, colorSchemeSeed: Colors.blue, fontFamily: GoogleFonts.inter().fontFamily, + scrollbarTheme: ScrollbarThemeData( + thumbVisibility: MaterialStateProperty.all(true), + ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: Colors.blue.shade600, diff --git a/APP/lib/models/alert.dart b/APP/lib/models/alert.dart new file mode 100644 index 000000000..04007ff28 --- /dev/null +++ b/APP/lib/models/alert.dart @@ -0,0 +1,39 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +class Alert { + String id; + String type; + String title; + String subtitle; + + Alert( + this.id, + this.type, + this.title, + this.subtitle, + ); + + Map toMap() { + return { + 'id': id, + 'type': type, + 'title': title, + 'subtitle': subtitle, + }; + } + + factory Alert.fromMap(Map map) { + return Alert( + map['id'].toString(), + map['type'].toString(), + map['title'].toString(), + map['subtitle'].toString(), + ); + } + + String toJson() => json.encode(toMap()); + + factory Alert.fromJson(String source) => + Alert.fromMap(json.decode(source) as Map); +} diff --git a/APP/lib/models/project.dart b/APP/lib/models/project.dart index ca534af70..b3e5ac2d6 100644 --- a/APP/lib/models/project.dart +++ b/APP/lib/models/project.dart @@ -14,6 +14,7 @@ class Project { List attributes; List objects; final List permissions; + bool isImpact; Project( this.name, @@ -27,11 +28,11 @@ class Project { this.attributes, this.objects, this.permissions, - {this.id}); + {this.id, + this.isImpact = false}); Map toMap() { return { - // 'id': id, 'name': name, 'dateRange': dateRange, 'namespace': namespace, @@ -43,6 +44,7 @@ class Project { 'attributes': attributes, 'objects': objects, 'permissions': permissions, + 'isImpact': isImpact, }; } @@ -60,6 +62,7 @@ class Project { List.from((map['objects'])), List.from((map['permissions'])), id: map['Id'].toString(), + isImpact: map['isImpact'] is bool ? map['isImpact'] as bool : false, ); } diff --git a/APP/lib/pages/alert_page.dart b/APP/lib/pages/alert_page.dart index e18d73e55..03a24deca 100644 --- a/APP/lib/pages/alert_page.dart +++ b/APP/lib/pages/alert_page.dart @@ -1,15 +1,18 @@ import 'package:flutter/material.dart'; import 'package:ogree_app/common/appbar.dart'; -import 'package:ogree_app/models/tenant.dart'; +import 'package:ogree_app/common/definitions.dart'; +import 'package:ogree_app/models/alert.dart'; +import 'package:ogree_app/models/project.dart'; import 'package:ogree_app/pages/projects_page.dart'; +import 'package:ogree_app/pages/select_page.dart'; +import 'package:ogree_app/widgets/select_objects/settings_view/tree_filter.dart'; import 'package:ogree_app/widgets/select_objects/treeapp_controller.dart'; - -// THIS IS A DEMO +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class AlertPage extends StatefulWidget { final String userEmail; - final Tenant? tenant; - const AlertPage({super.key, required this.userEmail, this.tenant}); + final List alerts; + const AlertPage({super.key, required this.userEmail, required this.alerts}); @override State createState() => AlertPageState(); @@ -24,10 +27,10 @@ class AlertPageState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { + final localeMsg = AppLocalizations.of(context)!; return Scaffold( backgroundColor: const Color.fromARGB(255, 238, 238, 241), - appBar: myAppBar(context, widget.userEmail, - isTenantMode: widget.tenant != null), + appBar: myAppBar(context, widget.userEmail, isTenantMode: false), body: Padding( padding: const EdgeInsets.all(20.0), child: CustomScrollView(slivers: [ @@ -45,7 +48,7 @@ class AlertPageState extends State with TickerProviderStateMixin { Navigator.of(context).push(MaterialPageRoute( builder: (context) => ProjectsPage( userEmail: widget.userEmail, - isTenantMode: widget.tenant != null), + isTenantMode: false), )), icon: Icon( Icons.arrow_back, @@ -53,65 +56,37 @@ class AlertPageState extends State with TickerProviderStateMixin { )), const SizedBox(width: 5), Text( - "Alerts", + localeMsg.myAlerts, style: Theme.of(context).textTheme.headlineLarge, ), ], ), ), Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.only( - left: 15, right: 15, top: 10, bottom: 10), - height: MediaQuery.of(context).size.height - - (isSmallDisplay ? 310 : 160), - width: double.maxFinite, - child: Row( - children: [ - Expanded( - flex: 3, - child: ListView( - children: [ - getListTitle( - "MINOR", - Colors.amber, - "The temperature of device BASIC.A.R1.A02.chassis01 is higher than usual", - 0), - getListTitle( - "HINT", - Colors.grey, - "All nodes of a kubernetes cluster are on the same rack", - 1), - ], - ), - ), - const VerticalDivider( - width: 30, - thickness: 0.5, - color: Colors.grey, - ), - Expanded( - flex: 7, - child: Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Align( - alignment: Alignment.topLeft, - child: SingleChildScrollView( - child: selectedIndex == 0 - ? getAlertText() - : getHintText(), - ), + child: widget.alerts.isEmpty + ? SizedBox( + height: MediaQuery.of(context).size.height > 205 + ? MediaQuery.of(context).size.height - 205 + : MediaQuery.of(context).size.height, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.thumb_up, + size: 50, + color: Colors.grey.shade600, + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + "${AppLocalizations.of(context)!.noAlerts} :)"), ), - ), + ], ), - ], - ), - ), - ], - ), + ), + ) + : alertView(localeMsg), ), ], ), @@ -120,6 +95,91 @@ class AlertPageState extends State with TickerProviderStateMixin { )); } + alertView(AppLocalizations localeMsg) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: + const EdgeInsets.only(left: 15, right: 15, top: 10, bottom: 10), + height: + MediaQuery.of(context).size.height - (isSmallDisplay ? 310 : 160), + width: double.maxFinite, + child: Row( + children: [ + Expanded( + flex: 3, + child: ListView( + children: getListTitles(), + ), + ), + const VerticalDivider( + width: 30, + thickness: 0.5, + color: Colors.grey, + ), + Expanded( + flex: 7, + child: Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Align( + alignment: Alignment.topLeft, + child: ListView(children: [ + getAlertText(selectedIndex), + const SizedBox(height: 20), + Directionality( + textDirection: TextDirection.rtl, + child: Align( + alignment: Alignment.topLeft, + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SelectPage( + project: Project( + "auto", + "", + Namespace.Physical.name, + localeMsg.autoGenerated, + "auto", + false, + false, + false, + [], + [widget.alerts[selectedIndex].id], + [], + isImpact: true), + userEmail: widget.userEmail, + ), + ), + ); + }, + icon: Icon(Icons.arrow_back), + label: Text(localeMsg.goToImpact)), + ), + ) + ]), + ), + ), + ), + ], + ), + ), + ], + ); + } + + List getListTitles() { + List list = []; + int index = 0; + for (var alert in widget.alerts) { + list.add(getListTitle( + alert.type.toUpperCase(), Colors.amber, alert.title, index)); + index++; + } + return list; + } + ListTile getListTitle(String type, Color typeColor, String title, int index) { return ListTile( isThreeLine: true, @@ -160,7 +220,7 @@ class AlertPageState extends State with TickerProviderStateMixin { ); } - getAlertText() { + getAlertText(int index) { return Text.rich( TextSpan( children: [ @@ -171,86 +231,12 @@ class AlertPageState extends State with TickerProviderStateMixin { ), children: [ TextSpan( - text: 'Minor Alert\n', + text: '${widget.alerts[index].type.capitalize()} Alert\n', style: Theme.of(context).textTheme.headlineLarge, ), - const TextSpan(text: '\nThe temperature of device '), - const TextSpan( - text: 'BASIC.A.R1.A02.chassis01', - style: TextStyle(fontWeight: FontWeight.bold)), - const TextSpan( - text: - ' is higher than usual. This could impact the performance of your applications running in a Kubernetes cluster with nodes in this device: "my-frontend-app" and "my-backend-app".\n'), - ...getSubtitle("Details:"), - const TextSpan( - text: - 'The last measurement of temperature for the device in question reads '), - const TextSpan( - text: '64°C', style: TextStyle(fontWeight: FontWeight.bold)), - const TextSpan( - text: - '. The temperature recommendation for a chassis of this type is to not surpass '), - const TextSpan( - text: '55°C', style: TextStyle(fontWeight: FontWeight.bold)), - const TextSpan(text: '.\n'), - ...getSubtitle("Impacted by this alert:"), - getWidgetSpan(" BASIC.A.R1.A02.chassis01", "Physical - Device", - Colors.teal), - const TextSpan(text: '\n'), - ...getSubtitle("May also be impacted:"), - getWidgetSpan(" BASIC.A.R1.A02.chassis01.blade01", - "Physical - Device", Colors.teal), - getWidgetSpan(" BASIC.A.R1.A02.chassis01.blade02", - "Physical - Device", Colors.teal), - getWidgetSpan(" BASIC.A.R1.A02.chassis01.blade03", - "Physical - Device", Colors.teal), - getWidgetSpan(" kubernetes-cluster.my-frontend-app", - "Logical - Application", Colors.deepPurple), - getWidgetSpan(" kubernetes-cluster.my-backend-app", - "Logical - Application", Colors.deepPurple), - ], - ), - ], - ), - ); - } - - getHintText() { - return Text.rich( - TextSpan( - children: [ - TextSpan( - style: const TextStyle( - fontSize: 14.0, - color: Colors.black, - ), - children: [ TextSpan( - text: 'Hint\n', - style: Theme.of(context).textTheme.headlineLarge, - ), - const TextSpan( - text: - '\nAll nodes of a kubernetes cluster are servers from the same rack.\n'), - ...getSubtitle("Details:"), - const TextSpan( - text: - 'The Kubernetes cluster "kubernetes-cluster" has the following devices as its nodes: "chassis01.blade01" , "chassis01.blade02" and "chassis01.blade03". All of these devices are in the same rack "BASIC.A.R1.A02".\n'), - ...getSubtitle("Suggestion:"), - const TextSpan( text: - 'To limit impacts to the cluster and its applications in case of issue with this rack, consider adding a server from a different rack as a node to this cluster.\n'), - ...getSubtitle("Impacted by this hint:"), - getWidgetSpan( - " BASIC.A.R1.A02", "Physical - Device", Colors.teal), - getWidgetSpan(" kubernetes-cluster", "Logical - Application", - Colors.deepPurple), - const TextSpan(text: '\n'), - ...getSubtitle("May also be impacted:"), - getWidgetSpan(" kubernetes-cluster.my-frontend-app", - "Logical - Application", Colors.deepPurple), - getWidgetSpan(" kubernetes-cluster.my-backend-app", - "Logical - Application", Colors.deepPurple), + "\n${widget.alerts[index].title}. ${widget.alerts[index].subtitle}."), ], ), ], diff --git a/APP/lib/pages/impact_page.dart b/APP/lib/pages/impact_page.dart new file mode 100644 index 000000000..decf8ce49 --- /dev/null +++ b/APP/lib/pages/impact_page.dart @@ -0,0 +1,201 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:csv/csv.dart'; +import 'package:ogree_app/common/definitions.dart'; +import 'package:ogree_app/common/snackbar.dart'; +import 'package:ogree_app/common/theme.dart'; +import 'package:ogree_app/models/alert.dart'; +import 'package:flutter/material.dart'; +import 'package:ogree_app/common/api_backend.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:ogree_app/widgets/impact/impact_view.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:universal_html/html.dart' as html; + +class ImpactPage extends StatefulWidget { + List selectedObjects; + + ImpactPage({ + super.key, + required this.selectedObjects, + }); + + @override + State createState() => _ImpactPageState(); +} + +class _ImpactPageState extends State { + List columnLabels = []; + bool? shouldMarkAll; + + @override + Widget build(BuildContext context) { + final localeMsg = AppLocalizations.of(context)!; + bool isSmallDisplay = IsSmallDisplay(MediaQuery.of(context).size.width); + + return SizedBox( + height: MediaQuery.of(context).size.height > 205 + ? MediaQuery.of(context).size.height - 220 + : MediaQuery.of(context).size.height, + child: Card( + margin: const EdgeInsets.all(0.1), + child: ListView.builder( + itemCount: widget.selectedObjects.length + 1, + itemBuilder: (BuildContext context, int index) { + index = index - 1; + if (index == -1) { + if (widget.selectedObjects.length == 1) { + return SizedBox(height: 6); + } + return Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Tooltip( + message: localeMsg.markAllTip, + child: TextButton.icon( + onPressed: () => markAll(true, localeMsg), + label: Text(localeMsg.markAllMaintenance), + icon: Icon(Icons.check_circle)), + ), + Padding( + padding: EdgeInsets.only(left: 2), + child: Tooltip( + message: localeMsg.unmarkAllTip, + child: TextButton.icon( + onPressed: () => markAll(false, localeMsg), + label: Text(localeMsg.unmarkAll), + icon: Icon(Icons.check_circle_outline)), + ), + ), + Padding( + padding: EdgeInsets.only(left: 2), + child: Tooltip( + message: localeMsg.downloadAllTip, + child: TextButton.icon( + onPressed: () => downloadAll(), + label: Text(localeMsg.downloadAll), + icon: Icon(Icons.download)), + ), + ), + ], + ), + const Divider( + thickness: 0.5, + indent: 20, + endIndent: 25, + ), + ], + ), + ); + } + final String option = widget.selectedObjects.elementAt(index); + return ImpactView( + rootId: option, + receivedMarkAll: shouldMarkAll, + ); + }, + ), + ), + ); + } + + markAll(bool isMark, AppLocalizations localeMsg) async { + final messenger = ScaffoldMessenger.of(context); + if (!isMark) { + // unmark + for (var obj in widget.selectedObjects) { + var result = await deleteObject(obj, "alert"); + switch (result) { + case Success(): + break; + case Failure(exception: final exception): + showSnackBar(messenger, exception.toString(), isError: true); + } + } + showSnackBar( + messenger, + localeMsg.allUnmarked, + ); + setState(() { + shouldMarkAll = false; + }); + } else { + for (var obj in widget.selectedObjects) { + var alert = Alert( + obj, "minor", "$obj ${localeMsg.isMarked}", localeMsg.checkImpact); + + var result = await createAlert(alert); + switch (result) { + case Success(): + break; + case Failure(exception: final exception): + showSnackBar(messenger, exception.toString(), isError: true); + return; + } + } + showSnackBar(messenger, localeMsg.allMarked, isSuccess: true); + setState(() { + shouldMarkAll = true; + }); + } + } + + downloadAll() async { + List> rows = []; + final messenger = ScaffoldMessenger.of(context); + for (var obj in widget.selectedObjects) { + List selectedCategories = []; + List selectedPtypes = []; + List selectedVtypes = []; + if ('.'.allMatches(obj).length > 2) { + // default for racks and under + selectedPtypes = ["blade"]; + selectedVtypes = ["application", "cluster", "vm"]; + } + final result = await fetchObjectImpact( + obj, selectedCategories, selectedPtypes, selectedVtypes); + switch (result) { + case Success(value: final value): + rows.add(["target", obj]); + for (var type in ["direct", "indirect"]) { + var direct = (Map.from(value[type])).keys.toList(); + direct.insertAll(0, [type]); + rows.add(direct); + } + break; + case Failure(exception: final exception): + showSnackBar(messenger, exception.toString(), isError: true); + return; + } + } + + // Prepare the file + String csv = const ListToCsvConverter().convert(rows); + final bytes = utf8.encode(csv); + if (kIsWeb) { + // If web, use html to download csv + html.AnchorElement( + href: 'data:application/octet-stream;base64,${base64Encode(bytes)}') + ..setAttribute("download", "impact-report.csv") + ..click(); + } else { + // Save to local filesystem + var path = (await getApplicationDocumentsDirectory()).path; + var fileName = '$path/impact-report.csv'; + var file = File(fileName); + for (var i = 1; await file.exists(); i++) { + fileName = '$path/impact-report ($i).csv'; + file = File(fileName); + } + file.writeAsBytes(bytes, flush: true).then((value) => showSnackBar( + ScaffoldMessenger.of(context), + "${AppLocalizations.of(context)!.fileSavedTo} $fileName")); + } + } +} diff --git a/APP/lib/pages/projects_page.dart b/APP/lib/pages/projects_page.dart index 799209bf6..fd12dc0db 100644 --- a/APP/lib/pages/projects_page.dart +++ b/APP/lib/pages/projects_page.dart @@ -4,6 +4,7 @@ import 'package:ogree_app/common/appbar.dart'; import 'package:ogree_app/common/definitions.dart'; import 'package:ogree_app/common/popup_dialog.dart'; import 'package:ogree_app/common/snackbar.dart'; +import 'package:ogree_app/models/alert.dart'; import 'package:ogree_app/models/container.dart'; import 'package:ogree_app/models/netbox.dart'; import 'package:ogree_app/models/project.dart'; @@ -40,10 +41,12 @@ class _ProjectsPageState extends State { bool _hasNautobot = false; bool _hasOpenDcim = false; bool _gotData = false; + bool _gotAlerts = false; + List _alerts = []; @override Widget build(BuildContext context) { - _isSmallDisplay = MediaQuery.of(context).size.width < 600; + _isSmallDisplay = MediaQuery.of(context).size.width < 720; final localeMsg = AppLocalizations.of(context)!; return Scaffold( appBar: myAppBar(context, widget.userEmail, @@ -74,6 +77,10 @@ class _ProjectsPageState extends State { style: Theme.of(context).textTheme.headlineLarge), Row( children: [ + Padding( + padding: const EdgeInsets.only(right: 10.0, bottom: 10), + child: impactViewButton(), + ), Padding( padding: const EdgeInsets.only(right: 10.0, bottom: 10), child: createProjectButton(), @@ -190,6 +197,21 @@ class _ProjectsPageState extends State { } } + getAlerts() async { + final messenger = ScaffoldMessenger.of(context); + final result = await fetchAlerts(); + switch (result) { + case Success(value: final value): + _alerts = value; + setState(() { + _gotAlerts = true; + }); + case Failure(exception: final exception): + showSnackBar(messenger, exception.toString(), isError: true); + _projects = []; + } + } + createProjectButton() { final localeMsg = AppLocalizations.of(context)!; return ElevatedButton( @@ -376,72 +398,110 @@ class _ProjectsPageState extends State { return cards; } -// DEMO ONLY + impactViewButton() { + final localeMsg = AppLocalizations.of(context)!; + return ElevatedButton( + style: ElevatedButton.styleFrom( + // backgroundColor: Colors.blue.shade600, + // foregroundColor: Colors.white, + ), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SelectPage( + isImpact: true, + userEmail: widget.userEmail, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.only( + top: 8, bottom: 8, right: _isSmallDisplay ? 0 : 10), + child: const Icon(Icons.settings_suggest), + ), + _isSmallDisplay ? Container() : Text(localeMsg.impactAnalysis), + ], + ), + ); + } + List getAlertDemoWidgets(AppLocalizations localeMsg) { - if (!isDemo) { - return []; - } return [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(localeMsg.myAlerts, style: Theme.of(context).textTheme.headlineLarge), - Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 10.0, bottom: 10), - child: analysisViewButton(), - ), - ], + Padding( + padding: const EdgeInsets.only(right: 10.0, bottom: 10), + child: alertViewButton(), ), ], ), const SizedBox(height: 20), Padding( padding: const EdgeInsets.only(right: 20.0), - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => AlertPage(userEmail: widget.userEmail), - ), - ), - child: MaterialBanner( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), - content: _isSmallDisplay - ? Text(localeMsg.oneAlert) - : RichText( - text: TextSpan( - style: const TextStyle( - fontSize: 14.0, - color: Colors.black, - ), - children: [ - TextSpan(text: localeMsg.temperatureAlert1), - const TextSpan( - text: 'BASIC.A.R1.A02.chassis01', - style: TextStyle(fontWeight: FontWeight.bold)), - TextSpan(text: localeMsg.temperatureAlert2), - ], + child: FutureBuilder( + future: _gotAlerts ? null : getAlerts(), + builder: (context, _) { + if (!_gotAlerts) { + return const Center(child: CircularProgressIndicator()); + } + return InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AlertPage( + userEmail: widget.userEmail, + alerts: _alerts, ), ), - leading: const Icon(Icons.info), - backgroundColor: Colors.amber.shade100, - dividerColor: Colors.transparent, - actions: const [ - TextButton( - onPressed: null, - child: Text(''), - ), - ], - ), - ), + ), + child: MaterialBanner( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 5), + content: _isSmallDisplay + ? Text(localeMsg.oneAlert) + : (_alerts.isEmpty + ? Text("${localeMsg.noAlerts} :)") + : Text(alertsToString(localeMsg))), + leading: const Icon(Icons.info), + backgroundColor: _alerts.isEmpty + ? Colors.grey.shade200 + : Colors.amber.shade100, + dividerColor: Colors.transparent, + actions: const [ + TextButton( + onPressed: null, + child: Text(''), + ), + ], + ), + ); + }), ), const SizedBox(height: 30), ]; } - analysisViewButton() { + String alertsToString(AppLocalizations localeMsg) { + var alertStr = ""; + if (_alerts.length > 1) { + for (var alert in _alerts) { + alertStr = "$alertStr${alert.title.split(" ").first}, "; + } + alertStr = alertStr.substring(0, alertStr.length - 2); + alertStr = "${localeMsg.areMarkedMaintenance} $alertStr."; + } else { + for (var alert in _alerts) { + alertStr = "${alert.title.split(" ").first} ${localeMsg.isMarked}."; + } + } + return alertStr; + } + + alertViewButton() { final localeMsg = AppLocalizations.of(context)!; return ElevatedButton( style: ElevatedButton.styleFrom( @@ -455,7 +515,10 @@ class _ProjectsPageState extends State { } else { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => AlertPage(userEmail: widget.userEmail), + builder: (context) => AlertPage( + userEmail: widget.userEmail, + alerts: _alerts, + ), ), ); } diff --git a/APP/lib/pages/select_page.dart b/APP/lib/pages/select_page.dart index e87c56f4d..833e1a12b 100644 --- a/APP/lib/pages/select_page.dart +++ b/APP/lib/pages/select_page.dart @@ -3,6 +3,7 @@ import 'package:intl/intl.dart'; import 'package:ogree_app/common/api_backend.dart'; import 'package:ogree_app/common/appbar.dart'; import 'package:ogree_app/common/definitions.dart'; +import 'package:ogree_app/pages/impact_page.dart'; import 'package:ogree_app/widgets/projects/project_popup.dart'; import 'package:ogree_app/common/snackbar.dart'; import 'package:ogree_app/models/project.dart'; @@ -18,7 +19,12 @@ enum Steps { date, namespace, objects, result } class SelectPage extends StatefulWidget { final String userEmail; Project? project; - SelectPage({super.key, this.project, required this.userEmail}); + bool isImpact; + SelectPage( + {super.key, + this.project, + required this.userEmail, + this.isImpact = false}); @override State createState() => SelectPageState(); @@ -53,13 +59,17 @@ class SelectPageState extends State with TickerProviderStateMixin { @override void initState() { if (widget.project != null) { + // select date and namespace from project _selectedDate = widget.project!.dateRange; _selectedNamespace = Namespace.values.firstWhere( (e) => e.toString() == 'Namespace.${widget.project!.namespace}'); _selectedAttrs = widget.project!.attributes; + // select objects for (var obj in widget.project!.objects) { _selectedObjects[obj] = true; } + // adjust step + widget.isImpact = widget.project!.isImpact; if (widget.project!.lastUpdate == "AUTO") { // auto project _loadObjects = true; @@ -68,6 +78,10 @@ class SelectPageState extends State with TickerProviderStateMixin { } else { _currentStep = Steps.result; } + } else if (widget.isImpact) { + _selectedNamespace = Namespace.Physical; + _loadObjects = true; + _currentStep = Steps.objects; } super.initState(); } @@ -87,7 +101,7 @@ class SelectPageState extends State with TickerProviderStateMixin { // onStepTapped: (step) => tapped(step), controlsBuilder: (context, _) { return Padding( - padding: const EdgeInsets.only(top: 10), + padding: const EdgeInsets.only(top: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, @@ -141,7 +155,7 @@ class SelectPageState extends State with TickerProviderStateMixin { : null, content: SizedBox( height: MediaQuery.of(context).size.height > 205 - ? MediaQuery.of(context).size.height - 205 + ? MediaQuery.of(context).size.height - 220 : MediaQuery.of(context).size.height, child: SelectObjects( dateRange: _selectedDate, @@ -155,12 +169,16 @@ class SelectPageState extends State with TickerProviderStateMixin { Step( title: Text(localeMsg.result, style: const TextStyle(fontSize: 14)), content: _currentStep == Steps.result - ? ResultsPage( - dateRange: _selectedDate, - selectedAttrs: _selectedAttrs, - selectedObjects: _selectedObjects.keys.toList(), - namespace: _selectedNamespace.name, - ) + ? (widget.isImpact + ? ImpactPage( + selectedObjects: _selectedObjects.keys.toList(), + ) + : ResultsPage( + dateRange: _selectedDate, + selectedAttrs: _selectedAttrs, + selectedObjects: _selectedObjects.keys.toList(), + namespace: _selectedNamespace.name, + )) : const Center(child: CircularProgressIndicator()), isActive: _currentStep.index >= Steps.date.index, state: _currentStep.index >= Steps.result.index @@ -172,7 +190,7 @@ class SelectPageState extends State with TickerProviderStateMixin { ); } - continued() { + continued() async { final localeMsg = AppLocalizations.of(context)!; _loadObjects = false; switch (_currentStep) { @@ -217,7 +235,8 @@ class SelectPageState extends State with TickerProviderStateMixin { false, _selectedAttrs, _selectedObjects.keys.toList(), - [widget.userEmail]); + [widget.userEmail], + isImpact: widget.isImpact); } showProjectDialog( @@ -239,6 +258,13 @@ class SelectPageState extends State with TickerProviderStateMixin { case (Steps.namespace): setState(() => _currentStep = Steps.date); case (Steps.objects): + if (widget.isImpact) { + showSnackBar( + ScaffoldMessenger.of(context), + AppLocalizations.of(context)!.onlyPredefinedWarning, + ); + return; + } setState(() => _currentStep = Steps.namespace); case (Steps.result): _loadObjects = true; @@ -251,6 +277,7 @@ class SelectPageState extends State with TickerProviderStateMixin { Result result; project.name = userInput; final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); if (isCreate) { result = await createProject(project); } else { @@ -258,7 +285,7 @@ class SelectPageState extends State with TickerProviderStateMixin { } switch (result) { case Success(): - Navigator.of(context).push( + navigator.push( MaterialPageRoute( builder: (context) => ProjectsPage(userEmail: widget.userEmail, isTenantMode: false), diff --git a/APP/lib/widgets/common/form_field.dart b/APP/lib/widgets/common/form_field.dart index c67221a0b..aedd5dd28 100644 --- a/APP/lib/widgets/common/form_field.dart +++ b/APP/lib/widgets/common/form_field.dart @@ -67,6 +67,7 @@ class _CustomFormFieldState extends State { Widget build(BuildContext context) { final localeMsg = AppLocalizations.of(context)!; isSmallDisplay = IsSmallDisplay(MediaQuery.of(context).size.width); + final FocusNode focusNode = FocusNode(); Widget? iconWidget; if (widget.checkListController != null) { iconWidget = PopupMenuButton( @@ -128,6 +129,7 @@ class _CustomFormFieldState extends State { child: Tooltip( message: widget.tipStr, child: TextFormField( + focusNode: focusNode, obscureText: widget.isObscure, readOnly: widget.isReadOnly, controller: widget.isColor @@ -150,6 +152,7 @@ class _CustomFormFieldState extends State { validator: (text) { if (widget.shouldValidate) { if (text == null || text.isEmpty) { + focusNode.requestFocus(); return localeMsg.mandatoryField; } if (widget.isColor && text.length < 6) { diff --git a/APP/lib/widgets/impact/impact_graph_view.dart b/APP/lib/widgets/impact/impact_graph_view.dart new file mode 100644 index 000000000..9960e4cc4 --- /dev/null +++ b/APP/lib/widgets/impact/impact_graph_view.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:graphview/GraphView.dart'; +import 'package:ogree_app/common/api_backend.dart'; +import 'package:ogree_app/common/definitions.dart'; +import 'package:ogree_app/common/snackbar.dart'; +import 'package:ogree_app/common/theme.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class ImpactGraphView extends StatefulWidget { + String rootId; + Map data; + ImpactGraphView(this.rootId, this.data); + @override + _ImpactGraphViewState createState() => _ImpactGraphViewState(); +} + +class _ImpactGraphViewState extends State { + bool loaded = false; + Map idCategory = {}; + + final Graph graph = Graph(); + + SugiyamaConfiguration builder = SugiyamaConfiguration() + ..bendPointShape = CurvedBendPointShape(curveLength: 10); + + @override + void initState() { + super.initState(); + + builder + ..nodeSeparation = (25) + ..levelSeparation = (35) + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM; + } + + @override + Widget build(BuildContext context) { + graph.addNode(Node.Id(widget.rootId)); + addToGraph(widget.data["direct"]); + addToGraph(widget.data["indirect"]); + addIndirectRelationsToGraph(widget.data["relations"]); + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + constraints: const BoxConstraints(maxHeight: 400, maxWidth: 1000), + decoration: BoxDecoration( + border: Border.all(color: Colors.lightBlue.shade100), + borderRadius: BorderRadius.all(Radius.circular(15.0)), + ), + child: InteractiveViewer( + alignment: Alignment.center, + constrained: true, + boundaryMargin: const EdgeInsets.all(double.infinity), + minScale: 0.0001, + maxScale: 10.6, + child: OverflowBox( + alignment: Alignment.center, + minWidth: 0.0, + minHeight: 0.0, + maxWidth: double.infinity, + maxHeight: double.infinity, + child: GraphView( + graph: graph, + algorithm: SugiyamaAlgorithm(builder), + paint: Paint() + ..color = Colors.blue + ..strokeWidth = 1 + ..style = PaintingStyle.stroke, + builder: (Node node) { + var a = node.key!.value as String?; + return rectangleWidget(a!); + }, + ), + )), + ), + ), + ); + } + + Widget rectangleWidget(String a) { + return Tooltip( + message: a, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: idCategory[a] == "virtual_obj" + ? Colors.purple[100]! + : Colors.blue[100]!, + spreadRadius: 1), + ], + ), + child: Text('${a.split(".").last}')), + ); + } + + addToGraph(Map value) { + for (var key in value.keys) { + var node = Node.Id(key); + if (!graph.contains(node: node)) { + graph.addNode(node); + while (key.contains(".") && + key != widget.rootId && + !graph.hasPredecessor(node)) { + var predecessorId = key.substring(0, key.lastIndexOf(".")); + var predecessor = Node.Id(predecessorId); + graph.addEdge(predecessor, node); + node = predecessor; + key = predecessorId; + } + } + } + } + + addIndirectRelationsToGraph(Map value) { + for (var key in value.keys) { + final node = Node.Id(key); + + if (!graph.contains(node: node)) { + graph.addNode(node); + } + for (var childId in value[key]) { + graph.addEdge(Node.Id(childId), node, + paint: Paint()..color = Colors.purple); + } + } + } +} diff --git a/APP/lib/widgets/impact/impact_popup.dart b/APP/lib/widgets/impact/impact_popup.dart new file mode 100644 index 000000000..0ba3250a5 --- /dev/null +++ b/APP/lib/widgets/impact/impact_popup.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:ogree_app/common/theme.dart'; +import 'package:ogree_app/widgets/select_objects/object_popup.dart'; + +class ImpactOptionsPopup extends StatefulWidget { + List selectedCategories; + List selectedPtypes; + List selectedVtypes; + final Function(List selectedCategories, List selectedPtypes, + List selectedVtypes) parentCallback; + ImpactOptionsPopup( + {super.key, + required this.selectedCategories, + required this.selectedPtypes, + required this.selectedVtypes, + required this.parentCallback}); + + @override + State createState() => _ImpactOptionsPopupState(); +} + +class _ImpactOptionsPopupState extends State { + bool _isSmallDisplay = false; + List ptypes = ["blade", "chassis", "disk", "processor"]; + List vtypes = ["application", "cluster", "storage", "vm"]; + late List selectedCategories; + late List selectedPtypes; + late List selectedVtypes; + + @override + void initState() { + selectedCategories = widget.selectedCategories; + selectedPtypes = widget.selectedPtypes; + selectedVtypes = widget.selectedVtypes; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final localeMsg = AppLocalizations.of(context)!; + _isSmallDisplay = IsSmallDisplay(MediaQuery.of(context).size.width); + + return Center( + child: Container( + height: 330, + width: 680, + constraints: const BoxConstraints(maxHeight: 430), + margin: const EdgeInsets.symmetric(horizontal: 20), + decoration: PopupDecoration, + child: Padding( + padding: const EdgeInsets.fromLTRB(40, 20, 40, 15), + child: ScaffoldMessenger( + child: Builder( + builder: (context) => Scaffold( + backgroundColor: Colors.white, + body: Column( + children: [ + Center( + child: Text( + localeMsg.indirectOptions, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + const SizedBox(height: 15), + const SizedBox(height: 10), + SizedBox( + height: 200, + child: Wrap( + children: [ + Column( + children: [ + const Text( + "Category", + ), + SizedBox( + height: 200, + width: 200, + child: ListView.builder( + itemCount: PhyCategories.values.length, + itemBuilder: + (BuildContext context, int index) { + var name = PhyCategories.values[index].name; + return CheckboxListTile( + controlAffinity: + ListTileControlAffinity.leading, + dense: true, + value: selectedCategories.contains( + PhyCategories.values[index].name), + onChanged: (bool? selected) { + setState(() { + if (selectedCategories.contains( + PhyCategories + .values[index].name)) { + selectedCategories.remove( + PhyCategories + .values[index].name); + } else { + selectedCategories.add(PhyCategories + .values[index].name); + } + }); + }, + title: Text(name), + ); + }), + ), + ], + ), + Column( + children: [ + const Text("Physical Type"), + SizedBox( + height: 200, + width: 200, + child: ListView.builder( + itemCount: ptypes.length, + itemBuilder: + (BuildContext context, int index) { + return CheckboxListTile( + controlAffinity: + ListTileControlAffinity.leading, + dense: true, + value: selectedPtypes + .contains(ptypes[index]), + onChanged: (bool? selected) { + if (selectedPtypes + .contains(ptypes[index])) { + selectedPtypes.remove(ptypes[index]); + } else { + selectedPtypes.add(ptypes[index]); + } + setState(() {}); + }, + title: Text(ptypes[index]), + ); + }), + ), + ], + ), + Column( + children: [ + const Text("Virtual Type"), + SizedBox( + height: 200, + width: 200, + child: ListView.builder( + itemCount: vtypes.length, + itemBuilder: + (BuildContext context, int index) { + return CheckboxListTile( + controlAffinity: + ListTileControlAffinity.leading, + dense: true, + value: selectedVtypes + .contains(vtypes[index]), + onChanged: (bool? selected) { + if (selectedVtypes + .contains(vtypes[index])) { + selectedVtypes.remove(vtypes[index]); + } else { + selectedVtypes.add(vtypes[index]); + } + setState(() {}); + }, + title: Text(vtypes[index]), + ); + }), + ), + ], + ) + ], + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton.icon( + onPressed: () { + widget.parentCallback(selectedCategories, + selectedPtypes, selectedVtypes); + Navigator.of(context).pop(); + }, + label: const Text("OK"), + icon: const Icon(Icons.thumb_up, size: 16)) + ], + ) + ], + ), + ), + )), + ), + ), + ); + } +} diff --git a/APP/lib/widgets/impact/impact_view.dart b/APP/lib/widgets/impact/impact_view.dart new file mode 100644 index 000000000..b96240c56 --- /dev/null +++ b/APP/lib/widgets/impact/impact_view.dart @@ -0,0 +1,371 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:csv/csv.dart'; +import 'package:flutter/material.dart'; +import 'package:ogree_app/common/api_backend.dart'; +import 'package:ogree_app/common/definitions.dart'; +import 'package:ogree_app/common/popup_dialog.dart'; +import 'package:ogree_app/common/snackbar.dart'; +import 'package:ogree_app/common/theme.dart'; +import 'package:ogree_app/models/alert.dart'; +import 'package:ogree_app/widgets/impact/impact_graph_view.dart'; +import 'package:ogree_app/widgets/impact/impact_popup.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:universal_html/html.dart' as html; + +class ImpactView extends StatefulWidget { + String rootId; + bool? receivedMarkAll; + ImpactView({required this.rootId, required this.receivedMarkAll}); + + @override + State createState() => _ImpactViewState(); +} + +class _ImpactViewState extends State + with AutomaticKeepAliveClientMixin { + Map _data = {}; + List selectedCategories = []; + List selectedPtypes = []; + List selectedVtypes = []; + bool isMarkedForMaintenance = false; + bool? lastReceivedMarkAll; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + if ('.'.allMatches(widget.rootId).length > 2) { + // default for racks and under + selectedPtypes = ["blade"]; + selectedVtypes = ["application", "cluster", "vm"]; + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + final localeMsg = AppLocalizations.of(context)!; + bool isSmallDisplay = IsSmallDisplay(MediaQuery.of(context).size.width); + + if (lastReceivedMarkAll != widget.receivedMarkAll) { + isMarkedForMaintenance = widget.receivedMarkAll!; + lastReceivedMarkAll = widget.receivedMarkAll; + } + + return FutureBuilder( + future: _data.isEmpty ? getData() : null, + builder: (context, _) { + if (_data.isEmpty) { + return SizedBox( + height: MediaQuery.of(context).size.height > 205 + ? MediaQuery.of(context).size.height - 220 + : MediaQuery.of(context).size.height, + child: const Card( + margin: EdgeInsets.all(0.1), + child: Center(child: CircularProgressIndicator()), + ), + ); + } + return objectImpactView(widget.rootId, localeMsg); + }); + } + + getData() async { + final messenger = ScaffoldMessenger.of(context); + final result = await fetchObjectImpact( + widget.rootId, selectedCategories, selectedPtypes, selectedVtypes); + switch (result) { + case Success(value: final value): + _data = value; + case Failure(exception: final exception): + showSnackBar(messenger, exception.toString(), isError: true); + _data = {}; + return; + } + // is it marked for maintenance? + final alertResult = await fetchAlert(widget.rootId); + switch (alertResult) { + case Success(): + isMarkedForMaintenance = true; + case Failure(): + return; + } + } + + objectImpactView(String rootId, AppLocalizations localeMsg) { + print(widget.receivedMarkAll); + return Column( + children: [ + const SizedBox(height: 10), + Row( + children: [ + Padding( + padding: EdgeInsets.only(left: 4), + child: SizedBox( + width: 230, + child: TextButton.icon( + onPressed: () => markForMaintenance(localeMsg), + label: Text(isMarkedForMaintenance + ? localeMsg.markedMaintenance + : localeMsg.markMaintenance), + icon: isMarkedForMaintenance + ? Icon(Icons.check_circle) + : Icon(Icons.check_circle_outline)), + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(right: 150), + child: getWidgetSpan(rootId, "target", size: 18), + ), + ), + IconButton(onPressed: () => getCSV(), icon: Icon(Icons.download)), + Padding( + padding: EdgeInsets.only(right: 10), + child: IconButton( + onPressed: () => showCustomPopup( + context, + ImpactOptionsPopup( + selectedCategories: selectedCategories, + selectedPtypes: selectedPtypes, + selectedVtypes: selectedVtypes, + parentCallback: changeImpactFilters, + ), + isDismissible: true), + icon: Icon(Icons.edit)), + ), + ], + ), + + Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + localeMsg.impacts, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Row( + children: [ + Text( + localeMsg.directly.toUpperCase(), + style: TextStyle( + fontWeight: FontWeight.w900, fontSize: 17), + ), + Padding( + padding: EdgeInsets.only(left: 6), + child: Tooltip( + message: localeMsg.directTip, + verticalOffset: 13, + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + textStyle: TextStyle( + fontSize: 13, + color: Colors.white, + ), + padding: EdgeInsets.all(13), + child: Icon(Icons.info_outline_rounded, + color: Colors.blueAccent), + ), + ), + ], + ), + SizedBox(height: 15), + ...listImpacted(_data["direct"]), + ], + ), + Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Row( + children: [ + Text( + localeMsg.indirectly.toUpperCase(), + style: TextStyle( + fontWeight: FontWeight.w900, fontSize: 17), + ), + Padding( + padding: EdgeInsets.only(left: 6), + child: Tooltip( + message: localeMsg.indirectTip, + verticalOffset: 13, + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + textStyle: TextStyle( + fontSize: 13, + color: Colors.white, + ), + padding: EdgeInsets.all(13), + child: Icon(Icons.info_outline_rounded, + color: Colors.blueAccent), + ), + ), + ], + ), + SizedBox(height: 15), + ...listImpacted(_data["indirect"]), + ], + ) + ], + ), + ), + SizedBox(height: 15), + Center( + child: Text( + localeMsg.graphView, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + SizedBox(height: 15), + // ImpactGraphView("BASIC.A.R1.A01.chT"), + ImpactGraphView(rootId, _data), + SizedBox(height: 10), + ], + ); + } + + getCSV() async { + // Prepare data + List> rows = [ + ["target", widget.rootId] + ]; + for (var type in ["direct", "indirect"]) { + var direct = (Map.from(_data[type])).keys.toList(); + direct.insertAll(0, [type]); + rows.add(direct); + } + + // Prepare the file + String csv = const ListToCsvConverter().convert(rows); + final bytes = utf8.encode(csv); + if (kIsWeb) { + // If web, use html to download csv + html.AnchorElement( + href: 'data:application/octet-stream;base64,${base64Encode(bytes)}') + ..setAttribute("download", "impact-report.csv") + ..click(); + } else { + // Save to local filesystem + var path = (await getApplicationDocumentsDirectory()).path; + var fileName = '$path/impact-report.csv'; + var file = File(fileName); + for (var i = 1; await file.exists(); i++) { + fileName = '$path/impact-report ($i).csv'; + file = File(fileName); + } + file.writeAsBytes(bytes, flush: true).then((value) => showSnackBar( + ScaffoldMessenger.of(context), + "${AppLocalizations.of(context)!.fileSavedTo} $fileName")); + } + } + + markForMaintenance(AppLocalizations localeMsg) async { + final messenger = ScaffoldMessenger.of(context); + if (isMarkedForMaintenance) { + // unmark + var result = await deleteObject(widget.rootId, "alert"); + switch (result) { + case Success(): + showSnackBar( + messenger, + "${widget.rootId} ${localeMsg.isUnmarked}", + ); + setState(() { + isMarkedForMaintenance = false; + }); + case Failure(exception: final exception): + showSnackBar(messenger, exception.toString(), isError: true); + } + } else { + var alert = Alert(widget.rootId, "minor", + "${widget.rootId} ${localeMsg.isMarked}", localeMsg.checkImpact); + + var result = await createAlert(alert); + switch (result) { + case Success(): + showSnackBar(messenger, "${widget.rootId} ${localeMsg.successMarked}", + isSuccess: true); + setState(() { + isMarkedForMaintenance = true; + }); + case Failure(exception: final exception): + showSnackBar(messenger, exception.toString(), isError: true); + } + } + } + + getWidgetSpan(String text, String category, {double size = 14}) { + MaterialColor badgeColor = Colors.blue; + if (category == "device") { + badgeColor = Colors.teal; + } else if (category == "virtual_obj") { + badgeColor = Colors.deepPurple; + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: size + 10, + child: Tooltip( + message: category, + verticalOffset: 13, + child: Badge( + backgroundColor: badgeColor.shade50, + label: Text( + " $text ", + style: TextStyle( + fontSize: size, + fontWeight: FontWeight.bold, + color: badgeColor.shade900), + ), + ), + ), + ), + // Text(text), + ], + ), + ); + } + + List listImpacted(Map objects) { + List listWidgets = []; + for (var objId in objects.keys) { + listWidgets.add(getWidgetSpan(objId, objects[objId]["category"])); + } + return listWidgets; + } + + changeImpactFilters( + List categories, List ptypes, List vtypes) async { + selectedCategories = categories; + selectedPtypes = ptypes; + selectedVtypes = vtypes; + setState(() { + _data = {}; + }); + await getData(); + } +} diff --git a/APP/lib/widgets/login/login_card.dart b/APP/lib/widgets/login/login_card.dart index 655206b7c..768d313c4 100644 --- a/APP/lib/widgets/login/login_card.dart +++ b/APP/lib/widgets/login/login_card.dart @@ -33,7 +33,7 @@ class _LoginCardState extends State { child: Form( key: _formKey, child: Container( - constraints: const BoxConstraints(maxWidth: 550, maxHeight: 515), + constraints: const BoxConstraints(maxWidth: 550, maxHeight: 520), padding: EdgeInsets.only( right: isSmallDisplay ? 45 : 100, left: isSmallDisplay ? 45 : 100, diff --git a/APP/lib/widgets/projects/project_card.dart b/APP/lib/widgets/projects/project_card.dart index c00f8899a..af51b5cbc 100644 --- a/APP/lib/widgets/projects/project_card.dart +++ b/APP/lib/widgets/projects/project_card.dart @@ -40,7 +40,7 @@ class ProjectCard extends StatelessWidget { deleteProjectCallback(String projectId, Function? parentCallback) async { final messenger = ScaffoldMessenger.of(context); - var result = await deleteProject(projectId); + var result = await deleteObject(projectId, "project"); switch (result) { case Success(): parentCallback!(); @@ -66,8 +66,21 @@ class ProjectCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + project.isImpact + ? Padding( + padding: const EdgeInsets.only(right: 4), + child: Tooltip( + message: localeMsg.impactAnalysis, + child: Icon( + Icons.settings_suggest, + size: 14, + color: Colors.black, + ), + ), + ) + : Container(), SizedBox( - width: 170, + width: 160, child: Text(project.name, overflow: TextOverflow.clip, style: const TextStyle( diff --git a/APP/lib/widgets/select_objects/settings_view/_advanced_find_field.dart b/APP/lib/widgets/select_objects/settings_view/_advanced_find_field.dart index d4fb8bca2..5e9114aa5 100644 --- a/APP/lib/widgets/select_objects/settings_view/_advanced_find_field.dart +++ b/APP/lib/widgets/select_objects/settings_view/_advanced_find_field.dart @@ -53,6 +53,59 @@ class _AdvancedFindFieldState extends State<_AdvancedFindField> { ); } + Future submittedAsFilter() async { + final searchExpression = controller.text.trim(); + final appController = TreeAppController.of(context); + final localeMsg = AppLocalizations.of(context)!; + final messenger = ScaffoldMessenger.of(context); + List ids; + + if (searchExpression.isEmpty) { + appController.filterTreeById([]); + showSnackBar( + messenger, + 'Filter cleared', + duration: const Duration(seconds: 3), + isSuccess: true, + ); + return; + } + + var result = await fetchWithComplexFilter( + searchExpression, widget.namespace, localeMsg); + switch (result) { + case Success(value: final foundObjs): + print(foundObjs); + ids = getIdsFromObjects(foundObjs); + case Failure(exception: final exception): + showSnackBar(messenger, exception.toString(), isError: true); + return; + } + + if (ids.isEmpty) { + showSnackBar( + messenger, + '${localeMsg.noNodeFound} $searchExpression', + duration: const Duration(seconds: 3), + ); + } else { + showSnackBar( + messenger, + '${localeMsg.xNodesFound(ids.length)} $searchExpression', + isSuccess: true, + ); + appController.filterTreeById(ids); + } + } + + List getIdsFromObjects(List> foundObjs) { + List ids = []; + for (var obj in foundObjs) { + ids.add(obj["id"] as String); + } + return ids; + } + Future submitted() async { final searchExpression = controller.text.trim(); final appController = TreeAppController.of(context); diff --git a/APP/lib/widgets/select_objects/settings_view/tree_filter.dart b/APP/lib/widgets/select_objects/settings_view/tree_filter.dart index 85a67c6cf..586127da7 100644 --- a/APP/lib/widgets/select_objects/settings_view/tree_filter.dart +++ b/APP/lib/widgets/select_objects/settings_view/tree_filter.dart @@ -79,14 +79,14 @@ class _TreeFilterState extends State { param: key, paramLevel: enumParams[key]!, options: options, - notifyParent: notifyChildSelection, + notifyParent: NotifyChildSelection, showClearFilter: enumParams[key] == 0 ? !isFilterEmpty() : false, ); }).toList()); } // Callback for child to update parent state - void notifyChildSelection({bool isClearAll = false}) { + void NotifyChildSelection({bool isClearAll = false}) { if (isClearAll) { for (var level in _filterLevels.keys) { _filterLevels[level] = []; diff --git a/APP/lib/widgets/select_objects/treeapp_controller.dart b/APP/lib/widgets/select_objects/treeapp_controller.dart index dccdf1d20..9a4d6158e 100644 --- a/APP/lib/widgets/select_objects/treeapp_controller.dart +++ b/APP/lib/widgets/select_objects/treeapp_controller.dart @@ -3,6 +3,7 @@ import 'package:flutter_fancy_tree_view/flutter_fancy_tree_view.dart'; import 'package:ogree_app/common/api_backend.dart'; import 'package:ogree_app/common/definitions.dart'; import 'package:ogree_app/common/theme.dart'; +import 'package:ogree_app/widgets/select_objects/settings_view/tree_filter.dart'; import 'tree_view/tree_node.dart'; @@ -78,6 +79,12 @@ class TreeAppController with ChangeNotifier { } } + deepCopy(Map> source, destination) { + for (var item in source.keys) { + destination[item] = List.from(source[item]!); + } + } + void generateTree(TreeNode parent, Map> data) { final childrenIds = data[parent.id]; if (childrenIds == null) return; @@ -108,19 +115,6 @@ class TreeAppController with ChangeNotifier { if (shouldNotify) notifyListeners(); } - void selectNode(String id) { - select(id); - notifyListeners(); - } - - void deselectNode(String id) { - selectedNodes.remove(id); - notifyListeners(); - } - - void select(String id) => selectedNodes[id] = true; - void deselect(String id) => selectedNodes.remove(id); - void selectAll([bool select = true]) { //treeController.expandAll(); if (select) { @@ -143,6 +137,19 @@ class TreeAppController with ChangeNotifier { notifyListeners(); } + void selectNode(String id) { + select(id); + notifyListeners(); + } + + void deselectNode(String id) { + selectedNodes.remove(id); + notifyListeners(); + } + + void select(String id) => selectedNodes[id] = true; + void deselect(String id) => selectedNodes.remove(id); + void toggleAllFrom(TreeNode node) { if (node.id[0] != starSymbol) { toggleSelection(node.id); @@ -157,9 +164,7 @@ class TreeAppController with ChangeNotifier { void filterTree(String id, int level) { // Deep copy original data Map> filteredData = {}; - for (var item in fetchedData.keys) { - filteredData[item] = List.from(fetchedData[item]!); - } + deepCopy(fetchedData, filteredData); // Add or remove filter if (level < 0) { @@ -196,10 +201,12 @@ class TreeAppController with ChangeNotifier { for (var i = 0; i < filters.length; i++) { var parent = filters[i].substring(0, filters[i].lastIndexOf('.')); //parent - filteredData[parent]!.removeWhere((element) { - return !filters.contains(element); - }); - newList.add(parent); + if (filteredData[parent] != null) { + filteredData[parent]!.removeWhere((element) { + return !filters.contains(element); + }); + newList.add(parent); + } } filters = newList; testLevel--; @@ -212,11 +219,36 @@ class TreeAppController with ChangeNotifier { treeController.rebuild(); } + filterTreeById(List ids) { + Map> filteredData = {}; + if (ids.isEmpty) { + for (var item in fetchedData.keys) { + filteredData[item] = List.from(fetchedData[item]!); + } + print(filteredData); + } else { + filteredData[kRootId] = []; + for (var id in ids) { + filteredData[kRootId]!.add(id); + } + } + + // Regenerate tree + final rootNode = TreeNode(id: kRootId); + generateTree(rootNode, filteredData); + treeController.roots = rootNode.children; + treeController.rebuild(); + } + // Tree Scroll Functionality final nodeHeight = 50.0; late final scrollController = ScrollController(); void scrollTo(TreeNode node) { - final offset = node.depth * nodeHeight; + var offset = node.depth * nodeHeight; + if (node.ancestors.isNotEmpty) { + var parent = node.ancestors.last; + offset = offset + parent.children.toList().indexOf(node) * nodeHeight; + } scrollController.animateTo( offset, duration: const Duration(milliseconds: 500), diff --git a/APP/pubspec.lock b/APP/pubspec.lock index 40c4d5467..90d6a7dd3 100644 --- a/APP/pubspec.lock +++ b/APP/pubspec.lock @@ -669,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" + screenshot: + dependency: "direct main" + description: + name: screenshot + sha256: d448f43130f49bc7eead1b267b3ea0291cb2450f037bb0a0ecce7aa4c65e2aee + url: "https://pub.dev" + source: hosted + version: "2.5.0" shelf: dependency: transitive description: diff --git a/APP/pubspec.yaml b/APP/pubspec.yaml index 83d0eb685..f3289ce19 100644 --- a/APP/pubspec.yaml +++ b/APP/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: flag: ^7.0.0 flutter_inappwebview: ^6.0.0 graphview: ^1.2.0 + screenshot: ^2.5.0 dev_dependencies: mockito: ^5.4.4