From 4b03764e1f4620b7bd27ef183c2abf9408cf1d47 Mon Sep 17 00:00:00 2001 From: Clark Andrianasolo Date: Wed, 20 Dec 2023 12:15:50 +0100 Subject: [PATCH] Fixes #23926: Display group compliance --- .../rudder/rest/data/Compliance.scala | 106 ++++- .../rudder/rest/lift/ComplianceApi.scala | 11 +- .../src/test/resources/api/api_compliance.yml | 69 ++- .../rudder/rest/TestRestFromFileDef.scala | 2 +- .../elm/sources/GroupCompliance/ApiCalls.elm | 64 +++ .../elm/sources/GroupCompliance/DataTypes.elm | 102 +++++ .../main/elm/sources/GroupCompliance/Init.elm | 22 + .../sources/GroupCompliance/JsonDecoder.elm | 118 +++++ .../main/elm/sources/GroupCompliance/View.elm | 36 ++ .../GroupCompliance/ViewNodesCompliance.elm | 90 ++++ .../GroupCompliance/ViewRulesCompliance.elm | 91 ++++ .../elm/sources/GroupCompliance/ViewUtils.elm | 403 ++++++++++++++++++ .../src/main/elm/sources/Groupcompliance.elm | 179 ++++++++ .../bootstrap/liftweb/RudderConfig.scala | 3 + .../rudder/web/components/NodeGroupForm.scala | 82 +++- .../src/main/style/rudder/rudder-groups.css | 15 + .../webapp/secure/nodeManager/groups.html | 1 + .../components/NodeGroupForm.html | 75 ++-- 18 files changed, 1401 insertions(+), 68 deletions(-) create mode 100644 webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ApiCalls.elm create mode 100644 webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/DataTypes.elm create mode 100644 webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/Init.elm create mode 100644 webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/JsonDecoder.elm create mode 100644 webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/View.elm create mode 100644 webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewNodesCompliance.elm create mode 100644 webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewRulesCompliance.elm create mode 100644 webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewUtils.elm create mode 100644 webapp/sources/rudder/rudder-web/src/main/elm/sources/Groupcompliance.elm diff --git a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/data/Compliance.scala b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/data/Compliance.scala index 143253c3d9a..0fa73aa2b2c 100644 --- a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/data/Compliance.scala +++ b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/data/Compliance.scala @@ -128,14 +128,31 @@ final case class ByNodeGroupCompliance( compliance: ComplianceLevel, mode: ComplianceModeName, rules: Seq[ByNodeGroupRuleCompliance], - nodes: Seq[ByNodeNodeCompliance] + nodes: Seq[ByNodeGroupNodeCompliance] +) + +final case class ByNodeGroupNodeCompliance( + id: NodeId, + name: String, + mode: ComplianceModeName, + compliance: ComplianceLevel, + policyMode: Option[PolicyMode], + rules: Seq[ByNodeRuleCompliance] ) final case class ByNodeGroupRuleCompliance( id: RuleId, name: String, compliance: ComplianceLevel, - directives: Seq[ByRuleDirectiveCompliance] + directives: Seq[ByNodeGroupByRuleDirectiveCompliance] +) + +final case class ByNodeGroupByRuleDirectiveCompliance( + id: DirectiveId, + name: String, + compliance: ComplianceLevel, + policyMode: Option[PolicyMode], + components: Seq[ByRuleComponentCompliance] ) final case class ByRuleDirectiveCompliance( @@ -795,7 +812,7 @@ object JsonCompliance { ~ ("mode" -> nodeGroup.mode.name) ~ ("complianceDetails" -> percents(nodeGroup.compliance, precision)) ~ ("rules" -> byRule(nodeGroup.rules, level, precision)) - ~ ("nodes" -> JArray(nodeGroup.nodes.map(_.toJson(level, precision)).toList))) + ~ ("nodes" -> byNode(nodeGroup.nodes, level, precision))) } private[this] def byRule( @@ -815,8 +832,88 @@ object JsonCompliance { } } + private[this] def byNode( + nodes: Seq[ByNodeGroupNodeCompliance], + level: Int, + precision: CompliancePrecision + ): Option[JsonAST.JValue] = { + if (level < 2) None + else { + Some(nodes.map { node => + (("id" -> node.id.value) + ~ ("name" -> node.name) + ~ ("mode" -> node.mode.name) + ~ ("compliance" -> node.compliance.complianceWithoutPending(precision)) + ~ ("policyMode" -> node.policyMode.map(_.name).getOrElse("default")) + ~ ("complianceDetails" -> percents(node.compliance, precision)) + ~ ("rules" -> byNodeRules(node.rules, level, precision))) + }) + } + } + + private[this] def byNodeRules( + rules: Seq[ByNodeRuleCompliance], + level: Int, + precision: CompliancePrecision + ): Option[JsonAST.JValue] = { + if (level < 2) None + else { + Some(rules.map { rule => + ( + ("id" -> rule.id.serialize) + ~ ("name" -> rule.name) + ~ ("compliance" -> rule.compliance.complianceWithoutPending(precision)) + ~ ("complianceDetails" -> percents(rule.compliance, precision)) + ~ ("directives" -> byNodeDirectives(rule.directives, level, precision)) + ) + }) + } + } + + private[this] def byNodeDirectives( + directives: Seq[ByNodeDirectiveCompliance], + level: Int, + precision: CompliancePrecision + ): Option[JsonAST.JValue] = { + if (level < 2) None + else { + Some(directives.map { directive => + ( + ("id" -> directive.id.serialize) + ~ ("name" -> directive.name) + ~ ("compliance" -> directive.compliance.complianceWithoutPending(precision)) + ~ ("complianceDetails" -> percents(directive.compliance, precision)) + ~ ("components" -> byNodeComponents(directive.components, level, precision)) + ) + }) + } + } + + private[this] def byNodeComponents( + comps: List[ComponentStatusReport], + level: Int, + precision: CompliancePrecision + ): Option[JsonAST.JValue] = { + if (level < 3) None + else { + Some(comps.map { + case component => + ( + ("name" -> component.componentName) + ~ ("compliance" -> component.compliance.complianceWithoutPending(precision)) + ~ ("complianceDetails" -> percents(component.compliance, precision)) + ~ (component match { + case component: BlockStatusReport => + ("components" -> byNodeComponents(component.subComponents, level, precision)) + case component: ValueStatusReport => ("values" -> values(component.componentValues, level)) + }) + ) + }) + } + } + private[this] def directives( - directives: Seq[ByRuleDirectiveCompliance], + directives: Seq[ByNodeGroupByRuleDirectiveCompliance], level: Int, precision: CompliancePrecision ): Option[JsonAST.JValue] = { @@ -827,6 +924,7 @@ object JsonCompliance { ("id" -> directive.id.serialize) ~ ("name" -> directive.name) ~ ("compliance" -> directive.compliance.complianceWithoutPending(precision)) + ~ ("policyMode" -> directive.policyMode.map(_.name).getOrElse("default")) ~ ("complianceDetails" -> percents(directive.compliance, precision)) ~ ("components" -> components(directive.components, level, precision)) ) diff --git a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/lift/ComplianceApi.scala b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/lift/ComplianceApi.scala index a2d01f2c14a..afb63de8063 100644 --- a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/lift/ComplianceApi.scala +++ b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/lift/ComplianceApi.scala @@ -850,12 +850,14 @@ class ComplianceAPIService( ComplianceLevel.sum(reports.map(_.compliance)), byDirectives.map { case (directiveId, nodeDirectives) => - ByRuleDirectiveCompliance( + ByNodeGroupByRuleDirectiveCompliance( directiveId, directiveLib.allDirectives.get(directiveId).map(_._2.name).getOrElse("Unknown directive"), ComplianceLevel.sum( nodeDirectives.map(_._2.compliance) - ), // here we want the compliance by components of the directive. + ), + directiveLib.allDirectives.get(directiveId).flatMap(_._2.policyMode), + // here we want the compliance by components of the directive. // if level is high enough, get all components and group by their name { val byComponents: Map[String, immutable.Iterable[(NodeId, ComponentStatusReport)]] = if (computedLevel < 3) { @@ -909,11 +911,12 @@ class ComplianceAPIService( case (nodeId, status) if currentGroupNodeIds.contains(nodeId) => // For non global compliance, we only want the compliance for the rules in the group val reports = status.reports.filter(r => isGlobalCompliance || ruleIds.contains(r.ruleId)).toSeq - ByNodeNodeCompliance( + ByNodeGroupNodeCompliance( nodeId, nodeFacts.get(nodeId).map(_.fqdn).getOrElse("Unknown node"), - ComplianceLevel.sum(reports.map(_.compliance)), compliance.mode, + ComplianceLevel.sum(reports.map(_.compliance)), + nodeFacts.get(nodeId).flatMap(_.rudderSettings.policyMode), reports.map(r => { ByNodeRuleCompliance( r.ruleId, diff --git a/webapp/sources/rudder/rudder-rest/src/test/resources/api/api_compliance.yml b/webapp/sources/rudder/rudder-rest/src/test/resources/api/api_compliance.yml index e0493cb51b9..48c0fac41ca 100644 --- a/webapp/sources/rudder/rudder-rest/src/test/resources/api/api_compliance.yml +++ b/webapp/sources/rudder/rudder-rest/src/test/resources/api/api_compliance.yml @@ -30,8 +30,9 @@ response: "directives" : [ { "id" : "e9a1a909-2490-4fc9-95c3-9d0aa01717c9", - "name" : "directive e9a1a909-2490-4fc9-95c3-9d0aa01717c9", + "name" : "directivee9a1a909-2490-4fc9-95c3-9d0aa01717c9", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -106,6 +107,7 @@ response: "id" : "directive2", "name" : "directive2", "compliance" : 0.0, + "policyMode" : "default", "complianceDetails" : { "error" : 100.0 }, @@ -178,8 +180,9 @@ response: "directives" : [ { "id" : "99f4ef91-537b-4e03-97bc-e65b447514cc", - "name" : "directive 99f4ef91-537b-4e03-97bc-e65b447514cc", + "name" : "directive99f4ef91-537b-4e03-97bc-e65b447514cc", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successRepaired" : 100.0 }, @@ -217,12 +220,12 @@ response: } ], "nodes" : [ - { "id" : "n2", "name" : "node1.localhost", - "compliance" : 50.0, "mode" : "full-compliance", + "compliance" : 50.0, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 50.0, "error" : 50.0 @@ -238,7 +241,7 @@ response: "directives" : [ { "id" : "e9a1a909-2490-4fc9-95c3-9d0aa01717c9", - "name" : "directive e9a1a909-2490-4fc9-95c3-9d0aa01717c9", + "name" : "directivee9a1a909-2490-4fc9-95c3-9d0aa01717c9", "compliance" : 100.0, "complianceDetails" : { "successAlreadyOK" : 100.0 @@ -307,8 +310,9 @@ response: { "id" : "n1", "name" : "node1.localhost", - "compliance" : 66.66, "mode" : "full-compliance", + "compliance" : 66.66, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 33.33, "error" : 33.34, @@ -325,7 +329,7 @@ response: "directives" : [ { "id" : "e9a1a909-2490-4fc9-95c3-9d0aa01717c9", - "name" : "directive e9a1a909-2490-4fc9-95c3-9d0aa01717c9", + "name" : "directivee9a1a909-2490-4fc9-95c3-9d0aa01717c9", "compliance" : 100.0, "complianceDetails" : { "successAlreadyOK" : 100.0 @@ -362,7 +366,7 @@ response: "directives" : [ { "id" : "99f4ef91-537b-4e03-97bc-e65b447514cc", - "name" : "directive 99f4ef91-537b-4e03-97bc-e65b447514cc", + "name" : "directive99f4ef91-537b-4e03-97bc-e65b447514cc", "compliance" : 100.0, "complianceDetails" : { "successRepaired" : 100.0 @@ -467,6 +471,7 @@ response: "id" : "e9a1a909-2490-4fc9-95c3-9d0aa01717c9", "name" : "directivee9a1a909-2490-4fc9-95c3-9d0aa01717c9", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -541,6 +546,7 @@ response: "id" : "99f4ef91-537b-4e03-97bc-e65b447514cc", "name" : "directive99f4ef91-537b-4e03-97bc-e65b447514cc", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successRepaired" : 100.0 }, @@ -581,8 +587,9 @@ response: { "id" : "n2", "name" : "node1.localhost", - "compliance" : 100.0, "mode" : "full-compliance", + "compliance" : 100.0, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -629,8 +636,9 @@ response: { "id" : "n1", "name" : "node1.localhost", - "compliance" : 100.0, "mode" : "full-compliance", + "compliance" : 100.0, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 50.0, "successRepaired" : 50.0 @@ -752,6 +760,7 @@ response: "id" : "e9a1a909-2490-4fc9-95c3-9d0aa01717c9", "name" : "directivee9a1a909-2490-4fc9-95c3-9d0aa01717c9", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -799,6 +808,7 @@ response: "id" : "directive2", "name" : "directive2", "compliance" : 0.0, + "policyMode" : "default", "complianceDetails" : { "error" : 100.0 }, @@ -846,6 +856,7 @@ response: "id" : "99f4ef91-537b-4e03-97bc-e65b447514cc", "name" : "directive99f4ef91-537b-4e03-97bc-e65b447514cc", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successRepaired" : 100.0 }, @@ -886,8 +897,9 @@ response: { "id" : "n1", "name" : "node1.localhost", - "compliance" : 66.66, "mode" : "full-compliance", + "compliance" : 66.66, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 33.33, "error" : 33.34, @@ -1045,6 +1057,7 @@ response: "id" : "directive2", "name" : "directive2", "compliance" : 0.0, + "policyMode" : "default", "complianceDetails" : { "error" : 100.0 }, @@ -1085,8 +1098,9 @@ response: { "id" : "n1", "name" : "node1.localhost", - "compliance" : 0.0, "mode" : "full-compliance", + "compliance" : 0.0, + "policyMode" : "enforce", "complianceDetails" : { "error" : 100.0 }, @@ -1170,6 +1184,7 @@ response: "id" : "e9a1a909-2490-4fc9-95c3-9d0aa01717c9", "name" : "directivee9a1a909-2490-4fc9-95c3-9d0aa01717c9", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -1244,6 +1259,7 @@ response: "id" : "directive2", "name" : "directive2", "compliance" : 0.0, + "policyMode" : "default", "complianceDetails" : { "error" : 100.0 }, @@ -1318,6 +1334,7 @@ response: "id" : "e9a1a909-2490-4fc9-95c3-9d0aa01717c9", "name" : "directivee9a1a909-2490-4fc9-95c3-9d0aa01717c9", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -1365,6 +1382,7 @@ response: "id" : "99f4ef91-537b-4e03-97bc-e65b447514cc", "name" : "directive99f4ef91-537b-4e03-97bc-e65b447514cc", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successRepaired" : 100.0 }, @@ -1405,8 +1423,9 @@ response: { "id" : "bn2", "name" : "node1.localhost", - "compliance" : 66.67, "mode" : "full-compliance", + "compliance" : 66.67, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 66.67, "error" : 33.33 @@ -1528,8 +1547,9 @@ response: { "id" : "bn1", "name" : "node1.localhost", - "compliance" : 66.66, "mode" : "full-compliance", + "compliance" : 66.66, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 33.33, "error" : 33.34, @@ -1688,6 +1708,7 @@ response: "id" : "99f4ef91-537b-4e03-97bc-e65b447514cc", "name" : "directive99f4ef91-537b-4e03-97bc-e65b447514cc", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successRepaired" : 100.0 }, @@ -1735,6 +1756,7 @@ response: "id" : "e9a1a909-2490-4fc9-95c3-9d0aa01717c9", "name" : "directivee9a1a909-2490-4fc9-95c3-9d0aa01717c9", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -1802,8 +1824,9 @@ response: { "id" : "bn2", "name" : "node1.localhost", - "compliance" : 100.0, "mode" : "full-compliance", + "compliance" : 100.0, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -1850,8 +1873,9 @@ response: { "id" : "bn1", "name" : "node1.localhost", - "compliance" : 100.0, "mode" : "full-compliance", + "compliance" : 100.0, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 50.0, "successRepaired" : 50.0 @@ -1971,6 +1995,7 @@ response: "id" : "e9a1a909-2490-4fc9-95c3-9d0aa01717c9", "name" : "directivee9a1a909-2490-4fc9-95c3-9d0aa01717c9", "compliance" : 100.0, + "policyMode" : "default", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -2047,8 +2072,9 @@ response: { "id" : "bn5", "name" : "node1.localhost", - "compliance" : 100.0, "mode" : "full-compliance", + "compliance" : 100.0, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -2095,8 +2121,9 @@ response: { "id" : "bn4", "name" : "node1.localhost", - "compliance" : 100.0, "mode" : "full-compliance", + "compliance" : 100.0, + "policyMode" : "enforce", "complianceDetails" : { "successAlreadyOK" : 100.0 }, @@ -2178,16 +2205,18 @@ response: { "id" : "bn5", "name" : "node1.localhost", - "compliance" : 0.0, "mode" : "full-compliance", + "compliance" : 0.0, + "policyMode" : "enforce", "complianceDetails" : {}, "rules" : [] }, { "id" : "bn4", "name" : "node1.localhost", - "compliance" : 0.0, "mode" : "full-compliance", + "compliance" : 0.0, + "policyMode" : "enforce", "complianceDetails" : {}, "rules" : [] } diff --git a/webapp/sources/rudder/rudder-rest/src/test/scala/com/normation/rudder/rest/TestRestFromFileDef.scala b/webapp/sources/rudder/rudder-rest/src/test/scala/com/normation/rudder/rest/TestRestFromFileDef.scala index 4ed867547c1..81aff8adf67 100644 --- a/webapp/sources/rudder/rudder-rest/src/test/scala/com/normation/rudder/rest/TestRestFromFileDef.scala +++ b/webapp/sources/rudder/rudder-rest/src/test/scala/com/normation/rudder/rest/TestRestFromFileDef.scala @@ -90,6 +90,6 @@ class TestRestFromFileDef extends TraitTestApiFromYamlFiles with AfterAll { // you can pass a list of file to test exclusively if you don't want to test all .yml // files in src/test/resource/${yamlSourceDirectory} - doTest(List("api_compliance.yml")) + doTest(Nil) } diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ApiCalls.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ApiCalls.elm new file mode 100644 index 00000000000..648895150da --- /dev/null +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ApiCalls.elm @@ -0,0 +1,64 @@ +module GroupCompliance.ApiCalls exposing (..) + +import Http exposing (..) +import Url.Builder exposing (QueryParameter) + +import GroupCompliance.DataTypes exposing (..) +import GroupCompliance.JsonDecoder exposing (..) + + +-- +-- This files contains all API calls for the Group compliance UI +-- + +getUrl: Model -> List String -> List QueryParameter -> String +getUrl m url p= + Url.Builder.relative (m.contextPath :: "secure" :: "api" :: url) p + +getPolicyMode : Model -> Cmd Msg +getPolicyMode model = + let + req = + request + { method = "GET" + , headers = [] + , url = getUrl model [ "settings", "global_policy_mode" ] [] + , body = emptyBody + , expect = expectJson GetPolicyModeResult decodeGetPolicyMode + , timeout = Nothing + , tracker = Nothing + } + in + req + +getGlobalGroupCompliance : Model -> Cmd Msg +getGlobalGroupCompliance model = + let + req = + request + { method = "GET" + , headers = [] + , url = getUrl model [ "compliance", "groups", model.groupId.value ] [] + , body = emptyBody + , expect = expectJson GetGroupComplianceResult decodeGetGroupCompliance + , timeout = Nothing + , tracker = Nothing + } + in + req + +getTargetedGroupCompliance : Model -> Cmd Msg +getTargetedGroupCompliance model = + let + req = + request + { method = "GET" + , headers = [] + , url = getUrl model [ "compliance", "groups", model.groupId.value, "target" ] [] + , body = emptyBody + , expect = expectJson GetGroupComplianceResult decodeGetGroupCompliance + , timeout = Nothing + , tracker = Nothing + } + in + req diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/DataTypes.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/DataTypes.elm new file mode 100644 index 00000000000..d062fccbaf2 --- /dev/null +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/DataTypes.elm @@ -0,0 +1,102 @@ +module GroupCompliance.DataTypes exposing (..) + +import Dict exposing (Dict) +import Http exposing (Error) + +import Compliance.DataTypes exposing (..) +import Rules.DataTypes exposing (RuleCompliance) +-- +-- All our data types +-- + +type alias RuleId = { value : String } +type alias DirectiveId = { value : String } +type alias NodeId = { value : String } +type alias GroupId = { value : String } + +type alias GroupCompliance = + { compliance : Float + , complianceDetails : ComplianceDetails + , rules : List (RuleCompliance NodeValueCompliance) + , nodes : List NodeCompliance + } + +type alias RuleCompliance value = + { ruleId : RuleId + , name : String + , compliance : Float + , complianceDetails : ComplianceDetails + , directives : List (DirectiveCompliance value) + } + +type alias DirectiveCompliance value = + { directiveId : DirectiveId + , name : String + , compliance : Float + , policyMode : String + , complianceDetails : ComplianceDetails + , components : List (ComponentCompliance value) + } + +type alias NodeValueCompliance = + { nodeId : NodeId + , name : String + , compliance : Float + , complianceDetails : ComplianceDetails + , values : List ValueCompliance + } + +type alias NodeCompliance = + { nodeId : NodeId + , name : String + , compliance : Float + , policyMode : String + , complianceDetails : ComplianceDetails + , rules : List (RuleCompliance ValueCompliance) + } + + +type alias TableFilters = + { sortOrder : SortOrder + , filter : String + , openedRows : Dict String (String, SortOrder) + } + +type SortOrder = Asc | Desc + +type alias UI = + { ruleFilters : TableFilters + , nodeFilters : TableFilters + , complianceFilters : ComplianceFilters + , viewMode : ViewMode + , loading : Bool + , loaded : Bool + } + +type ViewMode = RulesView | NodesView + +type alias Model = + { groupId : GroupId + , contextPath : String + , policyMode : String + , ui : UI + , groupCompliance : Maybe GroupCompliance + , complianceScope : ComplianceScope + } + +type ComplianceScope = GlobalCompliance | TargetedCompliance + +type Msg + = Ignore + | UpdateFilters TableFilters + | UpdateComplianceFilters ComplianceFilters + | GoTo String + | ChangeViewMode ViewMode + | ToggleRow String String + | ToggleRowSort String String SortOrder + | GetPolicyModeResult (Result Error String) + | GetGroupComplianceResult (Result Error GroupCompliance) + --| Export (Result Error String) --TODO: later + | CallApi (Model -> Cmd Msg) + | LoadCompliance ComplianceScope + diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/Init.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/Init.elm new file mode 100644 index 00000000000..dceeeb2a2d6 --- /dev/null +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/Init.elm @@ -0,0 +1,22 @@ +module GroupCompliance.Init exposing (..) + +import Dict + +import GroupCompliance.ApiCalls exposing (..) +import GroupCompliance.DataTypes exposing (..) +import Compliance.DataTypes exposing (..) + + +init : { groupId : String, contextPath : String } -> ( Model, Cmd Msg ) +init flags = + let + initFilters = (TableFilters Asc "" Dict.empty) + initUI = UI initFilters initFilters (ComplianceFilters False False []) RulesView True False + initModel = Model (GroupId flags.groupId) flags.contextPath "" initUI Nothing GlobalCompliance + listInitActions = + [ getPolicyMode initModel + ] + in + ( initModel + , Cmd.batch listInitActions + ) \ No newline at end of file diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/JsonDecoder.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/JsonDecoder.elm new file mode 100644 index 00000000000..59d16fbee71 --- /dev/null +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/JsonDecoder.elm @@ -0,0 +1,118 @@ +module GroupCompliance.JsonDecoder exposing (..) + +import Json.Decode exposing (..) +import Json.Decode.Pipeline exposing (..) +import Json.Decode.Field exposing (require) + +import GroupCompliance.DataTypes exposing (..) +import Compliance.DataTypes exposing (..) +import NodeCompliance.JsonDecoder exposing (decodeComponentCompliance) + + +decodeGetPolicyMode : Decoder String +decodeGetPolicyMode = + at ["data", "settings", "global_policy_mode" ] string + + +decodeGetGroupCompliance : Decoder GroupCompliance +decodeGetGroupCompliance = + at ["data", "nodeGroups" ] (index 0 decodeGroupCompliance) + +decodeGroupCompliance : Decoder GroupCompliance +decodeGroupCompliance = + succeed GroupCompliance + |> required "compliance" float + |> required "complianceDetails" decodeComplianceDetails + |> required "rules" (list (decodeRuleCompliance "nodes" decodeNodeCompliance) ) + |> required "nodes" (list decodeRuleComplianceByNode ) + +decodeRuleComplianceByNode : Decoder NodeCompliance +decodeRuleComplianceByNode = + succeed NodeCompliance + |> required "id" (map NodeId string) + |> required "name" string + |> required "compliance" float + |> required "policyMode" string + |> required "complianceDetails" decodeComplianceDetails + |> required "rules" (list (decodeRuleCompliance "values" decodeValueCompliance )) + +decodeReport : Decoder Report +decodeReport = + succeed Report + |> required "status" string + |> optional "message" (maybe string) Nothing + +decodeNodeCompliance : Decoder NodeValueCompliance +decodeNodeCompliance = + succeed NodeValueCompliance + |> required "id" (map NodeId string) + |> required "name" string + |> required "compliance" float + |> required "complianceDetails" decodeComplianceDetails + |> required "values" (list decodeValueCompliance) + +decodeValueCompliance : Decoder ValueCompliance +decodeValueCompliance = + succeed ValueCompliance + |> required "value" string + |> required "reports" (list decodeReport) + +decodeRuleCompliance : String -> Decoder a -> Decoder (RuleCompliance a) +decodeRuleCompliance elem decoder = + succeed RuleCompliance + |> required "id" (map RuleId string) + |> required "name" string + |> required "compliance" float + |> required "complianceDetails" decodeComplianceDetails + |> required "directives" (list (decodeDirectiveCompliance elem decoder )) + +decodeComplianceDetails : Decoder ComplianceDetails +decodeComplianceDetails = + succeed ComplianceDetails + |> optional "successNotApplicable" (map Just float) Nothing + |> optional "successAlreadyOK" (map Just float) Nothing + |> optional "successRepaired" (map Just float) Nothing + |> optional "error" (map Just float) Nothing + |> optional "auditCompliant" (map Just float) Nothing + |> optional "auditNonCompliant" (map Just float) Nothing + |> optional "auditError" (map Just float) Nothing + |> optional "auditNotApplicable" (map Just float) Nothing + |> optional "unexpectedUnknownComponent" (map Just float) Nothing + |> optional "unexpectedMissingComponent" (map Just float) Nothing + |> optional "noReport" (map Just float) Nothing + |> optional "reportsDisabled" (map Just float) Nothing + |> optional "applying" (map Just float) Nothing + |> optional "badPolicyMode" (map Just float) Nothing + +decodeDirectiveCompliance : String -> Decoder a -> Decoder (DirectiveCompliance a) +decodeDirectiveCompliance elem decoder = + succeed DirectiveCompliance + |> required "id" (map DirectiveId string) + |> required "name" string + |> required "compliance" float + |> required "policyMode" string + |> required "complianceDetails" decodeComplianceDetails + |> required "components" (list (decodeComponentCompliance elem decoder)) + +decodeComponentValueCompliance : String -> Decoder a -> Decoder (ComponentValueCompliance a) +decodeComponentValueCompliance elem decoder = + succeed ComponentValueCompliance + |> required "name" string + |> required "compliance" float + |> required "complianceDetails" decodeComplianceDetails + |> required elem (list decoder) + +decodeComponentCompliance : String -> Decoder a -> Decoder (ComponentCompliance a) +decodeComponentCompliance elem decoder = + oneOf [ + map (\b -> Block b) <| decodeBlockCompliance elem decoder () + , map (\v -> Value v) <| decodeComponentValueCompliance elem decoder + ] + +decodeBlockCompliance : String -> Decoder a -> () -> Decoder (BlockCompliance a) +decodeBlockCompliance elem decoder _ = + require "name" string <| \name -> + require "compliance" float <| \compliance -> + require "complianceDetails" decodeComplianceDetails <| \details -> + require "components" (list (decodeComponentCompliance elem decoder)) <| \components -> + succeed ({ component = name, compliance = compliance, complianceDetails = details, components = components } ) \ No newline at end of file diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/View.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/View.elm new file mode 100644 index 00000000000..0b25b7d57af --- /dev/null +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/View.elm @@ -0,0 +1,36 @@ +module GroupCompliance.View exposing (..) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import List +import Html.Lazy + +import GroupCompliance.DataTypes exposing (..) +import GroupCompliance.ViewUtils exposing (..) +import GroupCompliance.ViewRulesCompliance exposing (..) +import GroupCompliance.ViewNodesCompliance exposing (..) + + +view : Model -> Html Msg +view model = + div [class "tab-table-content"] + ( List.append + [ ul [class "ui-tabs-nav"] + [ li [class ("ui-tabs-tab ui-tab" ++ (if model.ui.viewMode == RulesView then " active" else ""))] + [ a [onClick (ChangeViewMode RulesView)] + [ text "By Rules" + ] + ] + , li [class ("ui-tabs-tab ui-tab" ++ (if model.ui.viewMode == NodesView then " active" else ""))] + [ a [onClick (ChangeViewMode NodesView)] + [ text "By Nodes" + ] + ] + ] + ] + [( case model.ui.viewMode of + RulesView -> Html.Lazy.lazy displayRulesComplianceTable model + NodesView -> Html.Lazy.lazy displayNodesComplianceTable model + )] + ) \ No newline at end of file diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewNodesCompliance.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewNodesCompliance.elm new file mode 100644 index 00000000000..ecd102af3b4 --- /dev/null +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewNodesCompliance.elm @@ -0,0 +1,90 @@ +module GroupCompliance.ViewNodesCompliance exposing (..) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import List +import List.Extra +import String +import Tuple3 +import Dict + +import GroupCompliance.ApiCalls exposing (..) +import GroupCompliance.DataTypes exposing (..) +import GroupCompliance.ViewUtils exposing (..) +import Compliance.Utils exposing (displayComplianceFilters, filterDetailsByCompliance) + + +displayNodesComplianceTable : Model -> Html Msg +displayNodesComplianceTable model = + let + filters = model.ui.nodeFilters + complianceFilters = model.ui.complianceFilters + fun = byNodeCompliance model complianceFilters + col = "Node" + childs = case model.groupCompliance of + Just dc -> dc.nodes + Nothing -> [] + childrenSort = childs + |> List.filter (\n -> (filterSearch filters.filter (searchFieldNodeCompliance n))) + |> List.filter (filterDetailsByCompliance complianceFilters) + |> List.sortWith sort + + (children, order, newOrder) = case sortOrder of + Asc -> (childrenSort, "asc", Desc) + Desc -> (List.reverse childrenSort, "desc", Asc) + + rowId = "by" ++ col ++ "s/" + rows = List.map Tuple3.first fun.rows + (sortId, sortOrder) = Dict.get rowId filters.openedRows |> Maybe.withDefault (col, Asc) + sort = case List.Extra.find (Tuple3.first >> (==) sortId) fun.rows of + Just (_,_,sortFun) -> (\i1 i2 -> sortFun (fun.data model i1) (fun.data model i2)) + Nothing -> (\_ _ -> EQ) + isGlobalMode = isGlobalCompliance model + in + ( if model.ui.loading then + generateLoadingTable + else + div[] + [ div [class "table-header extra-filters"] + [ div [class "d-inline-flex align-items-baseline pb-3 w-25"] + [ + div [class "btn-group yesno"] + [ label [class ("btn btn-default" ++ if isGlobalMode then " active" else ""), style "box-shadow" (if isGlobalMode then "inset 0 3px 5px rgba(0,0,0,.125)" else "none"), onClick (LoadCompliance GlobalCompliance)] + [text "Global"] + , label [class ("btn btn-default" ++ if isGlobalMode then "" else " active"), style "box-shadow" (if isGlobalMode then "none" else "inset 0 3px 5px rgba(0,0,0,.125)"), onClick (LoadCompliance TargetedCompliance)] + [text "Targeted"] + ] + , span [class "mx-3"] + [text "Compliance"] + ] + , div[class "main-filters"] + [ input [type_ "text", placeholder "Filter", class "input-sm form-control", value filters.filter, onInput (\s -> (UpdateFilters {filters | filter = s} ))][] + , button [class "btn btn-default btn-sm btn-icon", onClick (UpdateComplianceFilters {complianceFilters | showComplianceFilters = not complianceFilters.showComplianceFilters}), style "min-width" "170px"] + [ text ((if complianceFilters.showComplianceFilters then "Hide " else "Show ") ++ "compliance filters") + , i [class ("fa " ++ (if complianceFilters.showComplianceFilters then "fa-minus" else "fa-plus"))][] + ] + ] + , displayComplianceFilters complianceFilters UpdateComplianceFilters + ] + , div[class "table-container"] + [ table [class "dataTable compliance-table"] + [ thead [] + [ tr [ class "head" ] + ( List.map (\row -> th [onClick (ToggleRowSort rowId row (if row == sortId then newOrder else Asc)), class ("sorting" ++ (if row == sortId then "_"++order else ""))] [ text row ]) rows ) + ] + , tbody [] + ( if List.length childs <= 0 then + [ tr[] + [ td[class "empty", colspan 2][i [class"fa fa-exclamation-triangle"][], text "There is no compliance for this group."] ] + ] + else if List.length children == 0 then + [ tr[] + [ td[class "empty", colspan 2][i [class"fa fa-exclamation-triangle"][], text "No nodes match your filter."] ] + ] + else + List.concatMap (\d -> showComplianceDetails fun d "" filters.openedRows model) children + ) + ] + ] + ]) diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewRulesCompliance.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewRulesCompliance.elm new file mode 100644 index 00000000000..aab1e592891 --- /dev/null +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewRulesCompliance.elm @@ -0,0 +1,91 @@ +module GroupCompliance.ViewRulesCompliance exposing (..) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import List +import List.Extra +import Tuple3 +import Dict + +import GroupCompliance.ApiCalls exposing (..) +import GroupCompliance.DataTypes exposing (..) +import GroupCompliance.ViewUtils exposing (..) +import Compliance.Utils exposing (displayComplianceFilters, filterDetailsByCompliance) + +displayRulesComplianceTable : Model -> Html Msg +displayRulesComplianceTable model = + let + filters = model.ui.ruleFilters + complianceFilters = model.ui.complianceFilters + fun = byRuleCompliance model (nodeValueCompliance model complianceFilters) complianceFilters + col = "Rule" + childs = case model.groupCompliance of + Just dc -> dc.rules + Nothing -> [] + childrenSort = childs + |> List.filter (\d -> (filterSearch filters.filter (searchFieldRuleCompliance d))) + |> List.filter (filterDetailsByCompliance complianceFilters) + |> List.sortWith sort + + (children, order, newOrder) = case sortOrder of + Asc -> (childrenSort, "asc", Desc) + Desc -> (List.reverse childrenSort, "desc", Asc) + + rowId = "by" ++ col ++ "s/" + rows = List.map Tuple3.first fun.rows + (sortId, sortOrder) = Dict.get rowId filters.openedRows |> Maybe.withDefault (col, Asc) + sort = case List.Extra.find (Tuple3.first >> (==) sortId) fun.rows of + Just (_,_,sortFun) -> (\i1 i2 -> sortFun (fun.data model i1) (fun.data model i2)) + Nothing -> (\_ _ -> EQ) + isGlobalMode = isGlobalCompliance model + in + ( if model.ui.loading then + generateLoadingTable + else + div[][ div [class "table-header extra-filters"] + [ div [class "d-inline-flex align-items-baseline pb-3 w-25"] + [ + div [class "btn-group yesno"] + [ label [class ("btn btn-default" ++ if isGlobalMode then " active" else ""), style "box-shadow" (if isGlobalMode then "inset 0 3px 5px rgba(0,0,0,.125)" else "none"), onClick (LoadCompliance GlobalCompliance)] + [text "Global"] + , label [class ("btn btn-default" ++ if isGlobalMode then "" else " active"), style "box-shadow" (if isGlobalMode then "none" else "inset 0 3px 5px rgba(0,0,0,.125)"), onClick (LoadCompliance TargetedCompliance)] + [text "Targeted"] + ] + , span [class "mx-3"] + [text "Compliance"] + ] + , div [class "main-filters"] + [ input [type_ "text", placeholder "Filter", class "input-sm form-control", value filters.filter + , onInput (\s -> (UpdateFilters {filters | filter = s} ))][] + , button [class "btn btn-default btn-sm btn-icon", onClick (UpdateComplianceFilters {complianceFilters | showComplianceFilters = not complianceFilters.showComplianceFilters}), style "min-width" "170px"] + [ text ((if complianceFilters.showComplianceFilters then "Hide " else "Show ") ++ "compliance filters") + , i [class ("fa " ++ (if complianceFilters.showComplianceFilters then "fa-minus" else "fa-plus"))][] + ] + --TODO later : export csv + -- , button [class "btn btn-sm btn-primary btn-export", onClick (CallApi getCSVExport) ] + -- [ text "Export " , i [ class "fa fa-download" ] [] ] + ] + , displayComplianceFilters complianceFilters UpdateComplianceFilters + ] + , div[class "table-container"] + [ table [class "dataTable compliance-table"] + [ thead [] + [ tr [ class "head" ] + ( List.map (\row -> th [onClick (ToggleRowSort rowId row (if row == sortId then newOrder else Asc)), class ("sorting" ++ (if row == sortId then "_"++order else ""))] [ text row ]) rows ) + ] + , tbody [] + ( if List.length childs <= 0 then + [ tr[] + [ td[class "empty", colspan 2][i [class"fa fa-exclamation-triangle"][], text "There is no compliance for this group."] ] + ] + else if List.length children == 0 then + [ tr[] + [ td[class "empty", colspan 2][i [class"fa fa-exclamation-triangle"][], text "No rules match your filter."] ] + ] + else + List.concatMap (\d -> showComplianceDetails fun d "" filters.openedRows model) children + ) + ] + ] + ]) diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewUtils.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewUtils.elm new file mode 100644 index 00000000000..4c79bbbb635 --- /dev/null +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/GroupCompliance/ViewUtils.elm @@ -0,0 +1,403 @@ +module GroupCompliance.ViewUtils exposing (..) + +import Dict exposing (Dict) +import Either exposing (Either(..)) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput, custom) +import List.Extra +import List +import Maybe.Extra +import String exposing (fromFloat) +import Json.Decode as Decode +import Tuple3 +import NaturalOrdering as N exposing (compare) + +import GroupCompliance.ApiCalls exposing (..) +import GroupCompliance.DataTypes exposing (..) +import Compliance.DataTypes exposing (..) +import Compliance.Utils exposing (..) +import Tags.DataTypes exposing (Tag) + +isGlobalCompliance : Model -> Bool +isGlobalCompliance model = + case model.complianceScope of + GlobalCompliance -> True + TargetedCompliance -> False + +onCustomClick : msg -> Html.Attribute msg +onCustomClick msg = + custom "click" + (Decode.succeed + { message = msg + , stopPropagation = True + , preventDefault = True + } + ) +-- +-- DATATABLES & TREES +-- +subItemOrder : ItemFun item subItem data -> Model -> String -> (item -> item -> Order) +subItemOrder fun model id = + case List.Extra.find (Tuple3.first >> (==) id) fun.rows of + Just (_,_,sort) -> (\i1 i2 -> sort (fun.data model i1) (fun.data model i2)) + Nothing -> (\_ _ -> EQ) + +type alias ItemFun item subItem data = + { children : item -> Model -> String -> List subItem + , data : Model -> item -> data + , rows : List (String, data -> Html Msg, (data -> data -> Order) ) + , id : item -> String + , childDetails : Maybe (subItem -> String -> Dict String (String, SortOrder) -> Model -> List (Html Msg)) + , subItemRows : item -> List String + , filterItems : item -> Bool + } + +valueCompliance : ComplianceFilters -> ItemFun ValueCompliance () ValueCompliance +valueCompliance complianceFilters = + ItemFun + (\ _ _ _ -> []) + (\_ i -> i) + [ ("Value" , .value >> text, (\d1 d2 -> N.compare d1.value d2.value)) + , ("Messages", .reports >> List.filter (filterReports complianceFilters) >> List.map (\r -> Maybe.withDefault "" r.message) >> List.foldl (++) "\n" >> text, (\d1 d2 -> N.compare d1.value d2.value) ) + , ("Status" , .reports >> List.filter (filterReports complianceFilters) >> buildComplianceReport, (\d1 d2 -> Basics.compare d1.value d2.value)) + ] + .value + Nothing + (always []) + (filterReportsByCompliance complianceFilters) + +byComponentCompliance : ItemFun value subValue valueData -> ComplianceFilters -> ItemFun (ComponentCompliance value) (Either (ComponentCompliance value) value) (ComponentCompliance value) +byComponentCompliance subFun complianceFilters = + let + name = \item -> + case item of + Block b -> b.component + Value c -> c.component + compliance = \item -> + case item of + Block b -> b.complianceDetails + Value c -> c.complianceDetails + in + ItemFun + ( \item model sortId -> + case item of + Block b -> + let + sortFunction = subItemOrder (byComponentCompliance subFun complianceFilters) model sortId + in + b.components + |> List.filter (filterByCompliance complianceFilters) + |> List.sortWith sortFunction + |> List.map Left + Value c -> + let + sortFunction = subItemOrder subFun model sortId + in + c.values + |> List.filter subFun.filterItems + |> List.sortWith sortFunction + |> List.map Right + ) + (\_ i -> i) + [ ("Component", name >> text, (\d1 d2 -> N.compare (name d1) (name d2))) + , ("Compliance", \i -> buildComplianceBar complianceFilters (compliance i), (\d1 d2 -> Basics.compare (name d1) (name d2)) ) + ] + name + (Just ( \x -> + case x of + Left value -> showComplianceDetails (byComponentCompliance subFun complianceFilters) value + Right value -> showComplianceDetails subFun value + )) + ( \x -> + case x of + Block _ -> (List.map Tuple3.first (byComponentCompliance subFun complianceFilters).rows) + Value _ -> (List.map Tuple3.first subFun.rows) + ) + (always True) + +byNodeCompliance : Model -> ComplianceFilters -> ItemFun NodeCompliance (RuleCompliance ValueCompliance) NodeCompliance +byNodeCompliance mod complianceFilters = + let + rule = byRuleCompliance mod (valueCompliance complianceFilters) complianceFilters + in + ItemFun + (\item model sortId -> + let + sortFunction = subItemOrder rule mod sortId + in + item.rules + |> List.filter (filterDetailsByCompliance complianceFilters) + |> List.sortWith sortFunction + ) + (\m i -> i) + [ ("Node", (\nId -> span[][ (badgePolicyMode mod.policyMode nId.policyMode), text nId.name, goToBtn (getNodeLink mod.contextPath nId.nodeId.value)]), (\n1 n2 -> N.compare n1.name n2.name)) + , ("Compliance", .complianceDetails >> buildComplianceBar complianceFilters, (\n1 n2 -> Basics.compare n1.compliance n2.compliance)) + ] + (.nodeId >> .value) + (Just (\b -> showComplianceDetails rule b)) + (always (List.map Tuple3.first rule.rows)) + (always True) + +byRuleCompliance : Model -> ItemFun value subValue valueData -> ComplianceFilters -> ItemFun (RuleCompliance value) (DirectiveCompliance value) (RuleCompliance value) +byRuleCompliance model subFun complianceFilters = + let + contextPath = model.contextPath + directive = byDirectiveCompliance model complianceFilters subFun + in + ItemFun + (\item _ sortId -> + let + sortFunction = subItemOrder directive model sortId + in + item.directives + |> List.filter (filterDetailsByCompliance complianceFilters) + |> List.sortWith sortFunction + ) + (\_ i -> i ) + [ ("Rule", \i -> span [] [ (badgePolicyMode model.policyMode "default"), text i.name , goToBtn (getRuleLink contextPath i.ruleId) ], (\r1 r2 -> N.compare r1.name r2.name )) + , ("Compliance", \i -> buildComplianceBar complianceFilters i.complianceDetails, (\(r1) (r2) -> Basics.compare r1.compliance r2.compliance )) + ] + (.ruleId >> .value) + (Just (\b -> showComplianceDetails directive b)) + (always (List.map Tuple3.first directive.rows)) + (always True) + +byDirectiveCompliance : Model -> ComplianceFilters -> ItemFun value subValue valueData -> ItemFun (DirectiveCompliance value) (ComponentCompliance value) (DirectiveCompliance value) +byDirectiveCompliance mod complianceFilters subFun = + let + contextPath = mod.contextPath + in + ItemFun + (\item model sortId -> + let + sortFunction = subItemOrder (byComponentCompliance subFun complianceFilters) model sortId + in + item.components + |> List.filter (filterByCompliance complianceFilters) + |> List.sortWith sortFunction + ) + (\_ i -> i) + [ ("Directive", \i -> span [] [ (badgePolicyMode mod.policyMode i.policyMode), text i.name, goToBtn (getDirectiveLink contextPath i.directiveId) ], (\d1 d2 -> N.compare d1.name d2.name )) + , ("Compliance", \i -> buildComplianceBar complianceFilters i.complianceDetails, (\d1 d2 -> Basics.compare d1.compliance d2.compliance )) + ] + (.directiveId >> .value) + (Just (\b -> showComplianceDetails (byComponentCompliance subFun complianceFilters) b)) + (always (List.map Tuple3.first (byComponentCompliance subFun complianceFilters).rows)) + (always True) + +nodeValueCompliance : Model -> ComplianceFilters -> ItemFun NodeValueCompliance ValueCompliance NodeValueCompliance +nodeValueCompliance mod complianceFilters = + ItemFun + (\item model sortId -> + let + sortFunction = subItemOrder (valueCompliance complianceFilters) model sortId + in + item.values + |> List.filter (filterReportsByCompliance complianceFilters) + |> List.sortWith sortFunction + ) + (\_ i -> i) + [ ("Node", (\nId -> span[][text nId.name, goToBtn (getNodeLink mod.contextPath nId.nodeId.value)]), (\d1 d2 -> N.compare d1.name d2.name)) + , ("Compliance", .complianceDetails >> buildComplianceBar complianceFilters , (\d1 d2 -> Basics.compare d1.compliance d2.compliance)) + ] + (.nodeId >> .value) + (Just (\item -> showComplianceDetails (valueCompliance complianceFilters) item)) + (always (List.map Tuple3.first (valueCompliance complianceFilters).rows)) + (filterDetailsByCompliance complianceFilters) + +showComplianceDetails : ItemFun item subItems data -> item -> String -> Dict String (String, SortOrder) -> Model -> List (Html Msg) +showComplianceDetails fun compliance parent openedRows model = + let + itemRows = List.map Tuple3.second (fun.rows) + data = fun.data model compliance + detailsRows = List.map (\row -> td [class "ok"] [row data]) itemRows + id = fun.id compliance + rowId = parent ++ "/" ++ id + rowOpened = Dict.get rowId openedRows + defaultSort = Maybe.withDefault "" (List.head (fun.subItemRows compliance)) + clickEvent = + if Maybe.Extra.isJust fun.childDetails then + [ onClick (ToggleRow rowId defaultSort) ] + else + [] + (details, classes) = + case (fun.childDetails, rowOpened) of + (Just detailsFun, Just (sortId, sortOrder)) -> + let + childrenSort = fun.children compliance model sortId + (children, order, newOrder) = case sortOrder of + Asc -> (childrenSort, "asc", Desc) + Desc -> (List.reverse childrenSort, "desc", Asc) + in + ( + [ tr [ class "details" ] + [ td [ class "details", colspan 2 ] + [ div [ class "innerDetails" ] + [ + table [class "dataTable compliance-table"] [ + thead [] [ + tr [ class "head" ] + (List.map (\row -> th [onClick (ToggleRowSort rowId row (if row == sortId then newOrder else Asc)) , class ("sorting" ++ (if row == sortId then "_"++order else "")) ] [ text row ]) (fun.subItemRows compliance) ) + ] + , tbody [] + ( if(List.isEmpty children) then + [ tr [] [ td [colspan 2, class "dataTables_empty" ] [ text "There is no compliance details" ] ] ] + else + List.concatMap (\child -> + (detailsFun child) rowId openedRows model + ) children + ) + ] + ] + ] ] ], + "row-foldable row-open") + (Just _, Nothing) -> ([], "row-foldable row-folded") + (Nothing, _) -> ([],"") + in + (tr ( class classes :: clickEvent) + detailsRows) + :: details + +searchFieldRuleCompliance r = + [ r.ruleId.value + , r.name + ] + +searchFieldNodeCompliance n = + [ n.nodeId.value + , n.name + ] + +filterSearch : String -> List String -> Bool +filterSearch filterString searchFields = + let + -- Join all the fields into one string to simplify the search + stringToCheck = searchFields + |> String.join "|" + |> String.toLower + + searchString = filterString + |> String.toLower + |> String.trim + in + String.contains searchString stringToCheck + +-- WARNING: +-- +-- Here the content is an HTML so it need to be already escaped. +badgePolicyMode : String -> String -> Html Msg +badgePolicyMode globalPolicyMode policyMode = + let + mode = if policyMode == "default" then globalPolicyMode else policyMode + defaultMsg = "This mode is the globally defined default. You can change it in the global settings." + msg = + case mode of + "enforce" -> "
This rule is in enforce mode.
" ++ defaultMsg + "audit" -> "
This rule is in audit mode.
" ++ defaultMsg + "mixed" -> + """ +
This rule is in mixed mode.
+ This rule is applied on at least one node or directive that will enforce + one configuration, and at least one that will audit them. + """ + _ -> "Unknown policy mode" + + in + span [class ("treeGroupName rudder-label label-sm label-" ++ mode), attribute "data-bs-toggle" "tooltip", attribute "data-bs-placement" "bottom", title (buildTooltipContent "Policy mode" msg)][] + + +buildTooltipContent : String -> String -> String +buildTooltipContent title content = + let + headingTag = "

" + contentTag = "

" + closeTag = "
" + in + headingTag ++ title ++ contentTag ++ content ++ closeTag + +buildComplianceReport : List Report -> Html Msg +buildComplianceReport reports = + let + complianceTxt : String -> String + complianceTxt val = + case val of + "reportsDisabled" -> "Reports Disabled" + "noReport" -> "No report" + "error" -> "Error" + "successAlreadyOK" -> "Success" + "successRepaired" -> "Repaired" + "successNotApplicable" -> "Not applicable" + "applying" -> "Applying" + "auditNotApplicable" -> "Not applicable" + "unexpectedUnknownComponent" -> "Unexpected" + "unexpectedMissingComponent" -> "Missing" + "enforceNotApplicable" -> "Not applicable" + "auditError" -> "Error" + "auditCompliant" -> "Compliant" + "auditNonCompliant" -> "Non compliant" + "badPolicyMode" -> "Bad Policy Mode" + _ -> val + in + td [class "report-compliance"] + [ div[] + ( List.map (\r -> span[class r.status][text (complianceTxt r.status)]) reports ) + ] + +getRuleLink : String -> RuleId -> String +getRuleLink contextPath id = + contextPath ++ "/secure/configurationManager/ruleManagement/rule/" ++ id.value + +getDirectiveLink : String -> DirectiveId -> String +getDirectiveLink contextPath id = + contextPath ++ """/secure/configurationManager/directiveManagement#{"directiveId":" """++ id.value ++ """ "} """ + +getNodeLink : String -> String -> String +getNodeLink contextPath id = + contextPath ++ "/secure/nodeManager/node/" ++ id + + +goToBtn : String -> Html Msg +goToBtn link = + a [ class "btn-goto", href link , onCustomClick (GoTo link)] [ i[class "fa fa-pen"][] ] + +goToIcon : Html Msg +goToIcon = + span [ class "btn-goto" ] [ i[class "fa fa-pen"][] ] + +generateLoadingTable : Html Msg +generateLoadingTable = + div [class "table-container skeleton-loading", style "margin-top" "17px"] + [ div [class "dataTables_wrapper_top table-filter"] + [ div [class "form-group"] + [ span[][] + ] + ] + , table [class "dataTable"] + [ thead [] + [ tr [class "head"] + [ th [][ span[][] ] + , th [][ span[][] ] + ] + ] + , tbody [] + [ tr[] [ td[][span[style "width" "45%"][]], td[][span[][]]] + , tr[] [ td[][span[][]], td[][span[][]] ] + , tr[] [ td[][span[style "width" "30%"][]], td[][span[][]] ] + , tr[] [ td[][span[style "width" "75%"][]], td[][span[][]] ] + , tr[] [ td[][span[][]], td[][span[][]] ] + , tr[] [ td[][span[style "width" "45%"][]], td[][span[][]] ] + , tr[] [ td[][span[][]], td[][span[][]] ] + , tr[] [ td[][span[style "width" "70%"][]], td[][span[][]] ] + , tr[] [ td[][span[][]], td[][span[][]] ] + , tr[] [ td[][span[][]], td[][span[][]] ] + , tr[] [ td[][span[style "width" "80%"][]], td[][span[][]] ] + , tr[] [ td[][span[style "width" "30%"][]], td[][span[][]] ] + , tr[] [ td[][span[style "width" "75%"][]], td[][span[][]] ] + , tr[] [ td[][span[style "width" "45%"][]], td[][span[][]] ] + , tr[] [ td[][span[][]], td[][span[][]] ] + , tr[] [ td[][span[style "width" "70%"][]], td[][span[][]] ] + , tr[] [ td[][span[][]], td[][span[][]] ] + ] + ] + ] diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Groupcompliance.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Groupcompliance.elm new file mode 100644 index 00000000000..211457f3985 --- /dev/null +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Groupcompliance.elm @@ -0,0 +1,179 @@ +port module Groupcompliance exposing (..) + +import Browser +import Browser.Navigation as Nav +import Dict +import Dict.Extra +import Http exposing (..) +import Result +import String exposing (replace) +import File +import File.Download +import File.Select + +import GroupCompliance.ApiCalls exposing (..) +import GroupCompliance.DataTypes exposing (..) +import GroupCompliance.Init exposing (init) +import GroupCompliance.View exposing (view) + + +-- PORTS / SUBSCRIPTIONS +port errorNotification : String -> Cmd msg +port initTooltips : String -> Cmd msg +port loadCompliance : (String -> msg) -> Sub msg + + +subscriptions : Model -> Sub Msg +subscriptions _ = + loadCompliance (\_ -> LoadCompliance GlobalCompliance) -- default to global compliance + +main = + Browser.element + { init = init + , view = view + , update = update + , subscriptions = subscriptions + } + +-- +-- update loop -- +-- +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + CallApi apiCall -> + ( model , apiCall model) + Ignore -> + ( model , Cmd.none) + + UpdateFilters newFilters -> + let + ui = model.ui + newUi = case ui.viewMode of + RulesView -> { ui | ruleFilters = newFilters} + NodesView -> { ui | nodeFilters = newFilters} + in + ({model | ui = newUi}, initTooltips "") + + UpdateComplianceFilters newFilters -> + let + ui = model.ui + newUi = { ui | complianceFilters = newFilters } + in + ({model | ui = newUi}, initTooltips "") + + ChangeViewMode mode -> + let + ui = model.ui + in + ({model | ui = { ui | viewMode = mode}}, initTooltips "") + + GoTo link -> (model, Nav.load link) + + ToggleRow rowId defaultSortId -> + let + ui = model.ui + filters = case ui.viewMode of + RulesView -> ui.ruleFilters + NodesView -> ui.nodeFilters + newFilters = + { filters | openedRows = if Dict.member rowId filters.openedRows then + Dict.remove rowId filters.openedRows + else + Dict.insert rowId (defaultSortId, Asc) filters.openedRows + } + newUi = case ui.viewMode of + RulesView -> { ui | ruleFilters = newFilters} + NodesView -> { ui | nodeFilters = newFilters} + newModel = { model | ui = newUi } + in + (newModel, Cmd.none) + + ToggleRowSort rowId sortId order -> + let + ui = model.ui + tableFilters = case ui.viewMode of + RulesView -> ui.ruleFilters + NodesView -> ui.nodeFilters + newFilters = { tableFilters | openedRows = Dict.update rowId (always (Just (sortId,order))) tableFilters.openedRows } + newUi = case ui.viewMode of + RulesView -> { ui | ruleFilters = newFilters} + NodesView -> { ui | nodeFilters = newFilters} + newModel = { model | ui = newUi } + in + (newModel, Cmd.none) + + GetPolicyModeResult res -> + case res of + Ok p -> + ( { model | policyMode = p } + , Cmd.none + ) + Err err -> + processApiError "Getting Policy Mode" err model + + GetGroupComplianceResult res -> + let + ui = model.ui + newModel = {model | ui = {ui | loading = False}} + in + case res of + Ok compliance -> + ( { newModel | groupCompliance = Just compliance } + , Cmd.none + ) + Err err -> + processApiError "Getting group compliance" err newModel + + --TODO later + --Export res -> + -- case res of + -- Ok content -> + -- (model, File.Download.string (model.directiveId.value ++ ".csv") "text/csv" content) + -- Err err -> + -- processApiError "Export directive compliance" err model + + LoadCompliance complianceScope -> + let + ui = model.ui + shouldReload = ui.loaded && complianceScope == model.complianceScope + getCompliance = case complianceScope of + GlobalCompliance -> getGlobalGroupCompliance + TargetedCompliance -> getTargetedGroupCompliance + actions = if not shouldReload then + Cmd.none + else + Cmd.batch + [ getCompliance model + ] + newModel = {model | complianceScope = complianceScope, ui = {ui | loading = shouldReload, loaded = True}} + in + ( newModel + , actions + ) +processApiError : String -> Error -> Model -> ( Model, Cmd Msg ) +processApiError apiName err model = + let + modelUi = model.ui + message = + case err of + Http.BadUrl url -> + "The URL " ++ url ++ " was invalid" + Http.Timeout -> + "Unable to reach the server, try again" + Http.NetworkError -> + "Unable to reach the server, check your network connection" + Http.BadStatus 500 -> + "The server had a problem, try again later" + Http.BadStatus 400 -> + "Verify your information and try again" + Http.BadStatus _ -> + "Unknown error" + Http.BadBody errorMessage -> + errorMessage + + in + (model, errorNotification ("Error when "++apiName ++", details: \n" ++ message ) ) + +getUrl : Model -> String +getUrl model = model.contextPath ++ "/secure/nodeManager/groups" \ No newline at end of file diff --git a/webapp/sources/rudder/rudder-web/src/main/scala/bootstrap/liftweb/RudderConfig.scala b/webapp/sources/rudder/rudder-web/src/main/scala/bootstrap/liftweb/RudderConfig.scala index a26a37888da..3c40eac79ce 100644 --- a/webapp/sources/rudder/rudder-web/src/main/scala/bootstrap/liftweb/RudderConfig.scala +++ b/webapp/sources/rudder/rudder-web/src/main/scala/bootstrap/liftweb/RudderConfig.scala @@ -1135,6 +1135,7 @@ object RudderConfig extends Loggable { val clearCacheService: ClearCacheService = rci.clearCacheService val cmdbQueryParser: CmdbQueryParser = rci.cmdbQueryParser val commitAndDeployChangeRequest: CommitAndDeployChangeRequestService = rci.commitAndDeployChangeRequest + val complianceService: ComplianceAPIService = rci.complianceService val configService: ReadConfigService with UpdateConfigService = rci.configService val configurationRepository: ConfigurationRepository = rci.configurationRepository val databaseManager: DatabaseManager = rci.databaseManager @@ -1284,6 +1285,7 @@ case class RudderServiceApi( eventLogRepository: EventLogRepository, eventLogDetailsService: EventLogDetailsService, reportingService: ReportingService, + complianceService: ComplianceAPIService, asyncComplianceService: AsyncComplianceService, debugScript: DebugInfoService, cmdbQueryParser: CmdbQueryParser, @@ -3536,6 +3538,7 @@ object RudderConfigInit { eventLogRepository, eventLogDetailsServiceImpl, reportingServiceImpl, + complianceAPIService, asynComplianceService, scriptLauncher, queryParser, diff --git a/webapp/sources/rudder/rudder-web/src/main/scala/com/normation/rudder/web/components/NodeGroupForm.scala b/webapp/sources/rudder/rudder-web/src/main/scala/com/normation/rudder/web/components/NodeGroupForm.scala index 3c0d3ceed56..2aa1801e36a 100644 --- a/webapp/sources/rudder/rudder-web/src/main/scala/com/normation/rudder/web/components/NodeGroupForm.scala +++ b/webapp/sources/rudder/rudder-web/src/main/scala/com/normation/rudder/web/components/NodeGroupForm.scala @@ -44,7 +44,9 @@ import com.normation.rudder.AuthorizationType import com.normation.rudder.domain.nodes._ import com.normation.rudder.domain.policies._ import com.normation.rudder.domain.queries.Query +import com.normation.rudder.domain.reports.ComplianceLevelSerialisation import com.normation.rudder.domain.workflows.ChangeRequestId +import com.normation.rudder.facts.nodes.QueryContext import com.normation.rudder.repository.FullNodeGroupCategory import com.normation.rudder.services.workflows.DGModAction import com.normation.rudder.services.workflows.NodeGroupChangeRequest @@ -98,12 +100,14 @@ class NodeGroupForm( onFailureCallback: () => JsCmd = { () => Noop } ) extends DispatchSnippet with DefaultExtendableSnippet[NodeGroupForm] with Loggable { import NodeGroupForm._ + implicit private[this] val qc: QueryContext = CurrentUser.queryContext private[this] val nodeFactRepo = RudderConfig.nodeFactRepository private[this] val categoryHierarchyDisplayer = RudderConfig.categoryHierarchyDisplayer private[this] val workflowLevelService = RudderConfig.workflowLevelService private[this] val dependencyService = RudderConfig.dependencyAndDeletionService private[this] val roNodeGroupRepository = RudderConfig.roNodeGroupRepository + private[this] val complianceService = RudderConfig.complianceService private[this] val nodeGroupCategoryForm = new LocalSnippet[NodeGroupCategoryForm] private[this] val nodeGroupForm = new LocalSnippet[NodeGroupForm] @@ -178,12 +182,36 @@ class NodeGroupForm( OnLoad(JsRaw("$('#GroupTabs').tabs( {active : 0 });")) ) - (nodeGroup match { - case Left(target) => showFormTarget(target) - case Right(group) if (group.isSystem) => showFormTarget(GroupTarget(group.id)) + nodeGroup match { + case Left(target) => showFormTarget(target)(html) + case Right(group) if (group.isSystem) => showFormTarget(GroupTarget(group.id))(html) case Right(group) => - showFormNodeGroup(group) - })(html) + showFormNodeGroup(group)(html) ++ + Script( + OnLoad( + JsRaw(s""" + |var main = document.getElementById("groupComplianceApp") + |var initValues = { + | groupId : "${group.id.uid.value}", + | contextPath : contextPath + |}; + |var app = Elm.Groupcompliance.init({node: main, flags: initValues}); + |app.ports.errorNotification.subscribe(function(str) { + | createErrorNotification(str) + |}); + |// Initialize tooltips + |app.ports.initTooltips.subscribe(function(msg) { + | setTimeout(function(){ + | initBsTooltips(); + | }, 400); + |}); + |$$("#complianceLinkTab").on("click", function (){ + | app.ports.loadCompliance.send(""); + |}); + |""".stripMargin) + ) + ) + } } private[this] def showRulesForTarget(target: SimpleTarget): NodeSeq = { @@ -196,7 +224,15 @@ class NodeGroupForm( t => rootCategory.allTargets.get(t).map(_.name).getOrElse(t.target), _.name ) - private[this] def showFormNodeGroup(nodeGroup: NodeGroup): CssSel = { + + private[this] def showComplianceForGroup(progressBarSelector: String, optComplianceArray: Option[JsArray]) = { + nodeGroup.toOption.map(g => { + val complianceHtml = optComplianceArray.map(js => s"buildComplianceBar(${js.toJsCmd})").getOrElse("\"No report\"") + Script(JsRaw(s"""$$("${progressBarSelector}").html(${complianceHtml});""")) + }) + } + + private[this] def showFormNodeGroup(nodeGroup: NodeGroup): CssSel = { val nodesSel = "#gridResult" #> NodeSeq.Empty val nodes = nodesSel(searchNodeComponent.get match { case Full(req) => req.buildQuery(true) @@ -206,11 +242,11 @@ class NodeGroupForm( "#group-name" #> groupNameString & "group-pendingchangerequest" #> PendingChangeRequestDisplayer.checkByGroup(pendingChangeRequestXml, nodeGroup.id) & "group-name" #> groupName.toForm_! - & "group-rudderid" #>
+ & "group-rudderid" #>
- & "group-cfeclasses" #>
+ & "group-cfeclasses" #>
{RuleTarget.toCFEngineClassName(nodeGroup.id.serialize)}
@@ -258,6 +294,15 @@ class NodeGroupForm( case eb: EmptyBox => Error when retrieving the request, please try again }) + & ".groupGlobalComplianceProgressBar *" #> showComplianceForGroup( + ".groupGlobalComplianceProgressBar", + loadComplianceBar(true) + ) + & ".groupTargetedComplianceProgressBar *" #> showComplianceForGroup( + ".groupTargetedComplianceProgressBar", + loadComplianceBar(false) + ) + & ".id-value *" #> nodeGroup.id.serialize ) } @@ -269,7 +314,7 @@ class NodeGroupForm(
  • Parameters
  • Related rules
  • - & "group-rudderid" #>
    + & "group-rudderid" #>
    @@ -289,7 +334,8 @@ class NodeGroupForm( case Full(req) => req.displayNodesTable case eb: EmptyBox => Error when retrieving the request, please try again - })) + }) + & ".show-compliance" #> NodeSeq.Empty) } def showGroupProperties(group: NodeGroup): NodeSeq = { @@ -352,6 +398,16 @@ class NodeGroupForm( |""".stripMargin))) } + private def loadComplianceBar(isGlobalCompliance: Boolean): Option[JsArray] = { + for { + group <- nodeGroup.toOption + compliance <- + complianceService.getNodeGroupCompliance(group.id, level = Some(1), isGlobalCompliance = isGlobalCompliance).toOption + } yield { + ComplianceLevelSerialisation.ComplianceLevelToJs(compliance.compliance).toJsArray + } + } + ///////////// fields for category settings /////////////////// private[this] val groupName = { @@ -374,9 +430,9 @@ class NodeGroupForm( new WBTextAreaField("Description", desc) { override def setFilter = notNull _ :: trim _ :: Nil override def className = "form-control" - override def labelClassName = "row col-xs-12" - override def subContainerClassName = "row col-xs-12" - override def containerClassName = "col-xs-6" + override def labelClassName = "col-12" + override def subContainerClassName = "col-12" + override def containerClassName = "col-6" override def errorClassName = "field_errors paddscala" override def inputAttributes: Seq[(String, String)] = Seq(("rows", "15")) override def labelExtensions: NodeSeq = { diff --git a/webapp/sources/rudder/rudder-web/src/main/style/rudder/rudder-groups.css b/webapp/sources/rudder/rudder-web/src/main/style/rudder/rudder-groups.css index 570060aedb1..9d716aba37b 100644 --- a/webapp/sources/rudder/rudder-web/src/main/style/rudder/rudder-groups.css +++ b/webapp/sources/rudder/rudder-web/src/main/style/rudder/rudder-groups.css @@ -94,6 +94,21 @@ input[disabled] + .save-tooltip { margin-bottom: 10px; } +.id-label { + margin-top: 5px; +} +.id-value { + display:inline-block; +} +.clipboard-icon { + display:inline-block; + margin-left:15px; +} +.clipboard-icon:hover { + cursor: pointer; + color: #0366d6; +} + content-query{ display:block; } diff --git a/webapp/sources/rudder/rudder-web/src/main/webapp/secure/nodeManager/groups.html b/webapp/sources/rudder/rudder-web/src/main/webapp/secure/nodeManager/groups.html index 47e665d8ad1..af9410ca75e 100644 --- a/webapp/sources/rudder/rudder-web/src/main/webapp/secure/nodeManager/groups.html +++ b/webapp/sources/rudder/rudder-web/src/main/webapp/secure/nodeManager/groups.html @@ -3,6 +3,7 @@ Rudder - Node Groups Management + diff --git a/webapp/sources/rudder/rudder-web/src/main/webapp/templates-hidden/components/NodeGroupForm.html b/webapp/sources/rudder/rudder-web/src/main/webapp/templates-hidden/components/NodeGroupForm.html index baf1b21c7a0..2123181f110 100644 --- a/webapp/sources/rudder/rudder-web/src/main/webapp/templates-hidden/components/NodeGroupForm.html +++ b/webapp/sources/rudder/rudder-web/src/main/webapp/templates-hidden/components/NodeGroupForm.html @@ -51,36 +51,56 @@

  • Criteria
  • Related rules
  • Properties
  • +
  • Compliance
  • -
    - - - -
    - -
    -
    -

    No description defined, click on to edit

    +
    +
    +
    + + + +
    + +
    +
    +

    No description defined, click on to edit

    +
    +
    +
    +
    + Here comes the longDescription field +
    +
    + +
    +
    +
    + + +
    -
    -
    - Here comes the longDescription field -
    -
    - -
    +
    +
    +
    + +
    + +
    +
    + +
    +

    The node group id

    + +
    - - -
    @@ -91,15 +111,18 @@

    +
    +
    +
    -
    -
    -
    +

    +
    +