diff --git a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/plugins/PluginSettings.scala b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/plugins/PluginSettings.scala index 6582b3115c4..6ef61d895a1 100644 --- a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/plugins/PluginSettings.scala +++ b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/plugins/PluginSettings.scala @@ -706,7 +706,7 @@ object RudderPackagePlugin { trait RudderPackageService { import RudderPackageService.* - def update(): IOResult[Option[CredentialError]] + def update(): IOResult[Option[PluginSettingsError]] def listAllPlugins(): IOResult[Chunk[RudderPackagePlugin]] @@ -718,18 +718,27 @@ trait RudderPackageService { object RudderPackageService { - val ERROR_CODE: Int = 1 - - // see PluginSettings : the url and credentials configuration could cause errors : - final case class CredentialError(msg: String) extends RudderError - - object CredentialError { - private val regex = "^.*ERROR.* (Received an HTTP 401 Unauthorized error.*)$".r - - def fromResult(cmdResult: CmdResult): Option[CredentialError] = { - (cmdResult.code, cmdResult.stderr.strip) match { // do not forget to strip stderr - case (ERROR_CODE, regex(err)) => Some(CredentialError(err)) - case _ => None + // PluginSettings configuration could cause errors with known codes and colored stderr in rudder-package + sealed abstract class PluginSettingsError extends RudderError + object PluginSettingsError { + final case class InvalidCredentials(override val msg: String) extends PluginSettingsError + final case class Unauthorized(override val msg: String) extends PluginSettingsError + + private val colorRegex = "\u001b\\[[0-9;]*m".r + private val uncolor = (str: String) => colorRegex.replaceAllIn(str, "") + + private val sanitizeCmdResult = (res: CmdResult) => res.transform(uncolor.compose(_.strip)) + + /** + * Maps known errors codes from rudder package and adapt the error message to have no color + */ + def fromResult(cmdResult: CmdResult): PureResult[Option[PluginSettingsError]] = { + val result = sanitizeCmdResult(cmdResult) + result.code match { + case 0 => Right(None) + case 2 => Right(Some(PluginSettingsError.InvalidCredentials(result.stderr))) + case 3 => Right(Some(PluginSettingsError.Unauthorized(result.stderr))) + case _ => Left(Inconsistency(result.debugString())) } } } @@ -746,17 +755,16 @@ class RudderPackageCmdService(configCmdLine: String) extends RudderPackageServic case h :: tail => Right((h, tail)) } - override def update(): IOResult[Option[CredentialError]] = { + override def update(): IOResult[Option[PluginSettingsError]] = { // In case of error we need to check the result for { res <- runCmd("update" :: Nil) (cmd, result) = res - err = CredentialError.fromResult(result) - _ <- ZIO.when(result.code != 0 && err.isEmpty) { - Inconsistency( - s"An error occurred while updating plugins list with '${cmd.display}':\n code: ${result.code}\n stderr: ${result.stderr}\n stdout: ${result.stdout}" - ).fail - } + err <- + PluginSettingsError + .fromResult(result) + .toIO + .chainError(s"An error occurred while updating plugins list with '${cmd.display}'") } yield { err } @@ -783,7 +791,7 @@ class RudderPackageCmdService(configCmdLine: String) extends RudderPackageServic } override def removePlugins(plugins: Chunk[String]): IOResult[Unit] = { - runCmdOrFail("remove")( + runCmdOrFail("remove" :: plugins.toList)( s"An error occurred while removing plugins" ).unit } @@ -808,15 +816,9 @@ class RudderPackageCmdService(configCmdLine: String) extends RudderPackageServic (cmd, result) } } - private def runCmdOrFail(params: String*)(errorMsg: String): IOResult[CmdResult] = { - runCmdOrFail(params.toList)(errorMsg) - } private def runCmdOrFail(params: List[String])(errorMsg: String): IOResult[CmdResult] = { runCmd(params).reject { - case (cmd, result) if result.code != 0 => - Inconsistency( - s"${errorMsg} with '${cmd.display}':\n code: ${result.code}\n stderr: ${result.stderr}\n stdout: ${result.stdout}" - ) + case (cmd, result) if result.code != 0 => Inconsistency(s"${errorMsg} with '${cmd.display}': ${result.debugString()}") }.map(_._2) } } @@ -826,7 +828,7 @@ class RudderPackageCmdService(configCmdLine: String) extends RudderPackageServic */ trait PluginSystemService { - def updateIndex(): IOResult[Option[CredentialError]] + def updateIndex(): IOResult[Option[PluginSettingsError]] def list(): IOResult[Chunk[JsonPluginSystemDetails]] def install(plugins: Chunk[PluginId]): IOResult[Unit] def remove(plugins: Chunk[PluginId]): IOResult[Unit] @@ -838,7 +840,7 @@ trait PluginSystemService { * Implementation for tests, will do any operation without any error */ class InMemoryPluginSystemService(ref: Ref[Map[PluginId, JsonPluginSystemDetails]]) extends PluginSystemService { - override def updateIndex(): IOResult[Option[CredentialError]] = { + override def updateIndex(): IOResult[Option[PluginSettingsError]] = { ZIO.none } diff --git a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/hooks/RunNuCommand.scala b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/hooks/RunNuCommand.scala index ff86283de73..409fafd9409 100644 --- a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/hooks/RunNuCommand.scala +++ b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/hooks/RunNuCommand.scala @@ -82,7 +82,24 @@ import zio.syntax.* final case class Cmd(cmdPath: String, parameters: List[String], environment: Map[String, String], cwdPath: Option[String]) { def display: String = s"${cmdPath} ${parameters.mkString(" ")}" } -final case class CmdResult(code: Int, stdout: String, stderr: String) +final case class CmdResult(code: Int, stdout: String, stderr: String) { + + /** + * Display the attributes of this result, by default each on a new line with indentation + */ + def debugString(sep: String = "\n "): String = s"code: ${code}${sep}stderr: ${stderr}${sep}stdout: ${stdout}" + + /** + * Strips the stdout and stderr of the result, may be useful when matching with a regex or for display purpose + */ + def strip: CmdResult = transform(_.strip) + + /** + * Apply some transformation (e.g. sanitizing on stdout and stderr) + */ + def transform(f: String => String): CmdResult = copy(stdout = f(stdout), stderr = f(stderr)) + +} object RunNuCommand { diff --git a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/ncf/EditorTechniqueReader.scala b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/ncf/EditorTechniqueReader.scala index 3add113f958..f4809d19dd9 100644 --- a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/ncf/EditorTechniqueReader.scala +++ b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/ncf/EditorTechniqueReader.scala @@ -154,7 +154,7 @@ class EditorTechniqueReaderImpl( _ <- ZIO.when(res.code != 0)( Inconsistency( - s"An error occurred while updating generic methods library with command '${cmd.display}':\n code: ${res.code}\n stderr: ${res.stderr}\n stdout: ${res.stdout}" + s"An error occurred while updating generic methods library with command '${cmd.display}':\n ${res.debugString(sep = "\n ")}" ).fail ) // write file diff --git a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/services/healthcheck/HealthcheckService.scala b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/services/healthcheck/HealthcheckService.scala index d33f8ba7f72..0e182ecedd2 100644 --- a/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/services/healthcheck/HealthcheckService.scala +++ b/webapp/sources/rudder/rudder-core/src/main/scala/com/normation/rudder/services/healthcheck/HealthcheckService.scala @@ -149,7 +149,7 @@ final class CheckFileDescriptorLimit(val nodeFactRepository: NodeFactRepository) res <- fdLimitCmd.await _ <- ZIO.when(res.code != 0) { Inconsistency( - s"An error occurred while getting file descriptor soft limit with command '${cmd.display}':\n code: ${res.code}\n stderr: ${res.stderr}\n stdout: ${res.stdout}" + s"An error occurred while getting file descriptor soft limit with command '${cmd.display}':\n ${res.debugString(sep = "\n ")}" ).fail } limit <- IOResult.attempt(res.stdout.trim.toLong) diff --git a/webapp/sources/rudder/rudder-core/src/test/scala/com/normation/plugins/TestRudderPackageService.scala b/webapp/sources/rudder/rudder-core/src/test/scala/com/normation/plugins/TestRudderPackageService.scala index 36efb6d2b10..317cd99f0bc 100644 --- a/webapp/sources/rudder/rudder-core/src/test/scala/com/normation/plugins/TestRudderPackageService.scala +++ b/webapp/sources/rudder/rudder-core/src/test/scala/com/normation/plugins/TestRudderPackageService.scala @@ -35,6 +35,8 @@ ************************************************************************************* */ package com.normation.plugins + +import com.normation.errors.* import com.normation.rudder.hooks.CmdResult import org.junit.runner.RunWith import org.specs2.mutable.Specification @@ -44,19 +46,29 @@ import org.specs2.runner.JUnitRunner class TestRudderPackageService extends Specification { "RudderPackageService" should { - "handle credentials error" in { + "handle zero error code and message" in { + RudderPackageService.PluginSettingsError.fromResult(CmdResult(0, "", "OK")) must beRight(beNone) + } + "handle non-zero error code and message" in { val res = CmdResult( - 1, + 2, "", - "ERROR Received an HTTP 401 Unauthorized error when trying to get 8.2/rpkg.index. Please check your credentials in the configuration." + "\u001b[31mERROR\u001b[0m Invalid credentials, please check your credentials in the configuration. (received HTTP 401)\n" ) - RudderPackageService.CredentialError.fromResult(res).aka("CredentialError from cmd result") must beSome( - beEqualTo( - RudderPackageService.CredentialError( - "Received an HTTP 401 Unauthorized error when trying to get 8.2/rpkg.index. Please check your credentials in the configuration." + RudderPackageService.PluginSettingsError.fromResult(res).aka("PluginSettingsError from cmd result") must beRight( + beSome( + beEqualTo( + RudderPackageService.PluginSettingsError.InvalidCredentials( + "ERROR Invalid credentials, please check your credentials in the configuration. (received HTTP 401)" + ) ) ) ) } + "handle unknown error code and message" in { + RudderPackageService.PluginSettingsError.fromResult( + CmdResult(12345, "", "\u001b[31mERROR\u001b[0m Unknown error") + ) must beLeft(beLike[RudderError] { case err: Inconsistency => err.msg must contain("ERROR Unknown error") }) + } } } diff --git a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/RudderJsonResponse.scala b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/RudderJsonResponse.scala index ee51f127bda..d790de01332 100644 --- a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/RudderJsonResponse.scala +++ b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/RudderJsonResponse.scala @@ -124,8 +124,19 @@ object RudderJsonResponse { def fromSchema(schema: EndpointSchema): ResponseSchema = ResponseSchema(schema.name, schema.dataContainer) } - sealed trait ResponseError + sealed trait ResponseError { + def errorMsg: Option[String] + def toLiftErrorResponse(id: Option[String], schema: ResponseSchema)(implicit + prettify: Boolean + ): LiftResponse = this match { + case UnauthorizedError(errorMsg) => unauthorizedError(id, schema, errorMsg) + case ForbiddenError(errorMsg) => forbiddenError(id, schema, errorMsg) + case NotFoundError(errorMsg) => notFoundError(id, schema, errorMsg) + } + } final case class UnauthorizedError(errorMsg: Option[String]) extends ResponseError + final case class ForbiddenError(errorMsg: Option[String]) extends ResponseError + final case class NotFoundError(errorMsg: Option[String]) extends ResponseError //////////////////////////// utility methods to build responses //////////////////////////// @@ -224,12 +235,12 @@ object RudderJsonResponse { ): LiftJsonResponse[JsonRudderApiResponse[Unit]] = { generic.unauthorizedError(JsonRudderApiResponse.error(id, schema, errorMsg)) } - def notFoundError(id: Option[String], schema: ResponseSchema, errorMsg: String)(implicit + def notFoundError(id: Option[String], schema: ResponseSchema, errorMsg: Option[String])(implicit prettify: Boolean ): LiftJsonResponse[JsonRudderApiResponse[Unit]] = { generic.notFoundError(JsonRudderApiResponse.error(id, schema, errorMsg)) } - def forbiddenError(id: Option[String], schema: ResponseSchema, errorMsg: String)(implicit + def forbiddenError(id: Option[String], schema: ResponseSchema, errorMsg: Option[String])(implicit prettify: Boolean ): LiftJsonResponse[JsonRudderApiResponse[Unit]] = { generic.forbiddenError(JsonRudderApiResponse.error(id, schema, errorMsg)) @@ -332,12 +343,8 @@ object RudderJsonResponse { }, either => { ev.apply(either) match { - case Left(e) => - e match { - case UnauthorizedError(errorMsg) => unauthorizedError(id.error, schema, errorMsg) - } - case Right(b) => - successOne[B](schema, b, id.success(either)) + case Left(e) => e.toLiftErrorResponse(id.error, schema) + case Right(b) => successOne[B](schema, b, id.success(either)) } } ) @@ -354,8 +361,7 @@ object RudderJsonResponse { toLiftResponseOneEither(params, ResponseSchema.fromSchema(schema), SuccessIdTrace(id))(JsonEncoder[B], ev) } - // create a response from specific API errors modeled as an Either[ResponseError, Any] (the "zero" is for : "there is nothing on the right") - private def toLiftResponseZeroEither( + def toLiftResponseZeroEither( params: DefaultParams, schema: ResponseSchema, id: IdTrace @@ -369,12 +375,8 @@ object RudderJsonResponse { }, either => { ev.apply(either) match { - case Left(e) => - e match { - case UnauthorizedError(errorMsg) => unauthorizedError(id.error, schema, errorMsg) - } - case Right(_) => - successZero(schema) + case Left(e) => e.toLiftErrorResponse(id.error, schema) + case Right(_) => successZero(schema) } } ) diff --git a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/internal/PluginInternalApi.scala b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/internal/PluginInternalApi.scala index d2974b16f81..3c64cd9601c 100644 --- a/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/internal/PluginInternalApi.scala +++ b/webapp/sources/rudder/rudder-rest/src/main/scala/com/normation/rudder/rest/internal/PluginInternalApi.scala @@ -43,6 +43,7 @@ import com.normation.plugins.PluginId import com.normation.plugins.PluginSettings import com.normation.plugins.PluginSystemService import com.normation.plugins.PluginSystemStatus +import com.normation.plugins.RudderPackageService.PluginSettingsError import com.normation.rudder.api.ApiVersion import com.normation.rudder.domain.logger.ApplicationLoggerPure import com.normation.rudder.rest.ApiModuleProvider @@ -87,7 +88,11 @@ class PluginInternalApi( .updateIndex() .chainError("Could not update plugins index") .tapError(err => ApplicationLoggerPure.Plugin.error(err.fullMsg)) - .map(_.map(err => RudderJsonResponse.UnauthorizedError(Some(err.msg))).toLeft(())) + .map(_.map { + // the corresponding HTTP errors are the following ones + case PluginSettingsError.InvalidCredentials(msg) => RudderJsonResponse.UnauthorizedError(Some(msg)) + case PluginSettingsError.Unauthorized(msg) => RudderJsonResponse.ForbiddenError(Some(msg)) + }.toLeft(())) .toLiftResponseZeroEither(params, schema, None) } diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins.elm index 702ad43641a..51aaa9ab3e7 100644 --- a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins.elm +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins.elm @@ -1,18 +1,18 @@ port module Plugins exposing (update) --- fakeData - import Browser -import Bytes exposing (Bytes) -import Bytes.Decode +import Browser.Navigation +import Http import Http.Detailed as Detailed import Json.Decode exposing (..) import List exposing (drop, head) -import Plugins.ApiCalls exposing (getPluginInfos) +import Plugins.ApiCalls exposing (getPluginInfos, requestTypeAction) import Plugins.DataTypes exposing (..) import Plugins.Init exposing (init, subscriptions) import Plugins.View exposing (view) +import Process import String exposing (join, split) +import Task @@ -48,29 +48,38 @@ update msg model = CallApi apiCall -> ( model, apiCall model ) + RequestApi t -> + ( withLoading True model, requestTypeAction t model ) + ApiGetPlugins res -> case res of Ok ( _, { license, plugins } ) -> - ( { model | license = license, plugins = plugins }, Cmd.none ) + ( withLoading False { model | license = license, plugins = plugins }, Cmd.none ) Err err -> - processApiErrorString "Error while fetching information" err model + processApiError "Error while getting the list of plugins." err model -- We want to update all plugins information every time the index is updated - ApiPostPlugins (Ok UpdateIndex) -> - ( model, Cmd.batch [ successNotification ("Plugin " ++ requestTypeText UpdateIndex ++ " successful"), getPluginInfos model ] ) + ApiPostPlugins UpdateIndex (Ok _) -> + ( withLoading False model, Cmd.batch [ successNotification "Plugins list successfully updated.", getPluginInfos model ] ) + + ApiPostPlugins UpdateIndex (Err err) -> + processApiError "Error while trying to get the updated the list of plugins." err model - ApiPostPlugins res -> + ApiPostPlugins t res -> case res of - Ok t -> - ( model, successNotification ("Plugin " ++ requestTypeText t ++ " successful") ) + Ok _ -> + ( withLoading False model, successNotification ("Plugin " ++ requestTypeText t ++ " successful.") ) Err err -> - processApiErrorBytes "Error while fetching information" err model + processApiError ("Error while trying to " ++ requestTypeText t) err model SetModalState modalState -> ( { model | ui = (\ui -> { ui | modalState = modalState }) model.ui }, Cmd.none ) + ReloadPage -> + ( model, Browser.Navigation.reload ) + Copy s -> ( model, copy s ) @@ -81,24 +90,37 @@ update msg model = ( processSelect s model, Cmd.none ) - --- UpdateUI newUI -> --- ({model | ui = newUI}, Cmd.none) - - -processSpecificApiError : (a -> String) -> Detailed.Error a -> Model -> Maybe ( Model, Cmd Msg ) -processSpecificApiError errDetails err model = +processSpecificApiError : String -> Detailed.Error String -> Model -> Maybe ( Model, Cmd Msg ) +processSpecificApiError msg err model = case err of Detailed.BadStatus metadata body -> case metadata.statusCode of 401 -> Just ( withSettingsError - ( "There are credentials error related to plugin management. Please refresh the page after you update your configuration.", decodeErrorContent (errDetails body) ) + ( "There are credentials errors related to plugin management. Please refresh the list of plugins after you update your configuration credentials.", decodeErrorContent body ) + model + , errorNotification msg + ) + + 403 -> + Just + ( withSettingsError + ( "There are configuration errors related to plugin management. Please refresh the list of plugins after you update your configuration URL or check your access.", decodeErrorContent body ) model - , Cmd.none + , errorNotification msg ) + 502 -> + -- Bad Gateway may indicate that the server has probably restarted meanwhile to apply changes on plugins + Just + ( model, Cmd.batch [ waitAndReload 5000, successNotification "This page will reload automatically in a few seconds" ] ) + + 503 -> + -- Service Unavailable may indicate that the server has probably restarted meanwhile to apply changes on plugins + Just + ( model, Cmd.batch [ waitAndReload 5000, successNotification "This page will reload automatically in a few seconds" ] ) + _ -> Nothing @@ -106,8 +128,8 @@ processSpecificApiError errDetails err model = Nothing -processApiError : (a -> String) -> String -> Detailed.Error a -> Model -> ( Model, Cmd Msg ) -processApiError errDetails msg err model = +processApiError : String -> Detailed.Error String -> Model -> ( Model, Cmd Msg ) +processApiError msg err model = let message = case err of @@ -121,21 +143,16 @@ processApiError errDetails msg err model = "Unable to reach the server, check your network connection" Detailed.BadStatus _ body -> - errDetails body + decodeErrorContent body Detailed.BadBody _ _ m -> m in -- specific error override other ones which no longer need to be processed - processSpecificApiError errDetails err model + processSpecificApiError msg err model |> Maybe.withDefault ( model, errorNotification (msg ++ ", details: \n" ++ message) ) -processApiErrorString : String -> Detailed.Error String -> Model -> ( Model, Cmd Msg ) -processApiErrorString msg err model = - processApiError decodeErrorContent msg err model - - decodeErrorContent : String -> String decodeErrorContent body = let @@ -145,17 +162,6 @@ decodeErrorContent body = title ++ "\n" ++ errors -processApiErrorBytes : String -> Detailed.Error Bytes -> Model -> ( Model, Cmd Msg ) -processApiErrorBytes msg err model = - let - -- this 2048 chars should fit the notification box - f = - Bytes.Decode.decode (Bytes.Decode.string 2048) - >> Maybe.withDefault "Unknown error" - in - processApiError f msg err model - - decodeErrorDetails : String -> ( String, String ) decodeErrorDetails json = let @@ -182,3 +188,9 @@ decodeErrorDetails json = Just s -> ( s, join " \n " (drop 1 (List.map (\err -> "\t ‣ " ++ err) errors)) ) + + +waitAndReload : Float -> Cmd Msg +waitAndReload millis = + Process.sleep millis + |> Task.perform (\_ -> ReloadPage) diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/ApiCalls.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/ApiCalls.elm index 629e9b3e752..8bfa283db86 100644 --- a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/ApiCalls.elm +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/ApiCalls.elm @@ -1,6 +1,6 @@ module Plugins.ApiCalls exposing (..) -import Http exposing (emptyBody, header, request) +import Http exposing (emptyBody, expectStringResponse, header, request) import Http.Detailed as Detailed import Plugins.DataTypes exposing (..) import Plugins.JsonDecoder exposing (decodeGetPluginsInfo) @@ -19,7 +19,7 @@ updateIndex model = , headers = [ header "X-Requested-With" "XMLHttpRequest" ] , url = getUrl model "/pluginsinternal/update" , body = emptyBody - , expect = Detailed.expectWhatever <| ApiPostPlugins << Result.map (\_ -> UpdateIndex) + , expect = expectWhateverStringError <| ApiPostPlugins UpdateIndex , timeout = Nothing , tracker = Nothing } @@ -45,7 +45,7 @@ installPlugins plugins model = , headers = [ header "X-Requested-With" "XMLHttpRequest" ] , url = getUrl model "/pluginsinternal/install" , body = Http.jsonBody (encodePluginIds plugins) - , expect = Detailed.expectWhatever <| ApiPostPlugins << Result.map (\_ -> Install) + , expect = expectWhateverStringError <| ApiPostPlugins Install , timeout = Nothing , tracker = Nothing } @@ -58,7 +58,7 @@ removePlugins plugins model = , headers = [ header "X-Requested-With" "XMLHttpRequest" ] , url = getUrl model "/pluginsinternal/remove" , body = Http.jsonBody (encodePluginIds plugins) - , expect = Detailed.expectWhatever <| ApiPostPlugins << Result.map (\_ -> Uninstall) + , expect = expectWhateverStringError <| ApiPostPlugins Uninstall , timeout = Nothing , tracker = Nothing } @@ -71,7 +71,7 @@ changePluginStatus requestType plugins model = , headers = [ header "X-Requested-With" "XMLHttpRequest" ] , url = getUrl model ("/pluginsinternal/" ++ requestTypeText requestType) , body = Http.jsonBody (encodePluginIds plugins) - , expect = Detailed.expectWhatever <| ApiPostPlugins << Result.map (\_ -> requestType) + , expect = expectWhateverStringError <| ApiPostPlugins requestType , timeout = Nothing , tracker = Nothing } @@ -94,3 +94,10 @@ requestTypeAction t model = UpdateIndex -> updateIndex model + + +{-| Expect for a result that is ignored, but a BadStatus that needs to be read as String (e.g. JSON response from API) +-} +expectWhateverStringError : (Result (Detailed.Error String) () -> msg) -> Http.Expect msg +expectWhateverStringError toMsg = + expectStringResponse (Result.map (\_ -> ()) >> toMsg) Detailed.responseToString diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/DataTypes.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/DataTypes.elm index 41419df1bb5..1b1bbd44b61 100644 --- a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/DataTypes.elm +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/DataTypes.elm @@ -74,12 +74,18 @@ type alias PluginError = type alias UI = - { selected : List PluginId + { loading : Bool + , selected : List PluginId , modalState : ModalState - , settingsError : Maybe ( String, String ) -- message, details + , view : PluginsView } +type PluginsView + = ViewPluginsList + | ViewSettingsError ( String, String ) -- message, details + + type ModalState = OpenModal RequestType | NoModal @@ -110,18 +116,16 @@ type RequestType type Msg = CallApi (Model -> Cmd Msg) + | RequestApi RequestType | ApiGetPlugins (Result (Http.Detailed.Error String) ( Http.Metadata, PluginsInfo )) - | ApiPostPlugins (Result (Http.Detailed.Error Bytes) RequestType) + | ApiPostPlugins RequestType (Result (Http.Detailed.Error String) ()) | SetModalState ModalState + | ReloadPage | Copy String | CopyJson Value | CheckSelection Select - --- | UpdateUI UI - - requestTypeText : RequestType -> String requestTypeText t = case t of @@ -138,7 +142,7 @@ requestTypeText t = "disable" UpdateIndex -> - "index update" + "update index" processSelect : Select -> Model -> Model @@ -177,7 +181,7 @@ withSettingsError : ( String, String ) -> Model -> Model withSettingsError error model = let updateError ui = - { ui | settingsError = Just error } + { ui | view = ViewSettingsError error } in { model | ui = updateError model.ui } @@ -200,3 +204,8 @@ pluginDefaultOrdering : Ordering PluginInfo pluginDefaultOrdering = Ordering.byFieldWith pluginStatusOrdering .status |> Ordering.breakTiesWith (Ordering.byField .name) + + +withLoading : Bool -> Model -> Model +withLoading value model = + { model | ui = (\ui -> { ui | loading = value }) model.ui } diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/Init.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/Init.elm index f1f51cbccce..500268edbb7 100644 --- a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/Init.elm +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/Init.elm @@ -13,7 +13,7 @@ init : { contextPath : String } -> ( Model, Cmd Msg ) init flags = let initUI = - UI [] NoModal Nothing + UI True [] NoModal ViewPluginsList initModel = Model flags.contextPath Nothing [] initUI diff --git a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/View.elm b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/View.elm index c0c7c9f8da7..0ceaf418328 100644 --- a/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/View.elm +++ b/webapp/sources/rudder/rudder-web/src/main/elm/sources/Plugins/View.elm @@ -1,6 +1,6 @@ module Plugins.View exposing (..) -import Html exposing (Html, a, button, div, h1, h2, h3, i, input, label, li, p, pre, span, table, tbody, td, text, tr, ul) +import Html exposing (Html, a, button, div, h1, h2, h3, i, input, label, li, p, pre, span, strong, table, tbody, td, text, tr, ul) import Html.Attributes exposing (attribute, checked, class, disabled, for, href, id, style, target, type_) import Html.Attributes.Extra exposing (role) import Html.Events exposing (onCheck, onClick) @@ -13,27 +13,10 @@ import String.Extra import Time.DateTime import Time.Iso8601 import Time.ZonedDateTime -import Tuple exposing (first, second) view : Model -> Html Msg view model = - let - plugins = - model.plugins - - content = - div [ class "main-details" ] - [ displayMainLicense model - , displaySettingsErrorOrHtml model - (if List.isEmpty plugins then - i [ class "text-secondary" ] [ text "There are no plugins installed" ] - - else - pluginsSection model - ) - ] - in div [ class "rudder-template" ] [ div [ class "one-col w-100" ] [ div [ class "main-header" ] @@ -54,7 +37,10 @@ view model = ] , div [ class "one-col-main" ] [ div [ class "template-main" ] - [ div [ class "main-container" ] [ content ] + [ div [ class "main-container" ] + [ div [ class "main-details" ] + (loadWithSpinner "spinner-border" model.ui.loading (displayPluginView model)) + ] ] ] , displayModal model @@ -128,16 +114,25 @@ checkAll = ) -actionButtons : List (Html Msg) -actionButtons = +actionButtons : Model -> List (Html Msg) +actionButtons model = [ button [ class "btn btn-primary me-1", onClick (CallApi updateIndex) ] [ i [ class "fa fa-refresh me-1" ] [], text "Check for updates" ] - , button [ class "btn btn-default mx-1", onClick (SetModalState (OpenModal Install)) ] [ text "Install", i [ class "fa fa-plus-circle ms-1" ] [] ] - , button [ class "btn btn-default mx-1", onClick (SetModalState (OpenModal Uninstall)) ] [ text "Uninstall", i [ class "fa fa-minus-circle ms-1" ] [] ] - , button [ class "btn btn-default mx-1", onClick (SetModalState (OpenModal Enable)) ] [ text "Enable", i [ class "fa fa-check-circle ms-1" ] [] ] - , button [ class "btn btn-default ms-1", onClick (SetModalState (OpenModal Disable)) ] [ text "Disable", i [ class "fa fa-ban ms-1" ] [] ] + , button [ class "btn btn-default mx-1", disabled <| List.isEmpty model.ui.selected, onClick (SetModalState (OpenModal Install)) ] [ text "Install", i [ class "fa fa-plus-circle ms-1" ] [] ] + , button [ class "btn btn-default mx-1", disabled <| List.isEmpty model.ui.selected, onClick (SetModalState (OpenModal Uninstall)) ] [ text "Uninstall", i [ class "fa fa-minus-circle ms-1" ] [] ] + , button [ class "btn btn-default mx-1", disabled <| List.isEmpty model.ui.selected, onClick (SetModalState (OpenModal Enable)) ] [ text "Enable", i [ class "fa fa-check-circle ms-1" ] [] ] + , button [ class "btn btn-default ms-1", disabled <| List.isEmpty model.ui.selected, onClick (SetModalState (OpenModal Disable)) ] [ text "Disable", i [ class "fa fa-ban ms-1" ] [] ] ] +displayPluginsList : Model -> Html Msg +displayPluginsList model = + if List.isEmpty model.plugins then + i [ class "text-secondary" ] [ text "There are no plugins available." ] + + else + pluginsSection model + + pluginsSection : Model -> Html Msg pluginsSection model = let @@ -170,7 +165,7 @@ pluginsSection model = [ div [ class "table-container plugins-container" ] [ div [ class "dataTables_wrapper_top table-filter plugins-actions" ] [ div [ class "start" ] selectHtml - , div [ class "end" ] actionButtons + , div [ class "end" ] (actionButtons model) ] , h2 [ class "fs-5 p-3 m-0" ] [ text "Features" ] , div [ class "plugins-list" ] (List.map (displayPlugin model) plugins) @@ -255,24 +250,26 @@ displayMainLicense : Model -> Html Msg displayMainLicense model = case model.license of Nothing -> - displaySettingError model "No license found" ("Installed plugins : " ++ String.join "," (List.map .id model.plugins)) + displaySettingError model "No license found. Please contact Rudder to get license or configure your access" ("Available plugins : [" ++ String.join ", " (List.map .id model.plugins) ++ "]") Just license -> if license == noGlobalLicense then - displaySettingError model "Empty license found" ("Installed plugins : " ++ String.join "," (List.map .id model.plugins)) + displaySettingError model "Empty license found. Please contact Rudder to get license or configure your access" ("Available plugins : [" ++ String.join ", " (List.map .id model.plugins) ++ "]") else displayGlobalLicense license -displaySettingsErrorOrHtml : Model -> Html Msg -> Html Msg -displaySettingsErrorOrHtml model orHtml = - case model.ui.settingsError of - Just ( message, details ) -> - displaySettingError model message details +displayPluginView : Model -> List (Html Msg) +displayPluginView model = + case model.ui.view of + ViewSettingsError ( message, details ) -> + [ displaySettingError model message details ] - Nothing -> - orHtml + ViewPluginsList -> + [ displayMainLicense model + , displayPluginsList model + ] findLicenseNeededError : List PluginError -> Maybe PluginError @@ -377,9 +374,9 @@ displayPlugin model p = ] -buildModal : String -> Html Msg -> Msg -> Html Msg -buildModal title body saveAction = - div [ class "modal modal-account fade show", style "display" "block" ] +buildModal : Bool -> String -> Html Msg -> Msg -> Html Msg +buildModal loading title body saveAction = + div [ class "modal modal-plugins fade show", style "display" "block" ] [ div [ class "modal-backdrop fade show", onClick (SetModalState NoModal) ] [] , div [ class "modal-dialog modal-dialog-scrollable" ] [ div [ class "modal-content" ] @@ -392,7 +389,16 @@ buildModal title body saveAction = ] , div [ class "modal-footer" ] [ button [ type_ "button", class "btn btn-default", onClick (SetModalState NoModal) ] [ text "Close" ] - , button [ type_ "button", class "btn btn-success", onClick saveAction ] [ text "Confirm" ] + , button [ type_ "button", class "btn btn-success", onClick saveAction ] + (loadWithSpinner "spinner-grow spinner-grow-sm" + loading + (if loading then + [] + + else + [ text "Confirm" ] + ) + ) ] ] ] @@ -407,7 +413,7 @@ modalTitle requestType = modalBody : RequestType -> Model -> Html Msg modalBody requestType model = div [ class "callout-fade callout-warning" ] - [ p [] [ i [ class "fa fa-warning me-2" ] [], text <| "Rudder may restart to " ++ requestTypeText requestType ++ " " ++ String.Extra.pluralize "plugin" "plugins" (List.length model.ui.selected) ++ " :" ] + [ p [] [ i [ class "fa fa-warning me-2" ] [], strong [] [ text "Rudder may restart" ], text <| " to " ++ requestTypeText requestType ++ " " ++ String.Extra.pluralize "plugin" "plugins" (List.length model.ui.selected) ++ " :" ] , ul [ class "list-group m-0" ] (List.map (\p -> li [ class "list-group-item" ] [ text p ]) model.ui.selected) ] @@ -419,4 +425,28 @@ displayModal model = text "" OpenModal requestType -> - buildModal (modalTitle requestType) (modalBody requestType model) (CallApi (requestTypeAction requestType)) + buildModal model.ui.loading (modalTitle requestType) (modalBody requestType model) (RequestApi requestType) + + +loadWithSpinner : String -> Bool -> List (Html Msg) -> List (Html Msg) +loadWithSpinner spinnerClass loading html = + [ if loading then + div [ class <| "d-flex justify-content-center fade show" ] + [ div [ class spinnerClass, role "status" ] + [ span [ class "visually-hidden" ] [ text "Loading..." ] ] + ] + + else + text "" + , div + [ class <| + "fade " + ++ (if loading then + "" + + else + "show" + ) + ] + html + ] diff --git a/webapp/sources/rudder/rudder-web/src/main/scala/com/normation/plugins/RudderPlugin.scala b/webapp/sources/rudder/rudder-web/src/main/scala/com/normation/plugins/RudderPlugin.scala index aa658594107..3eacba74d70 100644 --- a/webapp/sources/rudder/rudder-web/src/main/scala/com/normation/plugins/RudderPlugin.scala +++ b/webapp/sources/rudder/rudder-web/src/main/scala/com/normation/plugins/RudderPlugin.scala @@ -42,7 +42,7 @@ import bootstrap.liftweb.ConfigResource import bootstrap.liftweb.FileSystemResource import bootstrap.liftweb.RudderProperties import com.normation.errors.IOResult -import com.normation.plugins.RudderPackageService.CredentialError +import com.normation.plugins.RudderPackageService.PluginSettingsError import com.normation.rudder.domain.logger.ApplicationLogger import com.normation.rudder.rest.EndpointSchema import com.normation.rudder.rest.lift.LiftApiModuleProvider @@ -370,7 +370,7 @@ class PluginSystemServiceImpl( pluginDefs: => Map[PluginName, RudderPluginDef], rudderFullVersion: String ) extends PluginSystemService { - override def updateIndex(): IOResult[Option[CredentialError]] = { + override def updateIndex(): IOResult[Option[PluginSettingsError]] = { rudderPackageService.update() } diff --git a/webapp/sources/rudder/rudder-web/src/main/style/rudder/rudder-plugins.scss b/webapp/sources/rudder/rudder-web/src/main/style/rudder/rudder-plugins.scss index c0c8b00e952..e5dd7669944 100644 --- a/webapp/sources/rudder/rudder-web/src/main/style/rudder/rudder-plugins.scss +++ b/webapp/sources/rudder/rudder-web/src/main/style/rudder/rudder-plugins.scss @@ -41,6 +41,11 @@ @import "../../node_modules/bootstrap/scss/variables"; @import "../../node_modules/bootstrap/scss/mixins"; +// Limit the size of spinner in loading buttons +$spinner-width-sm: 1.3rem; +$spinner-height-sm: $spinner-width-sm; +@import "../../node_modules/bootstrap/scss/spinners"; + .rudder-template .template-main { flex: auto !important; }