github.com/pf-qiu/concourse/v6@v6.7.3-0.20201207032516-1f455d73275f/web/elm/src/Pipeline/Pipeline.elm (about)

     1  module Pipeline.Pipeline exposing
     2      ( Flags
     3      , Model
     4      , changeToPipelineAndGroups
     5      , documentTitle
     6      , getUpdateMessage
     7      , handleCallback
     8      , handleDelivery
     9      , init
    10      , subscriptions
    11      , tooltip
    12      , update
    13      , view
    14      )
    15  
    16  import Application.Models exposing (Session)
    17  import Colors
    18  import Concourse
    19  import Concourse.Cli as Cli
    20  import EffectTransformer exposing (ET)
    21  import HoverState
    22  import Html exposing (Html)
    23  import Html.Attributes
    24      exposing
    25          ( class
    26          , download
    27          , href
    28          , id
    29          , src
    30          , style
    31          )
    32  import Html.Attributes.Aria exposing (ariaLabel)
    33  import Html.Events exposing (onMouseEnter, onMouseLeave)
    34  import Http
    35  import Keyboard
    36  import Login.Login as Login
    37  import Message.Callback exposing (Callback(..))
    38  import Message.Effects exposing (Effect(..))
    39  import Message.Message exposing (DomID(..), Message(..), PipelinesSection(..))
    40  import Message.Subscription
    41      exposing
    42          ( Delivery(..)
    43          , Interval(..)
    44          , Subscription(..)
    45          )
    46  import Message.TopLevelMessage exposing (TopLevelMessage(..))
    47  import Pipeline.PinMenu.PinMenu as PinMenu
    48  import Pipeline.Styles as Styles
    49  import RemoteData exposing (WebData)
    50  import Routes
    51  import Set
    52  import SideBar.SideBar as SideBar
    53  import StrictEvents exposing (onLeftClickOrShiftLeftClick)
    54  import Svg
    55  import Svg.Attributes as SvgAttributes
    56  import Tooltip
    57  import UpdateMsg exposing (UpdateMsg)
    58  import Views.FavoritedIcon as FavoritedIcon
    59  import Views.PauseToggle as PauseToggle
    60  import Views.Styles
    61  import Views.TopBar as TopBar
    62  
    63  
    64  type alias Model =
    65      Login.Model
    66          { pipelineLocator : Concourse.PipelineIdentifier
    67          , pipeline : WebData Concourse.Pipeline
    68          , fetchedJobs : Maybe (List Concourse.Job)
    69          , fetchedResources : Maybe (List Concourse.Resource)
    70          , renderedJobs : Maybe (List Concourse.Job)
    71          , renderedResources : Maybe (List Concourse.Resource)
    72          , turbulenceImgSrc : String
    73          , experiencingTurbulence : Bool
    74          , selectedGroups : List String
    75          , hideLegend : Bool
    76          , hideLegendCounter : Float
    77          , isToggleLoading : Bool
    78          , pinMenuExpanded : Bool
    79          }
    80  
    81  
    82  type alias Flags =
    83      { pipelineLocator : Concourse.PipelineIdentifier
    84      , turbulenceImgSrc : String
    85      , selectedGroups : List String
    86      }
    87  
    88  
    89  init : Flags -> ( Model, List Effect )
    90  init flags =
    91      let
    92          model =
    93              { turbulenceImgSrc = flags.turbulenceImgSrc
    94              , pipelineLocator = flags.pipelineLocator
    95              , pipeline = RemoteData.NotAsked
    96              , fetchedJobs = Nothing
    97              , fetchedResources = Nothing
    98              , renderedJobs = Nothing
    99              , renderedResources = Nothing
   100              , experiencingTurbulence = False
   101              , hideLegend = False
   102              , hideLegendCounter = 0
   103              , isToggleLoading = False
   104              , selectedGroups = flags.selectedGroups
   105              , isUserMenuExpanded = False
   106              , pinMenuExpanded = False
   107              }
   108      in
   109      ( model
   110      , [ FetchPipeline flags.pipelineLocator
   111        , ResetPipelineFocus
   112        , FetchAllPipelines
   113        ]
   114      )
   115  
   116  
   117  changeToPipelineAndGroups :
   118      { pipelineLocator : Concourse.PipelineIdentifier
   119      , selectedGroups : List String
   120      }
   121      -> ET Model
   122  changeToPipelineAndGroups { pipelineLocator, selectedGroups } ( model, effects ) =
   123      if model.pipelineLocator == pipelineLocator then
   124          let
   125              ( newModel, newEffects ) =
   126                  renderIfNeeded ( { model | selectedGroups = selectedGroups }, [] )
   127          in
   128          ( newModel, effects ++ newEffects ++ [ ResetPipelineFocus ] )
   129  
   130      else
   131          let
   132              ( newModel, newEffects ) =
   133                  init
   134                      { pipelineLocator = pipelineLocator
   135                      , selectedGroups = selectedGroups
   136                      , turbulenceImgSrc = model.turbulenceImgSrc
   137                      }
   138          in
   139          ( newModel, effects ++ newEffects )
   140  
   141  
   142  timeUntilHidden : Float
   143  timeUntilHidden =
   144      10 * 1000
   145  
   146  
   147  timeUntilHiddenCheckInterval : Float
   148  timeUntilHiddenCheckInterval =
   149      1 * 1000
   150  
   151  
   152  getUpdateMessage : Model -> UpdateMsg
   153  getUpdateMessage model =
   154      case model.pipeline of
   155          RemoteData.Failure _ ->
   156              UpdateMsg.NotFound
   157  
   158          _ ->
   159              UpdateMsg.AOK
   160  
   161  
   162  handleCallback : Callback -> ET Model
   163  handleCallback callback ( model, effects ) =
   164      let
   165          redirectToLoginIfUnauthenticated status =
   166              if status.code == 401 then
   167                  [ RedirectToLogin ]
   168  
   169              else
   170                  []
   171      in
   172      case callback of
   173          PipelineFetched (Ok pipeline) ->
   174              ( { model | pipeline = RemoteData.Success pipeline }
   175              , effects
   176                  ++ [ FetchJobs model.pipelineLocator
   177                     , FetchResources model.pipelineLocator
   178                     ]
   179              )
   180  
   181          PipelineFetched (Err err) ->
   182              case err of
   183                  Http.BadStatus { status } ->
   184                      if status.code == 404 then
   185                          ( { model | pipeline = RemoteData.Failure err }
   186                          , effects
   187                          )
   188  
   189                      else
   190                          ( model
   191                          , effects ++ redirectToLoginIfUnauthenticated status
   192                          )
   193  
   194                  _ ->
   195                      renderIfNeeded
   196                          ( { model | experiencingTurbulence = True }
   197                          , effects
   198                          )
   199  
   200          PipelineToggled _ (Ok ()) ->
   201              ( { model
   202                  | pipeline =
   203                      RemoteData.map
   204                          (\p -> { p | paused = not p.paused })
   205                          model.pipeline
   206                  , isToggleLoading = False
   207                }
   208              , effects
   209              )
   210  
   211          PipelineToggled _ (Err _) ->
   212              ( { model | isToggleLoading = False }, effects )
   213  
   214          JobsFetched (Ok fetchedJobs) ->
   215              renderIfNeeded
   216                  ( { model
   217                      | fetchedJobs = Just fetchedJobs
   218                      , experiencingTurbulence = False
   219                    }
   220                  , effects
   221                  )
   222  
   223          JobsFetched (Err err) ->
   224              case err of
   225                  Http.BadStatus { status } ->
   226                      ( model, effects ++ redirectToLoginIfUnauthenticated status )
   227  
   228                  _ ->
   229                      renderIfNeeded
   230                          ( { model
   231                              | fetchedJobs = Nothing
   232                              , experiencingTurbulence = True
   233                            }
   234                          , effects
   235                          )
   236  
   237          ResourcesFetched (Ok fetchedResources) ->
   238              renderIfNeeded
   239                  ( { model
   240                      | fetchedResources = Just fetchedResources
   241                      , experiencingTurbulence = False
   242                    }
   243                  , effects
   244                  )
   245  
   246          ResourcesFetched (Err err) ->
   247              case err of
   248                  Http.BadStatus { status } ->
   249                      ( model, effects ++ redirectToLoginIfUnauthenticated status )
   250  
   251                  _ ->
   252                      renderIfNeeded
   253                          ( { model
   254                              | fetchedResources = Nothing
   255                              , experiencingTurbulence = True
   256                            }
   257                          , effects
   258                          )
   259  
   260          ClusterInfoFetched (Ok _) ->
   261              ( { model
   262                  | experiencingTurbulence = False
   263                }
   264              , effects
   265              )
   266  
   267          ClusterInfoFetched (Err _) ->
   268              ( { model | experiencingTurbulence = True }, effects )
   269  
   270          AllPipelinesFetched (Err _) ->
   271              ( { model | experiencingTurbulence = True }, effects )
   272  
   273          _ ->
   274              ( model, effects )
   275  
   276  
   277  handleDelivery : Delivery -> ET Model
   278  handleDelivery delivery ( model, effects ) =
   279      case delivery of
   280          KeyDown keyEvent ->
   281              ( { model | hideLegend = False, hideLegendCounter = 0 }
   282              , if keyEvent.code == Keyboard.F then
   283                  effects ++ [ ResetPipelineFocus ]
   284  
   285                else
   286                  effects
   287              )
   288  
   289          Moused _ ->
   290              ( { model | hideLegend = False, hideLegendCounter = 0 }, effects )
   291  
   292          ClockTicked OneSecond _ ->
   293              if model.hideLegendCounter + timeUntilHiddenCheckInterval > timeUntilHidden then
   294                  ( { model | hideLegend = True }, effects )
   295  
   296              else
   297                  ( { model | hideLegendCounter = model.hideLegendCounter + timeUntilHiddenCheckInterval }
   298                  , effects
   299                  )
   300  
   301          ClockTicked FiveSeconds _ ->
   302              ( model
   303              , effects
   304                  ++ [ FetchPipeline model.pipelineLocator
   305                     , FetchAllPipelines
   306                     ]
   307              )
   308  
   309          ClockTicked OneMinute _ ->
   310              ( model, effects ++ [ FetchClusterInfo ] )
   311  
   312          _ ->
   313              ( model, effects )
   314  
   315  
   316  update : Message -> ET Model
   317  update msg ( model, effects ) =
   318      (case msg of
   319          ToggleGroup group ->
   320              ( model
   321              , effects
   322                  ++ [ NavigateTo <|
   323                          getNextUrl
   324                              (toggleGroup group model.selectedGroups model.pipeline)
   325                              model
   326                     ]
   327              )
   328  
   329          SetGroups groups ->
   330              ( model, effects ++ [ NavigateTo <| getNextUrl groups model ] )
   331  
   332          Click (TopBarPauseToggle pipelineIdentifier) ->
   333              let
   334                  paused =
   335                      model.pipeline |> RemoteData.map .paused
   336              in
   337              case paused of
   338                  RemoteData.Success p ->
   339                      ( { model | isToggleLoading = True }
   340                      , effects
   341                          ++ [ SendTogglePipelineRequest
   342                                  pipelineIdentifier
   343                                  p
   344                             ]
   345                      )
   346  
   347                  _ ->
   348                      ( model, effects )
   349  
   350          _ ->
   351              ( model, effects )
   352      )
   353          |> PinMenu.update msg
   354  
   355  
   356  subscriptions : List Subscription
   357  subscriptions =
   358      [ OnClockTick OneMinute
   359      , OnClockTick FiveSeconds
   360      , OnClockTick OneSecond
   361      , OnMouse
   362      , OnKeyDown
   363      , OnWindowResize
   364      ]
   365  
   366  
   367  documentTitle : Model -> String
   368  documentTitle model =
   369      model.pipelineLocator.pipelineName
   370  
   371  
   372  view : Session -> Model -> Html Message
   373  view session model =
   374      let
   375          route =
   376              Routes.Pipeline
   377                  { id = model.pipelineLocator
   378                  , groups = model.selectedGroups
   379                  }
   380  
   381          displayPaused =
   382              isPaused model.pipeline
   383                  && not (isArchived model.pipeline)
   384      in
   385      Html.div [ Html.Attributes.style "height" "100%" ]
   386          [ Html.div
   387              (id "page-including-top-bar" :: Views.Styles.pageIncludingTopBar)
   388              [ Html.div
   389                  (id "top-bar-app" :: Views.Styles.topBar displayPaused)
   390                  [ SideBar.hamburgerMenu session
   391                  , TopBar.concourseLogo
   392                  , TopBar.breadcrumbs route
   393                  , PinMenu.viewPinMenu session model
   394                  , Html.div (id "top-bar-favorited-icon" :: Styles.favoritedIcon)
   395                      [ FavoritedIcon.view
   396                          { isHovered = HoverState.isHovered (TopBarFavoritedIcon <| getPipelineId model.pipeline) session.hovered
   397                          , isFavorited =
   398                              Set.member (getPipelineId model.pipeline) session.favoritedPipelines
   399                          , isSideBar = False
   400                          , domID = TopBarFavoritedIcon <| getPipelineId model.pipeline
   401                          }
   402                          [ style "margin" "17px" ]
   403                      ]
   404                  , if isArchived model.pipeline then
   405                      Html.text ""
   406  
   407                    else
   408                      Html.div
   409                          (id "top-bar-pause-toggle" :: Styles.pauseToggle)
   410                          [ PauseToggle.view
   411                              { pipeline = model.pipelineLocator
   412                              , isPaused = isPaused model.pipeline
   413                              , isToggleHovered =
   414                                  HoverState.isHovered
   415                                      (TopBarPauseToggle model.pipelineLocator)
   416                                      session.hovered
   417                              , isToggleLoading = model.isToggleLoading
   418                              , tooltipPosition = Views.Styles.Below
   419                              , margin = "17px"
   420                              , userState = session.userState
   421                              , domID = TopBarPauseToggle model.pipelineLocator
   422                              }
   423                          ]
   424                  , Login.view session.userState model
   425                  ]
   426              , Html.div
   427                  (id "page-below-top-bar" :: Views.Styles.pageBelowTopBar route)
   428                <|
   429                  [ SideBar.view session (Just model.pipelineLocator)
   430                  , viewSubPage session model
   431                  ]
   432              ]
   433          ]
   434  
   435  
   436  tooltip : Model -> a -> Maybe Tooltip.Tooltip
   437  tooltip _ _ =
   438      Nothing
   439  
   440  
   441  getPipelineId : WebData Concourse.Pipeline -> Int
   442  getPipelineId p =
   443      RemoteData.withDefault -1 (RemoteData.map .id p)
   444  
   445  
   446  isPaused : WebData Concourse.Pipeline -> Bool
   447  isPaused p =
   448      RemoteData.withDefault False (RemoteData.map .paused p)
   449  
   450  
   451  isArchived : WebData Concourse.Pipeline -> Bool
   452  isArchived p =
   453      RemoteData.withDefault False (RemoteData.map .archived p)
   454  
   455  
   456  backgroundImage : WebData Concourse.Pipeline -> List (Html.Attribute msg)
   457  backgroundImage pipeline =
   458      case pipeline of
   459          RemoteData.Success p ->
   460              p.backgroundImage
   461                  |> Maybe.map Styles.pipelineBackground
   462                  |> Maybe.withDefault []
   463  
   464          _ ->
   465              []
   466  
   467  
   468  viewSubPage :
   469      { a | hovered : HoverState.HoverState, version : String }
   470      -> Model
   471      -> Html Message
   472  viewSubPage session model =
   473      Html.div
   474          [ class "pipeline-view"
   475          , id "pipeline-container"
   476          , style "display" "flex"
   477          , style "flex-direction" "column"
   478          , style "flex-grow" "1"
   479          ]
   480          [ viewGroupsBar session model
   481          , Html.div
   482              [ class "pipeline-content" ]
   483              [ Html.div
   484                  (id "pipeline-background" :: backgroundImage model.pipeline)
   485                  []
   486              , Svg.svg
   487                  [ SvgAttributes.class "pipeline-graph test" ]
   488                  []
   489              , Html.div
   490                  [ if model.experiencingTurbulence then
   491                      class "error-message"
   492  
   493                    else
   494                      class "error-message hidden"
   495                  ]
   496                  [ Html.div [ class "message" ]
   497                      [ Html.img [ src model.turbulenceImgSrc, class "seatbelt" ] []
   498                      , Html.p [] [ Html.text "experiencing turbulence" ]
   499                      , Html.p [ class "explanation" ] []
   500                      ]
   501                  ]
   502              , if model.hideLegend then
   503                  Html.text ""
   504  
   505                else
   506                  Html.dl
   507                      [ id "legend", class "legend" ]
   508                      [ Html.dt [ class "succeeded" ] []
   509                      , Html.dd [] [ Html.text "succeeded" ]
   510                      , Html.dt [ class "errored" ] []
   511                      , Html.dd [] [ Html.text "errored" ]
   512                      , Html.dt [ class "aborted" ] []
   513                      , Html.dd [] [ Html.text "aborted" ]
   514                      , Html.dt [ class "paused" ] []
   515                      , Html.dd [] [ Html.text "paused" ]
   516                      , Html.dt
   517                          [ Html.Attributes.style "background-color" Colors.pinned
   518                          ]
   519                          []
   520                      , Html.dd [] [ Html.text "pinned" ]
   521                      , Html.dt [ class "failed" ] []
   522                      , Html.dd [] [ Html.text "failed" ]
   523                      , Html.dt [ class "pending" ] []
   524                      , Html.dd [] [ Html.text "pending" ]
   525                      , Html.dt [ class "started" ] []
   526                      , Html.dd [] [ Html.text "started" ]
   527                      , Html.dt [ class "dotted" ] [ Html.text "." ]
   528                      , Html.dd [] [ Html.text "dependency" ]
   529                      , Html.dt [ class "solid" ] [ Html.text "-" ]
   530                      , Html.dd [] [ Html.text "dependency (trigger)" ]
   531                      ]
   532              , Html.table [ class "lower-right-info" ]
   533                  [ Html.tr []
   534                      [ Html.td [ class "label" ] [ Html.text "cli:" ]
   535                      , Html.td []
   536                          [ Html.ul [ class "cli-downloads" ] <|
   537                              List.map
   538                                  (\cli ->
   539                                      Html.li []
   540                                          [ Html.a
   541                                              ([ href <| Cli.downloadUrl cli
   542                                               , ariaLabel <| Cli.label cli
   543                                               , download ""
   544                                               ]
   545                                                  ++ Styles.cliIcon cli
   546                                              )
   547                                              []
   548                                          ]
   549                                  )
   550                                  Cli.clis
   551                          ]
   552                      ]
   553                  , Html.tr []
   554                      [ Html.td [ class "label" ] [ Html.text "version:" ]
   555                      , Html.td []
   556                          [ Html.div [ id "concourse-version" ]
   557                              [ Html.text "v"
   558                              , Html.span
   559                                  [ class "number" ]
   560                                  [ Html.text session.version ]
   561                              ]
   562                          ]
   563                      ]
   564                  ]
   565              ]
   566          ]
   567  
   568  
   569  viewGroupsBar : { a | hovered : HoverState.HoverState } -> Model -> Html Message
   570  viewGroupsBar session model =
   571      let
   572          groupList =
   573              case model.pipeline of
   574                  RemoteData.Success pipeline ->
   575                      List.indexedMap
   576                          (viewGroup
   577                              { selectedGroups = selectedGroupsOrDefault model
   578                              , pipelineLocator = model.pipelineLocator
   579                              , hovered = session.hovered
   580                              }
   581                          )
   582                          pipeline.groups
   583  
   584                  _ ->
   585                      []
   586      in
   587      if List.isEmpty groupList then
   588          Html.text ""
   589  
   590      else
   591          Html.div
   592              (id "groups-bar" :: Styles.groupsBar)
   593              groupList
   594  
   595  
   596  viewGroup :
   597      { a
   598          | selectedGroups : List String
   599          , pipelineLocator : Concourse.PipelineIdentifier
   600          , hovered : HoverState.HoverState
   601      }
   602      -> Int
   603      -> Concourse.PipelineGroup
   604      -> Html Message
   605  viewGroup { selectedGroups, pipelineLocator, hovered } idx grp =
   606      let
   607          url =
   608              Routes.toString <|
   609                  Routes.Pipeline { id = pipelineLocator, groups = [ grp.name ] }
   610      in
   611      Html.a
   612          ([ Html.Attributes.href <| url
   613           , onLeftClickOrShiftLeftClick
   614              (SetGroups [ grp.name ])
   615              (ToggleGroup grp)
   616           , onMouseEnter <| Hover <| Just <| JobGroup idx
   617           , onMouseLeave <| Hover Nothing
   618           ]
   619              ++ Styles.groupItem
   620                  { selected = List.member grp.name selectedGroups
   621                  , hovered = HoverState.isHovered (JobGroup idx) hovered
   622                  }
   623          )
   624          [ Html.text grp.name ]
   625  
   626  
   627  jobAppearsInGroups : List String -> Concourse.Job -> Bool
   628  jobAppearsInGroups groupNames job =
   629      anyIntersect job.groups groupNames
   630  
   631  
   632  filterJobs : Model -> List Concourse.Job -> List Concourse.Job
   633  filterJobs model jobs =
   634      List.filter
   635          (jobAppearsInGroups (activeGroups model))
   636          jobs
   637  
   638  
   639  activeGroups : Model -> List String
   640  activeGroups model =
   641      case ( model.selectedGroups, model.pipeline |> RemoteData.toMaybe |> Maybe.andThen (List.head << .groups) ) of
   642          ( [], Just firstGroup ) ->
   643              [ firstGroup.name ]
   644  
   645          ( groups, _ ) ->
   646              groups
   647  
   648  
   649  renderIfNeeded : ET Model
   650  renderIfNeeded ( model, effects ) =
   651      case ( model.fetchedResources, model.fetchedJobs ) of
   652          ( Just fetchedResources, Just fetchedJobs ) ->
   653              let
   654                  filteredFetchedJobs =
   655                      if List.isEmpty (activeGroups model) then
   656                          fetchedJobs
   657  
   658                      else
   659                          filterJobs model fetchedJobs
   660              in
   661              case ( model.renderedResources, model.renderedJobs ) of
   662                  ( Just renderedResources, Just renderedJobs ) ->
   663                      if
   664                          (renderedJobs /= filteredFetchedJobs)
   665                              || (renderedResources /= fetchedResources)
   666                      then
   667                          ( { model
   668                              | renderedJobs = Just filteredFetchedJobs
   669                              , renderedResources = Just fetchedResources
   670                            }
   671                          , effects ++ [ RenderPipeline filteredFetchedJobs fetchedResources ]
   672                          )
   673  
   674                      else
   675                          ( model, effects )
   676  
   677                  _ ->
   678                      ( { model
   679                          | renderedJobs = Just filteredFetchedJobs
   680                          , renderedResources = Just fetchedResources
   681                        }
   682                      , effects ++ [ RenderPipeline filteredFetchedJobs fetchedResources ]
   683                      )
   684  
   685          _ ->
   686              ( model, effects )
   687  
   688  
   689  anyIntersect : List a -> List a -> Bool
   690  anyIntersect list1 list2 =
   691      case list1 of
   692          [] ->
   693              False
   694  
   695          first :: rest ->
   696              if List.member first list2 then
   697                  True
   698  
   699              else
   700                  anyIntersect rest list2
   701  
   702  
   703  toggleGroup : Concourse.PipelineGroup -> List String -> WebData Concourse.Pipeline -> List String
   704  toggleGroup grp names mpipeline =
   705      if List.member grp.name names then
   706          List.filter ((/=) grp.name) names
   707  
   708      else if List.isEmpty names then
   709          grp.name :: getDefaultSelectedGroups mpipeline
   710  
   711      else
   712          grp.name :: names
   713  
   714  
   715  selectedGroupsOrDefault : Model -> List String
   716  selectedGroupsOrDefault model =
   717      if List.isEmpty model.selectedGroups then
   718          getDefaultSelectedGroups model.pipeline
   719  
   720      else
   721          model.selectedGroups
   722  
   723  
   724  getDefaultSelectedGroups : WebData Concourse.Pipeline -> List String
   725  getDefaultSelectedGroups pipeline =
   726      case pipeline of
   727          RemoteData.Success p ->
   728              case List.head p.groups of
   729                  Nothing ->
   730                      []
   731  
   732                  Just first ->
   733                      [ first.name ]
   734  
   735          _ ->
   736              []
   737  
   738  
   739  getNextUrl : List String -> Model -> String
   740  getNextUrl newGroups model =
   741      Routes.toString <|
   742          Routes.Pipeline { id = model.pipelineLocator, groups = newGroups }