diff --git a/cypress/fixtures/user_dashboards.json b/cypress/fixtures/user_dashboards.json new file mode 100644 index 000000000..98db5215d --- /dev/null +++ b/cypress/fixtures/user_dashboards.json @@ -0,0 +1,63 @@ +[ + { + "dashboard": { + "id": "6e26a6d0-2fc3-4531-a04d-678a58135288", + "name": "demo dashboard", + "created_at": 1726757028, + "created_by": "CookieCat", + "updated_at": 1726757028, + "updated_by": "CookieCat", + "admins": [ + { + "id": 1, + "name": "CookieCat", + "active": true + } + ], + "repos": [ + { + "id": 1, + "name": "github/repo1" + } + ] + }, + "repos": [ + { + "org": "github", + "name": "repo1", + "counter": 1, + "active": true, + "builds": [ + { + "number": 1, + "started": 1726757097, + "sender": "CookieCat", + "ref": "refs/heads/main", + "status": "running", + "event": "push", + "branch": "master", + "link": "http://vela.example.com/github/repo1/1" + } + ] + } + ] + }, + { + "dashboard": { + "id": "c4e8f563-4784-4b4b-9534-3007d579dc2a", + "name": "another demo dashboard", + "created_at": 1726757636, + "created_by": "CookieCat", + "updated_at": 1726757636, + "updated_by": "CookieCat", + "admins": [ + { + "id": 1, + "name": "CookieCat", + "active": true + } + ], + "repos": [] + } + } +] diff --git a/cypress/integration/dashboards.spec.js b/cypress/integration/dashboards.spec.js index 2ca8896b7..e9cf985a9 100644 --- a/cypress/integration/dashboards.spec.js +++ b/cypress/integration/dashboards.spec.js @@ -3,6 +3,56 @@ */ context('Dashboards', () => { + context('main dashboards page', () => { + beforeEach(() => { + cy.server(); + cy.route( + 'GET', + '*api/v1/user/dashboards', + 'fixture:user_dashboards.json', + ); + cy.login('/dashboards'); + }); + + it('shows the list of dashboards', () => { + cy.get('[data-test=dashboard-item]').should('have.length', 2); + }); + + it('shows the repos within a dashboard', () => { + cy.get('[data-test=dashboard-repos]').first().contains('github/repo1'); + }); + + it('shows a message when there are no repos', () => { + cy.get('[data-test=dashboard-repos]') + .eq(1) + .contains('No repositories in this dashboard'); + }); + + it('clicking dashoard name navigates to dashboard page', () => { + cy.get('[data-test=dashboard-item]') + .first() + .within(() => { + cy.get('a').first().click(); + cy.location('pathname').should( + 'eq', + '/dashboards/6e26a6d0-2fc3-4531-a04d-678a58135288', + ); + }); + }); + }); + + context('main dashboards page shows message', () => { + beforeEach(() => { + cy.server(); + cy.route( + 'GET', + '*api/v1/user/dashboards', + 'fixture:user_dashboards.json', + ); + cy.login('/dashboards'); + }); + }); + context('server returns dashboard with 3 cards, one without builds', () => { beforeEach(() => { cy.server(); @@ -112,15 +162,4 @@ context('Dashboards', () => { ); }); }); - - context('main dashboards page shows message', () => { - beforeEach(() => { - cy.server(); - cy.login('/dashboards'); - }); - - it('shows the welcome message', () => { - cy.get('[data-test=dashboards]').contains('Welcome to dashboards!'); - }); - }); }); diff --git a/src/elm/Api/Endpoint.elm b/src/elm/Api/Endpoint.elm index 225bd70ea..581a71c81 100644 --- a/src/elm/Api/Endpoint.elm +++ b/src/elm/Api/Endpoint.elm @@ -26,6 +26,7 @@ type Endpoint | Logout | CurrentUser | Dashboard String + | Dashboards | Deployment Vela.Org Vela.Repo (Maybe String) | Deployments (Maybe Pagination.Page) (Maybe Pagination.PerPage) Vela.Org Vela.Repo | Token @@ -164,6 +165,9 @@ toUrl api endpoint = Deployments maybePage maybePerPage org repo -> url api [ "deployments", org, repo ] <| Pagination.toQueryParams maybePage maybePerPage + Dashboards -> + url api [ "user", "dashboards" ] [] + Dashboard dashboard -> url api [ "dashboards", dashboard ] [] diff --git a/src/elm/Api/Operations.elm b/src/elm/Api/Operations.elm index 8581a5ab3..a807775c6 100644 --- a/src/elm/Api/Operations.elm +++ b/src/elm/Api/Operations.elm @@ -30,6 +30,7 @@ module Api.Operations exposing , getBuildSteps , getCurrentUser , getDashboard + , getDashboards , getOrgBuilds , getOrgRepos , getOrgSecret @@ -1316,3 +1317,16 @@ getDashboard baseUrl session options = ) Vela.decodeDashboard |> withAuth session + + +{-| getDashboards : retrieves the dashboards for the current user. +-} +getDashboards : + String + -> Session + -> Request (List Vela.Dashboard) +getDashboards baseUrl session = + get baseUrl + Api.Endpoint.Dashboards + Vela.decodeDashboards + |> withAuth session diff --git a/src/elm/Effect.elm b/src/elm/Effect.elm index 5a89b5b36..0e1678869 100644 --- a/src/elm/Effect.elm +++ b/src/elm/Effect.elm @@ -9,7 +9,7 @@ module Effect exposing , sendCmd, sendMsg , pushRoute, replaceRoute, loadExternalUrl , map, toCmd - , addAlertError, addAlertSuccess, addDeployment, addFavorites, addOrgSecret, addRepoSchedule, addRepoSecret, addSharedSecret, alertsUpdate, approveBuild, cancelBuild, chownRepo, clearRedirect, deleteOrgSecret, deleteRepoSchedule, deleteRepoSecret, deleteSharedSecret, disableRepo, downloadFile, enableRepo, expandPipelineConfig, finishAuthentication, focusOn, getAllBuildServices, getAllBuildSteps, getBuild, getBuildGraph, getBuildServiceLog, getBuildServices, getBuildStepLog, getBuildSteps, getCurrentUser, getCurrentUserShared, getDashboard, getOrgBuilds, getOrgRepos, getOrgSecret, getOrgSecrets, getPipelineConfig, getPipelineTemplates, getRepo, getRepoBuilds, getRepoBuildsShared, getRepoDeployments, getRepoHooks, getRepoHooksShared, getRepoSchedule, getRepoSchedules, getRepoSecret, getRepoSecrets, getSettings, getSharedSecret, getSharedSecrets, getWorkers, handleHttpError, logout, pushPath, redeliverHook, repairRepo, replacePath, replaceRouteRemoveTabHistorySkipDomFocus, restartBuild, setRedirect, setTheme, updateFavicon, updateFavorite, updateOrgSecret, updateRepo, updateRepoHooksShared, updateRepoSchedule, updateRepoSecret, updateSettings, updateSharedSecret, updateSourceReposShared + , addAlertError, addAlertSuccess, addDeployment, addFavorites, addOrgSecret, addRepoSchedule, addRepoSecret, addSharedSecret, alertsUpdate, approveBuild, cancelBuild, chownRepo, clearRedirect, deleteOrgSecret, deleteRepoSchedule, deleteRepoSecret, deleteSharedSecret, disableRepo, downloadFile, enableRepo, expandPipelineConfig, finishAuthentication, focusOn, getAllBuildServices, getAllBuildSteps, getBuild, getBuildGraph, getBuildServiceLog, getBuildServices, getBuildStepLog, getBuildSteps, getCurrentUser, getCurrentUserShared, getDashboard, getDashboards, getOrgBuilds, getOrgRepos, getOrgSecret, getOrgSecrets, getPipelineConfig, getPipelineTemplates, getRepo, getRepoBuilds, getRepoBuildsShared, getRepoDeployments, getRepoHooks, getRepoHooksShared, getRepoSchedule, getRepoSchedules, getRepoSecret, getRepoSecrets, getSettings, getSharedSecret, getSharedSecrets, getWorkers, handleHttpError, logout, pushPath, redeliverHook, repairRepo, replacePath, replaceRouteRemoveTabHistorySkipDomFocus, restartBuild, setRedirect, setTheme, updateFavicon, updateFavorite, updateOrgSecret, updateRepo, updateRepoHooksShared, updateRepoSchedule, updateRepoSecret, updateSettings, updateSharedSecret, updateSourceReposShared ) {-| @@ -1384,3 +1384,19 @@ getDashboard options = options ) |> sendCmd + + +getDashboards : + { baseUrl : String + , session : Auth.Session.Session + , onResponse : Result (Http.Detailed.Error String) ( Http.Metadata, List Vela.Dashboard ) -> msg + } + -> Effect msg +getDashboards options = + Api.try + options.onResponse + (Api.Operations.getDashboards + options.baseUrl + options.session + ) + |> sendCmd diff --git a/src/elm/Layouts/Default/Org.elm b/src/elm/Layouts/Default/Org.elm index 933f5a370..44f5f2c39 100644 --- a/src/elm/Layouts/Default/Org.elm +++ b/src/elm/Layouts/Default/Org.elm @@ -135,6 +135,13 @@ view props shared route { toContentMsg, model, content } = { buttons = props.navButtons ++ [ a + [ class "button" + , class "-outline" + , Util.testAttribute "dashboards-button" + , Route.Path.href Route.Path.Dashboards + ] + [ text "Dashboards" ] + , a [ class "button" , class "-outline" , Util.testAttribute "source-repos" diff --git a/src/elm/Pages/Dashboards.elm b/src/elm/Pages/Dashboards.elm index 09c63b37f..66617ad06 100644 --- a/src/elm/Pages/Dashboards.elm +++ b/src/elm/Pages/Dashboards.elm @@ -7,16 +7,25 @@ module Pages.Dashboards exposing (Model, Msg, page) import Auth import Components.Crumbs +import Components.Loading import Components.Nav +import Components.Svgs import Effect exposing (Effect) -import Html exposing (code, h1, h2, main_, p, text) +import Html exposing (Html, a, code, div, h1, h2, li, main_, p, span, text, ul) import Html.Attributes exposing (class) +import Http +import Http.Detailed import Layouts import Page exposing (Page) +import RemoteData exposing (WebData) import Route exposing (Route) import Route.Path import Shared +import Time +import Utils.Errors as Errors import Utils.Helpers as Util +import Utils.Interval as Interval +import Vela import View exposing (View) @@ -74,15 +83,19 @@ toLayout user route model = {-| Model : alias for a model object for the dashboards page. -} type alias Model = - {} + { dashboards : WebData (List Vela.Dashboard) } {-| init : takes shared model and initializes dashboards page input arguments. -} init : Shared.Model -> Route () -> () -> ( Model, Effect Msg ) init shared route () = - ( {} - , Effect.none + ( { dashboards = RemoteData.Loading } + , Effect.getDashboards + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetDashboardsResponse + } ) @@ -93,7 +106,9 @@ init shared route () = {-| Msg : custom type with possible messages. -} type Msg - = NoOp + = GetDashboardsResponse (Result (Http.Detailed.Error String) ( Http.Metadata, List Vela.Dashboard )) + -- REFRESH + | Tick { time : Time.Posix, interval : Interval.Interval } {-| update : takes current model, message, and returns an updated model and effect. @@ -101,9 +116,29 @@ type Msg update : Shared.Model -> Route () -> Msg -> Model -> ( Model, Effect Msg ) update shared route msg model = case msg of - NoOp -> + GetDashboardsResponse response -> + case response of + Ok ( _, dashboards ) -> + ( { model | dashboards = RemoteData.Success dashboards } + , Effect.none + ) + + Err error -> + ( { model | dashboards = Errors.toFailure error } + , Effect.handleHttpError + { error = error + , shouldShowAlertFn = Errors.showAlertAlways + } + ) + + -- REFRESH + Tick options -> ( model - , Effect.none + , Effect.getDashboards + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetDashboardsResponse + } ) @@ -115,7 +150,7 @@ update shared route msg model = -} subscriptions : Model -> Sub Msg subscriptions model = - Sub.none + Interval.tickEveryFiveSeconds Tick @@ -140,17 +175,117 @@ view shared route model = { buttons = [] , crumbs = Components.Crumbs.view route.path crumbs } - , main_ [ class "content-wrap", Util.testAttribute "dashboards" ] - [ h1 [] [ text "Welcome to dashboards!" ] - , h2 [] [ text "โœจ Want to create a new dashboard?" ] - , p [] [ text "Use the Vela CLI to add a new dashboard:" ] - , code [ class "shell" ] [ text "vela add dashboard --help" ] - , h2 [] [ text "๐Ÿš€ Already have a dashboard?" ] - , p [] [ text "Check your available dashboards with:" ] - , code [ class "shell" ] [ text "vela get dashboards" ] - , p [] [ text "Take note of your dashboard ID you are interested in and and add it to the current URL to view it." ] - , h2 [] [ text "๐Ÿ’ฌ Got Feedback?" ] - , p [] [ text "Follow the link in the top right to let us know your thoughts and ideas." ] + , main_ [ class "content-wrap" ] + [ div [ Util.testAttribute "dashboards" ] <| + case model.dashboards of + RemoteData.Success dashboards -> + if List.length dashboards > 0 then + [ div [ class "dashboards" ] + (h1 [] [ text "Dashboards", span [ class "beta" ] [ text "beta" ] ] + :: viewDashboards dashboards + ++ [ h2 [] [ text "๐Ÿงช Beta Limitations" ] + , p [] [ text "This is an early version of Dashboards. Please be aware of the following:" ] + , ul [] + [ li [] [ text "You have to use CLI/API to manage (add, edit, etc) dashboards" ] + , li [] [ text "This page will only show dashboards you created" ] + , li [] [ text "Bookmark or save links to dashboards you didn't create" ] + ] + , h2 [] [ text "๐Ÿ’ฌ Got Feedback?" ] + , p [] [ text "Help us shape Dashboards. What do you want to see? Use the \"feedback\" link in the top right!" ] + ] + ) + ] + + else + [ div [ class "dashboards" ] + [ h1 [] [ text "Welcome to Dashboards", span [ class "beta" ] [ text "beta" ] ] + , h2 [] [ text "โœจ Want to create a new dashboard?" ] + , p [] [ text "Use the Vela CLI to add a new dashboard:" ] + , code [ class "shell" ] [ text "vela add dashboard --help" ] + , p [] [ text "Once you added dashboards, they will show on this page." ] + , h2 [] [ text "๐Ÿ’ฌ Got Feedback?" ] + , p [] [ text "Follow the \"feedback\" link in the top right to let us know your thoughts and ideas." ] + ] + ] + + RemoteData.Failure error -> + [ span [] + [ text <| + case error of + Http.BadStatus statusCode -> + case statusCode of + 401 -> + "Unauthorized to retrieve dashboards" + + _ -> + "No dashboards found, there was an error with the server" + + _ -> + "No dashboards found, there was an error with the server" + ] + ] + + _ -> + [ Components.Loading.viewSmallLoader ] ] ] } + + +{-| viewDashboards : renders a list of dashboard links. +-} +viewDashboards : List Vela.Dashboard -> List (Html Msg) +viewDashboards dashboards = + dashboards + |> List.map + (\dashboard -> + let + dashboardLink = + Route.Path.Dashboards_Dashboard_ { dashboard = dashboard.dashboard.id } + |> Route.Path.href + in + div [ class "item", Util.testAttribute "dashboard-item" ] + [ span [ class "dashboard-item-title" ] + [ a [ dashboardLink ] [ text dashboard.dashboard.name ] + , code [] [ text dashboard.dashboard.id ] + ] + , div [ class "buttons" ] + [ a [ class "button", dashboardLink ] [ text "View" ] + ] + , viewDashboardRepos dashboard.repos dashboard.dashboard.id + ] + ) + + +{-| viewDashboardRepos : renders a list of repos belonging to a dashboard. +-} +viewDashboardRepos : List Vela.DashboardRepoCard -> String -> Html Msg +viewDashboardRepos repos dashboardId = + div [ class "dashboard-repos", Util.testAttribute "dashboard-repos" ] + (if List.length repos > 0 then + repos + |> List.map + (\repo -> + let + statusIcon = + case List.head repo.builds of + Just build -> + Components.Svgs.recentBuildStatusToIcon build.status 0 + + Nothing -> + Components.Svgs.recentBuildStatusToIcon Vela.Pending 0 + in + div + [ class "dashboard-repos-item" ] + [ statusIcon + , text (repo.org ++ "/" ++ repo.name) + ] + ) + + else + [ text <| + "โš ๏ธ No repositories in this dashboard. Use the CLI to add some: vela update dashboard --id " + ++ dashboardId + ++ " --add-repos org/repo" + ] + ) diff --git a/src/elm/Pages/Dashboards/Dashboard_.elm b/src/elm/Pages/Dashboards/Dashboard_.elm index 4d32587e3..30335ba02 100644 --- a/src/elm/Pages/Dashboards/Dashboard_.elm +++ b/src/elm/Pages/Dashboards/Dashboard_.elm @@ -36,7 +36,6 @@ import Route.Path import Shared import Time import Utils.Errors as Errors -import Utils.Favicons as Favicons import Utils.Helpers as Util import Utils.Interval as Interval import Vela @@ -202,7 +201,7 @@ view shared route model = crumbs = [ ( "Overview", Just Route.Path.Home_ ) - , ( "Dashboards", Nothing ) + , ( "Dashboards", Just Route.Path.Dashboards ) , ( dashboardName, Nothing ) ] in diff --git a/src/elm/Pages/Home_.elm b/src/elm/Pages/Home_.elm index 972bac422..72754f9c9 100644 --- a/src/elm/Pages/Home_.elm +++ b/src/elm/Pages/Home_.elm @@ -172,6 +172,13 @@ view shared route model = route { buttons = [ a + [ class "button" + , class "-outline" + , Util.testAttribute "dashboards-button" + , Route.Path.href Route.Path.Dashboards + ] + [ text "Dashboards" ] + , a [ class "button" , class "-outline" , Util.testAttribute "source-repos" diff --git a/src/elm/Vela.elm b/src/elm/Vela.elm index d7e91d96d..8f7d61f86 100644 --- a/src/elm/Vela.elm +++ b/src/elm/Vela.elm @@ -59,6 +59,7 @@ module Vela exposing , decodeBuildGraph , decodeBuilds , decodeDashboard + , decodeDashboards , decodeDeployment , decodeDeployments , decodeGraphInteraction @@ -209,6 +210,7 @@ type alias DashboardRepoCard = { org : String , name : String , counter : Int + , active : Bool , builds : List Build } @@ -220,6 +222,11 @@ decodeDashboard = |> optional "repos" (Json.Decode.list decodeDashboardRepoCard) [] +decodeDashboards : Decoder (List Dashboard) +decodeDashboards = + Json.Decode.list decodeDashboard + + decodeDashboardItem : Decoder DashboardItem decodeDashboardItem = Json.Decode.succeed DashboardItem @@ -239,6 +246,7 @@ decodeDashboardRepoCard = |> optional "org" string "" |> optional "name" string "" |> optional "counter" int -1 + |> optional "active" bool False |> optional "builds" (Json.Decode.list decodeBuild) [] @@ -259,6 +267,7 @@ type alias User = { id : Int , name : String , favorites : List String + , dashboards : List String , active : Bool , admin : Bool } @@ -270,13 +279,14 @@ decodeUser = |> required "id" int |> required "name" string |> optional "favorites" (Json.Decode.list string) [] + |> optional "dashboards" (Json.Decode.list string) [] |> required "active" bool |> optional "admin" bool False emptyUser : User emptyUser = - { id = -1, name = "", favorites = [], active = False, admin = False } + { id = -1, name = "", favorites = [], dashboards = [], active = False, admin = False } type alias UpdateUserPayload = diff --git a/src/scss/_dashboards.scss b/src/scss/_dashboards.scss index a7eaeda9b..a5bb7250e 100644 --- a/src/scss/_dashboards.scss +++ b/src/scss/_dashboards.scss @@ -2,6 +2,88 @@ // styles for the dashboard pages +.beta { + margin-left: 0.5rem; + padding: 0.25rem 0.4rem; + + font-size: 0.8rem; + vertical-align: top; + + background-color: var(--color-green-dark); +} + +.dashboards .item { + flex-wrap: wrap; +} + +.dashboard-repos { + display: flex; + flex-basis: 100%; + flex-wrap: wrap; + gap: 1rem; + margin-top: 1rem; + padding: 1rem 1rem 0 0; + + border-top: 2px dotted var(--color-bg-light); +} + +.dashboard-item-title code { + margin-left: 1rem; + + color: var(--color-bg-light); + font-size: 1rem; +} + +.dashboard-repos-item { + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.25rem 0.5rem; + + font-size: 0.8rem; + + background-color: var(--color-bg-darkest); + + .-icon { + width: 1rem; + height: 1rem; + + fill: none; + + &.-running { + background-color: var(--color-yellow); + + stroke: var(--color-bg); + } + + &.-failure, + &.-error { + background-color: var(--color-red); + + stroke: var(--color-bg); + } + + &.-canceled { + background-color: var(--color-cyan-dark); + + stroke: var(--color-bg); + } + + &.-success { + background-color: var(--color-green); + + stroke: var(--color-bg); + } + + &.-pending { + background-color: var(--color-bg-light); + + fill: var(--color-bg); + stroke: var(--color-bg); + } + } +} + .dashboard-title { border-bottom: var(--line-width) solid var(--color-secondary); }