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 }