diff --git a/cypress/fixtures/dashboard.json b/cypress/fixtures/dashboard.json new file mode 100644 index 000000000..a11573558 --- /dev/null +++ b/cypress/fixtures/dashboard.json @@ -0,0 +1,149 @@ +{ + "dashboard": { + "id": "86671eb5-a3ff-49e1-ad85-c3b2f648dcb2", + "name": "example", + "created_at": 1715749911, + "created_by": "CookieCat", + "updated_at": 1715749911, + "updated_by": "CookieCat", + "admins": [ + { + "id": 1, + "name": "CookieCat", + "active": true + } + ], + "repos": [ + { + "id": 2, + "name": "github/repo1" + }, + { + "id": 1, + "name": "github/repo2" + } + ] + }, + "repos": [ + { + "org": "github", + "name": "repo1", + "counter": 25, + "active": true, + "builds": [ + { + "number": 25, + "started": 1715965620, + "finished": 1715965674, + "sender": "github", + "status": "success", + "event": "push", + "branch": "main", + "link": "/github/repo1/25" + }, + { + "number": 24, + "started": 1715965597, + "finished": 1715965620, + "sender": "github", + "status": "canceled", + "event": "push", + "branch": "main", + "link": "/github/repo1/24" + }, + { + "number": 23, + "started": 1715964030, + "finished": 1715964083, + "sender": "github", + "status": "success", + "event": "push", + "branch": "main", + "link": "/github/repo1/23" + }, + { + "number": 22, + "started": 1715963978, + "finished": 1715964028, + "sender": "github", + "status": "success", + "event": "push", + "branch": "main", + "link": "/github/repo1/22" + }, + { + "number": 21, + "started": 1715919426, + "finished": 1715919479, + "sender": "github", + "status": "success", + "event": "push", + "branch": "main", + "link": "/github/repo1/21" + } + ] + }, + { + "org": "github", + "name": "repo2", + "active": true, + "builds": [ + { + "number": 15, + "started": 1715965620, + "finished": 1715965674, + "sender": "github", + "status": "failure", + "event": "push", + "branch": "main", + "link": "/github/repo2/15" + }, + { + "number": 14, + "started": 1715965597, + "finished": 1715965620, + "sender": "github", + "status": "canceled", + "event": "push", + "branch": "main", + "link": "/github/repo2/14" + }, + { + "number": 13, + "started": 1715964030, + "finished": 1715964083, + "sender": "github", + "status": "success", + "event": "push", + "branch": "main", + "link": "/github/repo2/13" + }, + { + "number": 12, + "started": 1715963978, + "finished": 1715964028, + "sender": "github", + "status": "success", + "event": "push", + "branch": "main", + "link": "/github/repo2/12" + }, + { + "number": 11, + "started": 1715919426, + "finished": 1715919479, + "sender": "github", + "status": "success", + "event": "push", + "branch": "main", + "link": "/github/repo2/11" + } + ] + }, + { + "org": "github", + "name": "repo3", + "active": true + } + ] +} diff --git a/cypress/fixtures/dashboard_no_repos.json b/cypress/fixtures/dashboard_no_repos.json new file mode 100644 index 000000000..ca3bd8c09 --- /dev/null +++ b/cypress/fixtures/dashboard_no_repos.json @@ -0,0 +1,18 @@ +{ + "dashboard": { + "id": "86671eb5-a3ff-49e1-ad85-c3b2f648dcb2", + "name": "example2", + "created_at": 1715749911, + "created_by": "CookieCat", + "updated_at": 1715749911, + "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 new file mode 100644 index 000000000..2ca8896b7 --- /dev/null +++ b/cypress/integration/dashboards.spec.js @@ -0,0 +1,126 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +context('Dashboards', () => { + context('server returns dashboard with 3 cards, one without builds', () => { + beforeEach(() => { + cy.server(); + cy.route( + 'GET', + '*api/v1/dashboards/86671eb5-a3ff-49e1-ad85-c3b2f648dcb2', + 'fixture:dashboard.json', + ); + cy.login('/dashboards/86671eb5-a3ff-49e1-ad85-c3b2f648dcb2'); + }); + + it('shows 3 dashboard cards', () => { + cy.get('[data-test=dashboard-card]').should('have.length', 3); + }); + + it('shows an empty state when there are no builds', () => { + cy.get('[data-test=dashboard-card]') + .last() + .contains('waiting for builds'); + }); + + it('shows success build icon in header in the first card', () => { + cy.get('[data-test=dashboard-card]') + .first() + .within(() => { + cy.get('.-icon').should('have.class', '-success'); + }); + }); + + it('shows failure build icon in header in the first card', () => { + cy.get('[data-test=dashboard-card]') + .eq(1) + .within(() => { + cy.get('.-icon').should('have.class', '-failure'); + }); + }); + + it('org link in card header goes to org page', () => { + cy.get('[data-test=dashboard-card]') + .first() + .within(() => { + cy.get('.card-org').click(); + cy.location('pathname').should('eq', '/github'); + }); + }); + + it('repo link in card header goes to repo page', () => { + cy.get('[data-test=dashboard-card]') + .first() + .within(() => { + cy.get('.card-repo').click(); + cy.location('pathname').should('eq', '/github/repo1'); + }); + }); + + it('build link in card goes to build page', () => { + cy.get('[data-test=dashboard-card]') + .first() + .within(() => { + cy.get('.card-build-data li:first-child a').click(); + cy.location('pathname').should('eq', '/github/repo1/25'); + }); + }); + + it('recent build link goes to respective build page', () => { + cy.get('[data-test=recent-build-link-25]').click(); + cy.location('pathname').should('eq', '/github/repo1/25'); + }); + }); + + context('server returning dashboard without repos', () => { + beforeEach(() => { + cy.server(); + cy.route( + 'GET', + '*api/v1/dashboards/86671eb5-a3ff-49e1-ad85-c3b2f648dcb2', + 'fixture:dashboard_no_repos.json', + ); + cy.login('/dashboards/86671eb5-a3ff-49e1-ad85-c3b2f648dcb2'); + }); + + it('shows message when there are no repositories added', () => { + cy.get('[data-test=dashboard]').contains( + `This dashboard doesn't have repositories added yet`, + ); + }); + }); + + context('dashboard not found', () => { + beforeEach(() => { + cy.server(); + cy.route({ + method: 'GET', + status: 404, + url: '*api/v1/dashboards/deadbeef', + response: { + error: + 'unable to read dashboard deadbeef: ERROR: invalid input syntax for type uuid: "deadbeef" (SQLSTATE 22P02)', + }, + }); + cy.login('/dashboards/deadbeef'); + }); + + it('shows a not found message', () => { + cy.get('[data-test=dashboard]').contains( + 'Dashboard "deadbeef" not found. Please check the URL.', + ); + }); + }); + + 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 8802046a1..225bd70ea 100644 --- a/src/elm/Api/Endpoint.elm +++ b/src/elm/Api/Endpoint.elm @@ -25,6 +25,7 @@ type Endpoint | Login | Logout | CurrentUser + | Dashboard String | Deployment Vela.Org Vela.Repo (Maybe String) | Deployments (Maybe Pagination.Page) (Maybe Pagination.PerPage) Vela.Org Vela.Repo | Token @@ -163,6 +164,9 @@ toUrl api endpoint = Deployments maybePage maybePerPage org repo -> url api [ "deployments", org, repo ] <| Pagination.toQueryParams maybePage maybePerPage + Dashboard dashboard -> + url api [ "dashboards", dashboard ] [] + Workers maybePage maybePerPage -> url api [ "workers" ] <| Pagination.toQueryParams maybePage maybePerPage diff --git a/src/elm/Api/Operations.elm b/src/elm/Api/Operations.elm index 8a00acdad..96d62acb5 100644 --- a/src/elm/Api/Operations.elm +++ b/src/elm/Api/Operations.elm @@ -27,6 +27,7 @@ module Api.Operations exposing , getBuildStepLog , getBuildSteps , getCurrentUser + , getDashboard , getOrgBuilds , getOrgRepos , getOrgSecret @@ -1251,3 +1252,15 @@ deleteSharedSecret baseUrl session options = ) Json.Decode.string |> withAuth session + + +{-| getDashboard : retrieve a dashboard. +-} +getDashboard : String -> Session -> { a | dashboardId : String } -> Request Vela.Dashboard +getDashboard baseUrl session options = + get baseUrl + (Api.Endpoint.Dashboard + options.dashboardId + ) + Vela.decodeDashboard + |> withAuth session diff --git a/src/elm/Components/DashboardRepoCard.elm b/src/elm/Components/DashboardRepoCard.elm new file mode 100644 index 000000000..4314849c1 --- /dev/null +++ b/src/elm/Components/DashboardRepoCard.elm @@ -0,0 +1,204 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Components.DashboardRepoCard exposing (view) + +import Components.RecentBuilds +import Components.Svgs +import DateFormat.Relative +import FeatherIcons +import Html + exposing + ( Html + , a + , br + , code + , div + , header + , li + , p + , section + , small + , span + , strong + , text + , ul + ) +import Html.Attributes + exposing + ( attribute + , class + , title + ) +import RemoteData +import Route.Path +import Shared +import Time +import Utils.Helpers as Util +import Vela + + +{-| Props : alias for an object representing properties for a dashboard repo card component. +-} +type alias Props = + { card : Vela.DashboardRepoCard + } + + +{-| view : renders a dashboard repo card component. +-} +view : Shared.Model -> Props -> Html msg +view shared props = + let + cardProps = + case List.head props.card.builds of + Just build -> + let + relativeAge = + build.started + |> Util.secondsToMillis + |> Time.millisToPosix + |> DateFormat.Relative.relativeTime shared.time + + runtime = + Util.formatRunTime shared.time build.started build.finished + in + { icon = Components.Svgs.recentBuildStatusToIcon build.status 0 + , build = + a + [ Route.Path.href <| + Route.Path.Org__Repo__Build_ + { org = props.card.org + , repo = props.card.name + , build = String.fromInt build.number + } + ] + [ text <| "#" ++ String.fromInt build.number ] + , event = build.event + , branch = build.branch + , sender = build.sender + , age = + if build.started > 0 then + relativeAge + + else + "-" + , duration = runtime + , recentBuilds = + div + [ class "dashboard-recent-builds" ] + [ Components.RecentBuilds.view shared + { builds = RemoteData.succeed props.card.builds + , build = RemoteData.succeed build + , num = 5 + , toPath = + \b -> + Route.Path.Org__Repo__Build_ + { org = props.card.org + , repo = props.card.name + , build = b + } + , showTitle = False + } + ] + } + + Nothing -> + let + dash = + "-" + in + { icon = Components.Svgs.recentBuildStatusToIcon Vela.Pending 0 + , build = span [] [ text dash ] + , event = dash + , branch = dash + , age = dash + , sender = dash + , duration = "--:--" + , recentBuilds = div [ class "dashboard-recent-builds", class "-none" ] [ text "waiting for builds" ] + } + in + section [ class "card", Util.testAttribute "dashboard-card" ] + [ header [ class "card-header" ] + [ cardProps.icon + , p [] + [ a + [ class "card-org" + , Route.Path.href <| + Route.Path.Org_ + { org = props.card.org + } + ] + [ small [] [ text props.card.org ] ] + , br [] [] + , a + [ class "card-repo -truncate" + , Route.Path.href <| + Route.Path.Org__Repo_ + { org = props.card.org + , repo = props.card.name + } + ] + [ strong + [ Util.attrIf (String.length props.card.name > 25) (title props.card.name) ] + [ text props.card.name ] + ] + ] + ] + , ul [ class "card-build-data" ] <| + [ -- build link + li [] + [ FeatherIcons.cornerDownRight + |> FeatherIcons.withSize 20 + |> FeatherIcons.toHtml [ attribute "aria-label" "go-to-build icon" ] + , cardProps.build + ] + + -- event + , li [] + [ FeatherIcons.send + |> FeatherIcons.withSize 20 + |> FeatherIcons.toHtml [ attribute "aria-label" "event icon" ] + , span [] [ text <| cardProps.event ] + ] + + -- branch + , li [] + [ FeatherIcons.gitBranch + |> FeatherIcons.withSize 20 + |> FeatherIcons.toHtml [ attribute "aria-label" "branch icon" ] + , span + [ Util.attrIf (String.length cardProps.branch > 15) (title cardProps.branch) ] + [ text <| cardProps.branch ] + ] + + -- sender + , li [] + [ span + [ Util.attrIf (String.length cardProps.sender > 15) (title cardProps.sender) ] + [ text <| cardProps.sender ] + , FeatherIcons.user + |> FeatherIcons.withSize 20 + |> FeatherIcons.toHtml [ attribute "aria-label" "build-sender icon" ] + ] + + -- age + , li [] + [ span [] [ text <| cardProps.age ] + , FeatherIcons.calendar + |> FeatherIcons.withSize 20 + |> FeatherIcons.toHtml [ attribute "aria-label" "time-started icon" ] + ] + + -- duration + , li [] + [ code [] [ text <| cardProps.duration ] + , FeatherIcons.clock + |> FeatherIcons.withSize 20 + |> FeatherIcons.toHtml [ attribute "aria-label" "duration icon" ] + ] + ] + , cardProps.recentBuilds + ] diff --git a/src/elm/Components/Header.elm b/src/elm/Components/Header.elm index 043c54a04..c598571d5 100644 --- a/src/elm/Components/Header.elm +++ b/src/elm/Components/Header.elm @@ -57,7 +57,7 @@ view shared props = identityAttributeList = Util.open props.showId in - header [] + header [ class "main-header" ] [ div [ class "identity", id "identity", Util.testAttribute "identity" ] [ a [ Route.Path.href Route.Path.Home_ diff --git a/src/elm/Components/RecentBuilds.elm b/src/elm/Components/RecentBuilds.elm index efd013acd..14ffbffc7 100644 --- a/src/elm/Components/RecentBuilds.elm +++ b/src/elm/Components/RecentBuilds.elm @@ -27,6 +27,7 @@ type alias Props = , build : WebData Vela.Build , num : Int , toPath : String -> Route.Path.Path + , showTitle : Bool } @@ -38,11 +39,19 @@ type alias Props = -} view : Shared.Model -> Props -> Html msg view shared props = + let + viewTitle = + if props.showTitle then + p [ class "build-history-title" ] [ text "Recent Builds" ] + + else + text "" + in case props.builds of RemoteData.Success builds -> if List.length builds > 0 then div [ class "build-history" ] - [ p [ class "build-history-title" ] [ text "Recent Builds" ] + [ viewTitle , ul [ Util.testAttribute "build-history", class "previews" ] <| List.indexedMap (viewRecentBuild shared props) <| List.take props.num builds @@ -145,7 +154,11 @@ buildInfo build = -} viewTooltipField : String -> String -> Html msg viewTooltipField key value = - li [ class "line" ] - [ span [] [ text key ] - , span [] [ text value ] - ] + if String.isEmpty value then + text "" + + else + li [ class "line" ] + [ span [] [ text key ] + , span [] [ text value ] + ] diff --git a/src/elm/Effect.elm b/src/elm/Effect.elm index 5073903eb..7b8817a13 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, getBuild, getBuildGraph, getBuildServiceLog, getBuildServices, getBuildStepLog, getBuildSteps, getCurrentUser, getCurrentUserShared, 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, 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, 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, updateRepoSchedule, updateRepoSecret, updateSettings, updateSharedSecret, updateSourceReposShared ) {-| @@ -1321,3 +1321,21 @@ replaceRouteRemoveTabHistorySkipDomFocus route = else none + + +getDashboard : + { baseUrl : String + , session : Auth.Session.Session + , onResponse : Result (Http.Detailed.Error String) ( Http.Metadata, Vela.Dashboard ) -> msg + , dashboardId : String + } + -> Effect msg +getDashboard options = + Api.try + options.onResponse + (Api.Operations.getDashboard + options.baseUrl + options.session + options + ) + |> sendCmd diff --git a/src/elm/Layouts/Default/Build.elm b/src/elm/Layouts/Default/Build.elm index a1f72c35f..e4adaf2d0 100644 --- a/src/elm/Layouts/Default/Build.elm +++ b/src/elm/Layouts/Default/Build.elm @@ -447,6 +447,7 @@ view props shared route { toContentMsg, model, content } = , build = model.build , num = 10 , toPath = props.toBuildPath + , showTitle = True } , Components.Build.view shared { build = model.build diff --git a/src/elm/Main.elm b/src/elm/Main.elm index cb78afe62..501d7e659 100644 --- a/src/elm/Main.elm +++ b/src/elm/Main.elm @@ -44,6 +44,8 @@ import Pages.Dash.Secrets.Engine_.Repo.Org_.Repo_.Name_ import Pages.Dash.Secrets.Engine_.Shared.Org_.Team_ import Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Add import Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Name_ +import Pages.Dashboards +import Pages.Dashboards.Dashboard_ import Pages.Home_ import Pages.NotFound_ import Pages.Org_ @@ -942,6 +944,54 @@ initPageAndLayout model = } ) + Route.Path.Dashboards -> + runWhenAuthenticatedWithLayout + model + (\user -> + let + page : Page.Page Pages.Dashboards.Model Pages.Dashboards.Msg + page = + Pages.Dashboards.page user model.shared (Route.fromUrl () model.url) + + ( pageModel, pageEffect ) = + Page.init page () + in + { page = + Tuple.mapBoth + Main.Pages.Model.Dashboards + (Effect.map Main.Pages.Msg.Dashboards >> fromPageEffect model) + ( pageModel, pageEffect ) + , layout = + Page.layout pageModel page + |> Maybe.map (Layouts.map (Main.Pages.Msg.Dashboards >> Page)) + |> Maybe.map (initLayout model) + } + ) + + Route.Path.Dashboards_Dashboard_ params -> + runWhenAuthenticatedWithLayout + model + (\user -> + let + page : Page.Page Pages.Dashboards.Dashboard_.Model Pages.Dashboards.Dashboard_.Msg + page = + Pages.Dashboards.Dashboard_.page user model.shared (Route.fromUrl params model.url) + + ( pageModel, pageEffect ) = + Page.init page () + in + { page = + Tuple.mapBoth + (Main.Pages.Model.Dashboards_Dashboard_ params) + (Effect.map Main.Pages.Msg.Dashboards_Dashboard_ >> fromPageEffect model) + ( pageModel, pageEffect ) + , layout = + Page.layout pageModel page + |> Maybe.map (Layouts.map (Main.Pages.Msg.Dashboards_Dashboard_ >> Page)) + |> Maybe.map (initLayout model) + } + ) + Route.Path.Org_ params -> runWhenAuthenticatedWithLayout model @@ -1720,6 +1770,26 @@ updateFromPage msg model = (Page.update (Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Name_.page user model.shared (Route.fromUrl params model.url)) pageMsg pageModel) ) + ( Main.Pages.Msg.Dashboards pageMsg, Main.Pages.Model.Dashboards pageModel ) -> + runWhenAuthenticated + model + (\user -> + Tuple.mapBoth + Main.Pages.Model.Dashboards + (Effect.map Main.Pages.Msg.Dashboards >> fromPageEffect model) + (Page.update (Pages.Dashboards.page user model.shared (Route.fromUrl () model.url)) pageMsg pageModel) + ) + + ( Main.Pages.Msg.Dashboards_Dashboard_ pageMsg, Main.Pages.Model.Dashboards_Dashboard_ params pageModel ) -> + runWhenAuthenticated + model + (\user -> + Tuple.mapBoth + (Main.Pages.Model.Dashboards_Dashboard_ params) + (Effect.map Main.Pages.Msg.Dashboards_Dashboard_ >> fromPageEffect model) + (Page.update (Pages.Dashboards.Dashboard_.page user model.shared (Route.fromUrl params model.url)) pageMsg pageModel) + ) + ( Main.Pages.Msg.Org_ pageMsg, Main.Pages.Model.Org_ params pageModel ) -> runWhenAuthenticated model @@ -2077,6 +2147,18 @@ toLayoutFromPage model = |> Maybe.andThen (Page.layout pageModel) |> Maybe.map (Layouts.map (Main.Pages.Msg.Dash_Secrets_Engine__Shared_Org__Team__Name_ >> Page)) + Main.Pages.Model.Dashboards pageModel -> + Route.fromUrl () model.url + |> toAuthProtectedPage model Pages.Dashboards.page + |> Maybe.andThen (Page.layout pageModel) + |> Maybe.map (Layouts.map (Main.Pages.Msg.Dashboards >> Page)) + + Main.Pages.Model.Dashboards_Dashboard_ params pageModel -> + Route.fromUrl params model.url + |> toAuthProtectedPage model Pages.Dashboards.Dashboard_.page + |> Maybe.andThen (Page.layout pageModel) + |> Maybe.map (Layouts.map (Main.Pages.Msg.Dashboards_Dashboard_ >> Page)) + Main.Pages.Model.Org_ params pageModel -> Route.fromUrl params model.url |> toAuthProtectedPage model Pages.Org_.page @@ -2365,6 +2447,24 @@ subscriptions model = ) (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Dashboards pageModel -> + Auth.Action.subscriptions + (\user -> + Page.subscriptions (Pages.Dashboards.page user model.shared (Route.fromUrl () model.url)) pageModel + |> Sub.map Main.Pages.Msg.Dashboards + |> Sub.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + + Main.Pages.Model.Dashboards_Dashboard_ params pageModel -> + Auth.Action.subscriptions + (\user -> + Page.subscriptions (Pages.Dashboards.Dashboard_.page user model.shared (Route.fromUrl params model.url)) pageModel + |> Sub.map Main.Pages.Msg.Dashboards_Dashboard_ + |> Sub.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Org_ params pageModel -> Auth.Action.subscriptions (\user -> @@ -2857,6 +2957,24 @@ viewPage model = ) (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Dashboards pageModel -> + Auth.Action.view (View.map never (Auth.viewCustomPage model.shared (Route.fromUrl () model.url))) + (\user -> + Page.view (Pages.Dashboards.page user model.shared (Route.fromUrl () model.url)) pageModel + |> View.map Main.Pages.Msg.Dashboards + |> View.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + + Main.Pages.Model.Dashboards_Dashboard_ params pageModel -> + Auth.Action.view (View.map never (Auth.viewCustomPage model.shared (Route.fromUrl () model.url))) + (\user -> + Page.view (Pages.Dashboards.Dashboard_.page user model.shared (Route.fromUrl params model.url)) pageModel + |> View.map Main.Pages.Msg.Dashboards_Dashboard_ + |> View.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Org_ params pageModel -> Auth.Action.view (View.map never (Auth.viewCustomPage model.shared (Route.fromUrl () model.url))) (\user -> @@ -3220,6 +3338,26 @@ toPageUrlHookCmd model routes = ) (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Dashboards pageModel -> + Auth.Action.command + (\user -> + Page.toUrlMessages routes (Pages.Dashboards.page user model.shared (Route.fromUrl () model.url)) + |> List.map Main.Pages.Msg.Dashboards + |> List.map Page + |> toCommands + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + + Main.Pages.Model.Dashboards_Dashboard_ params pageModel -> + Auth.Action.command + (\user -> + Page.toUrlMessages routes (Pages.Dashboards.Dashboard_.page user model.shared (Route.fromUrl params model.url)) + |> List.map Main.Pages.Msg.Dashboards_Dashboard_ + |> List.map Page + |> toCommands + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Org_ params pageModel -> Auth.Action.command (\user -> @@ -3580,6 +3718,12 @@ isAuthProtected routePath = Route.Path.Dash_Secrets_Engine__Shared_Org__Team__Name_ _ -> True + Route.Path.Dashboards -> + True + + Route.Path.Dashboards_Dashboard_ _ -> + True + Route.Path.Org_ _ -> True diff --git a/src/elm/Main/Pages/Model.elm b/src/elm/Main/Pages/Model.elm index 0bfe04309..c9d771308 100644 --- a/src/elm/Main/Pages/Model.elm +++ b/src/elm/Main/Pages/Model.elm @@ -21,6 +21,8 @@ import Pages.Dash.Secrets.Engine_.Repo.Org_.Repo_.Name_ import Pages.Dash.Secrets.Engine_.Shared.Org_.Team_ import Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Add import Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Name_ +import Pages.Dashboards +import Pages.Dashboards.Dashboard_ import Pages.Home_ import Pages.NotFound_ import Pages.Org_ @@ -60,6 +62,8 @@ type Model | Dash_Secrets_Engine__Shared_Org__Team_ { engine : String, org : String, team : String } Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Model | Dash_Secrets_Engine__Shared_Org__Team__Add { engine : String, org : String, team : String } Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Add.Model | Dash_Secrets_Engine__Shared_Org__Team__Name_ { engine : String, org : String, team : String, name : String } Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Name_.Model + | Dashboards Pages.Dashboards.Model + | Dashboards_Dashboard_ { dashboard : String } Pages.Dashboards.Dashboard_.Model | Org_ { org : String } Pages.Org_.Model | Org__Builds { org : String } Pages.Org_.Builds.Model | Org__Repo_ { org : String, repo : String } Pages.Org_.Repo_.Model diff --git a/src/elm/Main/Pages/Msg.elm b/src/elm/Main/Pages/Msg.elm index cac5dae03..84e1f3f5c 100644 --- a/src/elm/Main/Pages/Msg.elm +++ b/src/elm/Main/Pages/Msg.elm @@ -21,6 +21,8 @@ import Pages.Dash.Secrets.Engine_.Repo.Org_.Repo_.Name_ import Pages.Dash.Secrets.Engine_.Shared.Org_.Team_ import Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Add import Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Name_ +import Pages.Dashboards +import Pages.Dashboards.Dashboard_ import Pages.Home_ import Pages.NotFound_ import Pages.Org_ @@ -59,6 +61,8 @@ type Msg | Dash_Secrets_Engine__Shared_Org__Team_ Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Msg | Dash_Secrets_Engine__Shared_Org__Team__Add Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Add.Msg | Dash_Secrets_Engine__Shared_Org__Team__Name_ Pages.Dash.Secrets.Engine_.Shared.Org_.Team_.Name_.Msg + | Dashboards Pages.Dashboards.Msg + | Dashboards_Dashboard_ Pages.Dashboards.Dashboard_.Msg | Org_ Pages.Org_.Msg | Org__Builds Pages.Org_.Builds.Msg | Org__Repo_ Pages.Org_.Repo_.Msg diff --git a/src/elm/Pages/Dashboards.elm b/src/elm/Pages/Dashboards.elm new file mode 100644 index 000000000..09c63b37f --- /dev/null +++ b/src/elm/Pages/Dashboards.elm @@ -0,0 +1,156 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Pages.Dashboards exposing (Model, Msg, page) + +import Auth +import Components.Crumbs +import Components.Nav +import Effect exposing (Effect) +import Html exposing (code, h1, h2, main_, p, text) +import Html.Attributes exposing (class) +import Layouts +import Page exposing (Page) +import Route exposing (Route) +import Route.Path +import Shared +import Utils.Helpers as Util +import View exposing (View) + + +{-| page : takes user, shared model, route, and returns the dashboards page. +-} +page : Auth.User -> Shared.Model -> Route () -> Page Model Msg +page user shared route = + Page.new + { init = init shared route + , update = update shared route + , subscriptions = subscriptions + , view = view shared route + } + |> Page.withLayout (toLayout user route) + + + +-- LAYOUT + + +{-| toLayout : takes user, model, and passes the dashboards page info to Layouts. +-} +toLayout : Auth.User -> Route () -> Model -> Layouts.Layout Msg +toLayout user route model = + Layouts.Default + { helpCommands = + [ { name = "List Dashboards" + , content = "vela get dashboards" + , docs = Just "dashboard/get" + } + , { name = "Add Dashboard" + , content = "vela add dashboard --name MyDashboard" + , docs = Just "dashboard/add" + } + , { name = "Add Dashboard With Repositories" + , content = "vela add dashboard --name MyDashboard --repos org1/repo1,org2/repo1" + , docs = Just "dashboard/add" + } + , { name = "Add Dashboard With Repositories And Filters" + , content = "vela add dashboard --name MyDashboard --repos org1/repo1,org2/repo1 --branch main --event push" + , docs = Just "dashboard/add" + } + , { name = "Add Dashboard With Multiple Admins" + , content = "vela add dashboard --name MyDashboard --admins username1,username2" + , docs = Just "dashboard/add" + } + ] + } + + + +-- INIT + + +{-| Model : alias for a model object for the dashboards page. +-} +type alias Model = + {} + + +{-| init : takes shared model and initializes dashboards page input arguments. +-} +init : Shared.Model -> Route () -> () -> ( Model, Effect Msg ) +init shared route () = + ( {} + , Effect.none + ) + + + +-- UPDATE + + +{-| Msg : custom type with possible messages. +-} +type Msg + = NoOp + + +{-| update : takes current model, message, and returns an updated model and effect. +-} +update : Shared.Model -> Route () -> Msg -> Model -> ( Model, Effect Msg ) +update shared route msg model = + case msg of + NoOp -> + ( model + , Effect.none + ) + + + +-- SUBSCRIPTIONS + + +{-| subscriptions : takes model and returns the subscriptions for auto refreshing the page. +-} +subscriptions : Model -> Sub Msg +subscriptions model = + Sub.none + + + +-- VIEW + + +{-| view : takes models, route, and creates the html for the dashboards page. +-} +view : Shared.Model -> Route () -> Model -> View Msg +view shared route model = + let + crumbs = + [ ( "Overview", Just Route.Path.Home_ ) + , ( "Dashboards", Nothing ) + ] + in + { title = "Dashboards" + , body = + [ Components.Nav.view + shared + route + { 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." ] + ] + ] + } diff --git a/src/elm/Pages/Dashboards/Dashboard_.elm b/src/elm/Pages/Dashboards/Dashboard_.elm new file mode 100644 index 000000000..1f37abfdf --- /dev/null +++ b/src/elm/Pages/Dashboards/Dashboard_.elm @@ -0,0 +1,268 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Pages.Dashboards.Dashboard_ exposing (Model, Msg, page) + +import Auth +import Components.Crumbs +import Components.DashboardRepoCard +import Components.Loading +import Components.Nav +import Effect exposing (Effect) +import Html + exposing + ( br + , code + , div + , h1 + , main_ + , p + , span + , text + ) +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) + + +{-| page : takes user, shared model, route, and returns the dashboard page. +-} +page : Auth.User -> Shared.Model -> Route { dashboard : String } -> Page Model Msg +page user shared route = + Page.new + { init = init shared route + , update = update shared route + , subscriptions = subscriptions + , view = view shared route + } + |> Page.withLayout (toLayout user route) + + + +-- LAYOUT + + +{-| toLayout : takes user, model, and passes the dashboard page info to Layouts. +-} +toLayout : Auth.User -> Route { dashboard : String } -> Model -> Layouts.Layout Msg +toLayout user route model = + Layouts.Default + { helpCommands = + [ { name = "View Dashboard" + , content = + "vela view dashboard --id " + ++ route.params.dashboard + , docs = Just "dashboard/view" + } + , { name = "Update Dashboard To Change Name" + , content = + "vela update dashboard --id " + ++ route.params.dashboard + ++ " --name new-name" + , docs = Just "dashboard/update" + } + , { name = "Update Dashboard To Add A Repository" + , content = + "vela update dashboard --id " + ++ route.params.dashboard + ++ " --add-repos org/repo" + , docs = Just "dashboard/update" + } + , { name = "Update Dashboard To Add An Admin" + , content = + "vela update dashboard --id " + ++ route.params.dashboard + ++ " --add-admins username" + , docs = Just "dashboard/update" + } + ] + } + + + +-- INIT + + +{-| Model : alias for a model object for the dashboard page. +-} +type alias Model = + { dashboard : WebData Vela.Dashboard } + + +{-| init : takes shared model and initializes dashboard page input arguments. +-} +init : Shared.Model -> Route { dashboard : String } -> () -> ( Model, Effect Msg ) +init shared route () = + ( { dashboard = RemoteData.Loading } + , Effect.getDashboard + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetDashboardResponse + , dashboardId = route.params.dashboard + } + ) + + + +-- UPDATE + + +{-| Msg : custom type with possible messages. +-} +type Msg + = GetDashboardResponse (Result (Http.Detailed.Error String) ( Http.Metadata, Vela.Dashboard )) + -- REFRESH + | Tick { time : Time.Posix, interval : Interval.Interval } + + +{-| update : takes current model, message, and returns an updated model and effect. +-} +update : Shared.Model -> Route { dashboard : String } -> Msg -> Model -> ( Model, Effect Msg ) +update shared route msg model = + case msg of + GetDashboardResponse response -> + case response of + Ok ( _, dashboard ) -> + ( { model | dashboard = RemoteData.Success dashboard } + , Effect.none + ) + + Err error -> + ( { model | dashboard = Errors.toFailure error } + , Effect.handleHttpError + { error = error + , shouldShowAlertFn = Errors.showAlertAlways + } + ) + + Tick options -> + ( model + , Effect.getDashboard + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetDashboardResponse + , dashboardId = route.params.dashboard + } + ) + + + +-- SUBSCRIPTIONS + + +{-| subscriptions : takes model and returns the subscriptions for auto refreshing the page. +-} +subscriptions : Model -> Sub Msg +subscriptions model = + Sub.batch + [ Interval.tickEveryFiveSeconds Tick + ] + + + +-- VIEW + + +{-| view : takes models, route, and creates the html for the dashboard page. +-} +view : Shared.Model -> Route { dashboard : String } -> Model -> View Msg +view shared route model = + let + dashboardName = + case model.dashboard of + RemoteData.Success dashboard -> + dashboard.dashboard.name + + RemoteData.Loading -> + "" + + _ -> + "Unknown" + + pageTitle = + dashboardName ++ " Dashboard" + + crumbs = + [ ( "Overview", Just Route.Path.Home_ ) + , ( "Dashboards", Nothing ) + , ( dashboardName, Nothing ) + ] + in + { title = pageTitle + , body = + [ Components.Nav.view + shared + route + { buttons = [] + , crumbs = Components.Crumbs.view route.path crumbs + } + , main_ [ class "content-wrap" ] + [ div [ class "dashboard", Util.testAttribute "dashboard" ] + (case model.dashboard of + RemoteData.Success dashboard -> + [ h1 [ class "dashboard-title" ] [ text dashboard.dashboard.name ] + , div [ class "cards" ] + (if List.isEmpty dashboard.repos then + [ p + [] + [ text "This dashboard doesn't have repositories added yet. Add some with the CLI:" + , br [] [] + , code [ class "shell" ] + [ text ("vela update dashboard --id " ++ route.params.dashboard ++ " --add-repos org/repo") ] + ] + ] + + else + List.map + (\repo -> + Components.DashboardRepoCard.view shared + { card = repo + } + ) + dashboard.repos + ) + ] + + RemoteData.Failure error -> + [ span [] + [ text <| + case error of + Http.BadStatus statusCode -> + case statusCode of + 401 -> + "Unauthorized to retrieve dashboard" + + 404 -> + "Dashboard \"" ++ route.params.dashboard ++ "\" not found. Please check the URL." + + _ -> + "No dashboard found, there was an error with the server" + + _ -> + "No dashboard found, there was an error with the server" + ] + ] + + _ -> + [ Components.Loading.viewSmallLoader ] + ) + ] + ] + } diff --git a/src/elm/Route/Path.elm b/src/elm/Route/Path.elm index 78b2e7adf..4381696bb 100644 --- a/src/elm/Route/Path.elm +++ b/src/elm/Route/Path.elm @@ -29,6 +29,8 @@ type Path | Dash_Secrets_Engine__Shared_Org__Team_ { engine : String, org : String, team : String } | Dash_Secrets_Engine__Shared_Org__Team__Add { engine : String, org : String, team : String } | Dash_Secrets_Engine__Shared_Org__Team__Name_ { engine : String, org : String, team : String, name : String } + | Dashboards + | Dashboards_Dashboard_ { dashboard : String } | Org_ { org : String } | Org__Builds { org : String } | Org__Repo_ { org : String, repo : String } @@ -160,6 +162,15 @@ fromString urlPath = } |> Just + "dashboards" :: [] -> + Just Dashboards + + "dashboards" :: dashboard_ :: [] -> + Dashboards_Dashboard_ + { dashboard = dashboard_ + } + |> Just + org_ :: [] -> Org_ { org = org_ @@ -341,6 +352,12 @@ toString path = Dash_Secrets_Engine__Shared_Org__Team__Name_ params -> [ "-", "secrets", params.engine, "shared", params.org, params.team, params.name ] + Dashboards -> + [ "dashboards" ] + + Dashboards_Dashboard_ params -> + [ "dashboards", params.dashboard ] + Org_ params -> [ params.org ] diff --git a/src/elm/Utils/Helpers.elm b/src/elm/Utils/Helpers.elm index f6e22f573..470bf98ff 100644 --- a/src/elm/Utils/Helpers.elm +++ b/src/elm/Utils/Helpers.elm @@ -230,7 +230,12 @@ formatRunTime now started finished = seconds = runTimeSeconds runtime in - String.join ":" [ minutes, seconds ] + -- treating 00:00 as an unreasonable runtime state + if minutes == "00" && seconds == "00" then + "--:--" + + else + String.join ":" [ minutes, seconds ] {-| buildRunTime : calculates build runtime using current application time and build times, returned in seconds. @@ -238,18 +243,33 @@ formatRunTime now started finished = buildRunTime : Posix -> Int -> Int -> Int buildRunTime now started finished = let - start = - started - - end = - if finished /= 0 then - finished + isValid : Int -> Bool + isValid value = + value > 0 + + ( start, end ) = + case ( isValid started, isValid finished ) of + -- neither started nor finished + ( False, False ) -> + ( 0, 0 ) + + -- if finished but not started, we won't know duration + ( False, True ) -> + ( 0, 0 ) + + -- started, but not finished + ( True, False ) -> + ( started, millisToSeconds <| posixToMillis now ) + + -- both started and finished are legit + -- if it finished before it started, something is very wrong + -- otherwise, return started and finished + ( True, True ) -> + if started > finished then + ( 0, 0 ) - else if started == 0 then - start - - else - millisToSeconds <| posixToMillis now + else + ( started, finished ) in end - start diff --git a/src/elm/Vela.elm b/src/elm/Vela.elm index 27d3fa70e..08c9bf11f 100644 --- a/src/elm/Vela.elm +++ b/src/elm/Vela.elm @@ -12,6 +12,8 @@ module Vela exposing , BuildGraphInteraction , BuildGraphNode , BuildNumber + , Dashboard + , DashboardRepoCard , Deployment , DeploymentPayload , EnableRepoPayload @@ -56,6 +58,7 @@ module Vela exposing , decodeBuild , decodeBuildGraph , decodeBuilds + , decodeDashboard , decodeDeployment , decodeDeployments , decodeGraphInteraction @@ -171,6 +174,82 @@ type alias Ref = +-- DASHBOARD + + +type alias Dashboard = + { dashboard : DashboardItem + , repos : List DashboardRepoCard + } + + +type alias DashboardItem = + { id : String + , name : String + , created_at : Int + , created_by : String + , updated_at : Int + , updated_by : String + , admins : List User + , repos : List DashboardRepo + } + + +type alias DashboardRepo = + { id : Int + , name : String + , branches : List String + , events : List String + } + + +type alias DashboardRepoCard = + { org : String + , name : String + , counter : Int + , builds : List Build + } + + +decodeDashboard : Decoder Dashboard +decodeDashboard = + Json.Decode.succeed Dashboard + |> optional "dashboard" decodeDashboardItem (DashboardItem "" "" 0 "" 0 "" [] []) + |> optional "repos" (Json.Decode.list decodeDashboardRepoCard) [] + + +decodeDashboardItem : Decoder DashboardItem +decodeDashboardItem = + Json.Decode.succeed DashboardItem + |> optional "id" string "" + |> optional "name" string "" + |> optional "created_at" int -1 + |> optional "created_by" string "" + |> optional "updated_at" int -1 + |> optional "updated_by" string "" + |> optional "admins" (Json.Decode.list decodeUser) [] + |> optional "repos" (Json.Decode.list decodeDashboardRepo) [] + + +decodeDashboardRepoCard : Decoder DashboardRepoCard +decodeDashboardRepoCard = + Json.Decode.succeed DashboardRepoCard + |> optional "org" string "" + |> optional "name" string "" + |> optional "counter" int -1 + |> optional "builds" (Json.Decode.list decodeBuild) [] + + +decodeDashboardRepo : Decoder DashboardRepo +decodeDashboardRepo = + Json.Decode.succeed DashboardRepo + |> optional "id" int -1 + |> optional "name" string "" + |> optional "branches" (Json.Decode.list string) [] + |> optional "events" (Json.Decode.list string) [] + + + -- USER diff --git a/src/scss/_dashboards.scss b/src/scss/_dashboards.scss new file mode 100644 index 000000000..a7eaeda9b --- /dev/null +++ b/src/scss/_dashboards.scss @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 + +// styles for the dashboard pages + +.dashboard-title { + border-bottom: var(--line-width) solid var(--color-secondary); +} + +// holds all the dashboard cards +.cards { + display: flex; + flex-wrap: wrap; +} + +// an individual dashboard card +.card { + flex: 0 0 27rem; + margin-right: 2rem; + margin-bottom: 2rem; + padding: 0.5rem; + + background-color: var(--color-bg-dark); +} + +// a dashboard card header containing org, repo, and status icon +.card-header { + display: flex; + gap: 1rem; + align-items: center; + margin: -0.5rem -0.5rem 0 -0.5rem; // to ignore .card padding + padding: 0 1rem; + + line-height: 1.25; + + background-color: var(--color-bg-darkest); + + // custom styling for the status icon + .-icon { + width: 2.5rem; + height: 2.5rem; + + &.-pending { + background-color: var(--color-bg-light); + + stroke: var(--color-bg-darkest); + } + + &.-running { + background-color: var(--color-yellow); + + stroke: var(--color-bg-darkest); + } + + &.-success { + background-color: var(--color-green); + + stroke: var(--color-bg-darkest); + } + + &.-canceled { + background-color: var(--color-cyan-dark); + + stroke: var(--color-bg-darkest); + } + + &.-failure, + &.-error { + background-color: var(--color-red); + + stroke: var(--color-bg-darkest); + } + } +} + +// truncate repo name to avoid overflow +// max characters for repo is 100 on GitHub +.card-repo.-truncate { + @include truncate(25, false); +} + +.card-build-data { + margin: 0; + padding: 0.5rem; + + columns: 2; + column-gap: 0; + + list-style-type: none; +} + +// styling for the build data list items +.card-build-data li { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0; + + // add vertical padding to list items, + // except the last one because it will throw off spacing + &:not(:last-child) { + margin-bottom: 0.5rem; + } + + // 4th+ list item are right aligned + &:nth-child(n + 4) { + justify-content: flex-end; + } + + // all nodes other than the icon are truncated + :not(svg) { + @include truncate(); + } +} + +// wraps the recent builds component +.dashboard-recent-builds { + margin: 0.5rem; + + // modifier for recent builds if there are no builds yet + &.-none { + display: flex; + align-items: center; + justify-content: center; + height: 5rem; + + background-color: var(--color-bg-darkest); + } + + .build-history { + background-color: var(--color-bg-darkest); + } + + // icons for recent builds are larger + .-icon { + width: 5rem; + height: 5rem; + } + + // overrides for recent builds component due to custom styling + .recent-build:not(:first-child) .-icon { + background-color: var(--color-bg-darkest); // too many levels + border-style: solid; + border-width: 2px 0; + + &.-pending { + border-color: var(--color-bg-light); + + stroke: var(--color-bg-darkest); + } + + &.-running { + border-color: var(--color-yellow); + + stroke: var(--color-yellow); + } + + &.-success { + border-color: var(--color-green); + + stroke: var(--color-green); + } + + &.-canceled { + border-color: var(--color-cyan-dark); + + stroke: var(--color-cyan-dark); + } + + &.-failure, + &.-error { + border-color: var(--color-red); + + stroke: var(--color-red); + } + } + + // zero out margin for first box for + // this implementation of recent build component + .recent-build:first-child .recent-build-link.-current { + margin: 0; + } + + // add a triangle pointer to the most recent build status + .recent-build-link.-current::before { + position: absolute; + top: -0.4rem; + left: 50%; + + display: block; + width: 0.8rem; + + transform: translateX(-50%); + + content: ''; + clip-path: polygon(50% 0, 100% 100%, 0 100%); + aspect-ratio: 1 / 0.5; + } + + // color the pointer based on the respective status + // using lookahead to check for status class; + // browser support: https://caniuse.com/css-has + // unsupported browsers won't show the pointer + .recent-build-link.-current { + &:has(.-success)::before { + background-color: var(--color-green); + } + + &:has(.-pending)::before { + background-color: var(--color-bg-light); + } + + &:has(.-running)::before { + background-color: var(--color-yellow); + } + + &:has(.-canceled)::before { + background-color: var(--color-cyan-dark); + } + + &:has(.-error)::before, + &:has(.-failure)::before { + background-color: var(--color-red); + } + } +} diff --git a/src/scss/_main.scss b/src/scss/_main.scss index 0edc98574..161127bad 100644 --- a/src/scss/_main.scss +++ b/src/scss/_main.scss @@ -30,7 +30,7 @@ a { } } -header { +.main-header { display: flex; align-items: center; justify-content: space-between; @@ -805,6 +805,52 @@ details.build-toggle { font-size: 0.8em; } +.-icon { + fill: none; + stroke: var(--color-primary); + + &.-check { + background-color: var(--color-primary); + + stroke: var(--color-bg); + } + + &.-check, + &.-radio { + fill: none; + } + + &.-success { + stroke: var(--color-green); + } + + &.-running { + stroke: var(--color-yellow); + } + + &.-failure, + &.-error { + stroke: var(--color-red); + } + + &.-canceled { + stroke: var(--color-cyan-dark); + } + + &.-pending { + fill: var(--color-bg-light); + stroke: var(--color-bg-light); + } + + .-inner { + fill: var(--color-primary); + } + + &.-skip { + stroke: var(--color-lavender); + } +} + .recent-build { position: relative; @@ -829,7 +875,7 @@ details.build-toggle { margin: 0 0.2rem 0 0; } -.build-history .recent-build-link .-icon { +.recent-build-link .-icon { fill: none; stroke: var(--color-bg); @@ -1297,52 +1343,6 @@ details.build-toggle { justify-content: space-around; } -.-icon { - fill: none; - stroke: var(--color-primary); - - &.-check { - background-color: var(--color-primary); - - stroke: var(--color-bg); - } - - &.-check, - &.-radio { - fill: none; - } - - &.-success { - stroke: var(--color-green); - } - - &.-running { - stroke: var(--color-yellow); - } - - &.-failure, - &.-error { - stroke: var(--color-red); - } - - &.-canceled { - stroke: var(--color-cyan-dark); - } - - &.-pending { - fill: var(--color-bg-light); - stroke: var(--color-bg-light); - } - - .-inner { - fill: var(--color-primary); - } - - &.-skip { - stroke: var(--color-lavender); - } -} - .pager-actions { display: flex; justify-content: flex-end; @@ -1419,21 +1419,37 @@ details.build-toggle { } .cmd { + position: relative; + display: flex; justify-content: space-between; + + &::before { + position: absolute; + top: 52%; + left: 0.5rem; + + display: block; + + color: var(--color-green); + + transform: translateY(-52%); + + content: '$'; + } } .cmd-text { flex: 1; margin: 0.2rem 0; - padding: 0.5rem; + padding: 0.5rem 0.5rem 0.5rem 1.5rem; color: var(--color-text); background-color: var(--color-bg); border: none; - + .button { + + .vert-icon-container { margin-left: 0.5rem; } } @@ -1741,3 +1757,27 @@ details.build-toggle { .overflow-auto { overflow: auto; } + +code.shell { + position: relative; + + display: inline-block; + margin: 1rem 0; + padding: 0.75rem 1rem 0.75rem 2rem; + + background-color: var(--color-bg-dark); + + &::before { + position: absolute; + top: 50%; + left: 0.8rem; + + display: block; + + color: var(--color-green); + + transform: translateY(-50%); + + content: '$'; + } +} diff --git a/src/scss/_mixins.scss b/src/scss/_mixins.scss index 2f6e72c17..58f83b3df 100644 --- a/src/scss/_mixins.scss +++ b/src/scss/_mixins.scss @@ -36,3 +36,16 @@ } } } + +// truncate text with ellipsis, units in characters +@mixin truncate($chars: 15, $isBlock: true) { + @if $isBlock == false { + display: inline-block; + } + + max-width: $chars * 1ch; + overflow: hidden; + + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/src/scss/_themes.scss b/src/scss/_themes.scss index f3dbd88ac..8a8ba52b3 100644 --- a/src/scss/_themes.scss +++ b/src/scss/_themes.scss @@ -6,6 +6,7 @@ // dark theme (default) body, body.theme-dark { + --color-bg-darkest: var(--color-coal-darkest); --color-bg-dark: var(--color-coal-dark); --color-bg: var(--color-coal); --color-bg-light: var(--color-coal-light); @@ -27,6 +28,7 @@ body.theme-dark { // light theme body.theme-light { + --color-bg-darkest: var(--color-gray-lightest); --color-bg-dark: var(--color-gray-light); --color-bg: var(--color-offwhite); // --color-bg-light: var(--color-white); @@ -38,7 +40,7 @@ body.theme-light { --color-primary: var(--color-lavender); // ok for text --color-primary-light: var(--color-lavender-light); - --color-secondary-dark: var(--color-cyan-dark); + --color-secondary-dark: var(--color-cyan-semi-dark); --color-secondary: var(--color-cyan); --color-secondary-light: var(--color-cyan-light); @@ -63,16 +65,16 @@ body.theme-light { } .status.-canceled { - background-color: var(--color-cyan-semi-dark); + background-color: var(--color-secondary-dark); } .recent-build-link .-icon.-canceled { - background-color: var(--color-cyan-semi-dark); + background-color: var(--color-secondary-dark); } .steps .-icon.-canceled, .services .-icon.-canceled { - stroke: var(--color-cyan-semi-dark); + stroke: var(--color-secondary-dark); } .hooks { @@ -247,4 +249,26 @@ body.theme-light { } } +// dashboards +.theme-light .card-header .-canceled { + background-color: var(--color-cyan-semi-dark); +} + +/* stylelint-disable selector-max-compound-selectors */ +.theme-light + .dashboard-recent-builds + .recent-build:not(:first-child) + .-canceled { + background-color: var(--color-gray-lightest); + border-color: var(--color-secondary-dark); + + stroke: var(--color-secondary-dark); +} + +/* stylelint-enable selector-max-compound-selectors */ + +.theme-light .recent-build-link.-current:has(.-canceled)::before { + background-color: var(--color-secondary-dark); +} + /*! purgecss end ignore */ diff --git a/src/scss/_variables.scss b/src/scss/_variables.scss index 6ae42014d..97c6e9704 100644 --- a/src/scss/_variables.scss +++ b/src/scss/_variables.scss @@ -13,6 +13,7 @@ --color-violet: hsl(288, 55%, 57%); // grays + --color-coal-darkest: hsl(0, 0%, 10%); --color-coal-dark: hsl(0, 0%, 12%); --color-coal: hsl(0, 0%, 16%); // main dark bg --color-coal-light: hsl(0, 0%, 34%); diff --git a/src/scss/style.scss b/src/scss/style.scss index 1ccf0ca30..245da2544 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -23,6 +23,7 @@ @import 'settings'; @import 'pipelines'; @import 'graph'; +@import 'dashboards'; // general @import 'main'; diff --git a/tests/HelpersTest.elm b/tests/HelpersTest.elm new file mode 100644 index 000000000..e31b2d32f --- /dev/null +++ b/tests/HelpersTest.elm @@ -0,0 +1,81 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module HelpersTest exposing (..) + +import Expect +import Test exposing (..) +import Time +import Utils.Helpers + + + +-- FormatRunTime Tests + + +currentTime : Int +currentTime = + 1715840944 + + +currentTimeMillis : Int +currentTimeMillis = + Utils.Helpers.secondsToMillis currentTime + + +testFormatRunTimeFinishedInvalid : Test +testFormatRunTimeFinishedInvalid = + test "formatRunTime: started 1 second ago, finished is invalid (-1)" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) (currentTime - 1) -1 + |> Expect.equal "00:01" + + +testFormatRunTimeFinishedInvalid2 : Test +testFormatRunTimeFinishedInvalid2 = + test "formatRunTime: started 1 second ago, finished is invalid (0)" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) (currentTime - 1) 0 + |> Expect.equal "00:01" + + +testFormatRunTimeStartAndFinishedInvalid : Test +testFormatRunTimeStartAndFinishedInvalid = + test "formatRunTime: started and finished have invalid value (-1)" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) -1 -1 + |> Expect.equal "--:--" + + +testFormatRunTimeStartAndFinishedInvalid2 : Test +testFormatRunTimeStartAndFinishedInvalid2 = + test "formatRunTime: started and finished have invalid value (0)" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) 0 0 + |> Expect.equal "--:--" + + +testFormatRunTimeStartedInvalid : Test +testFormatRunTimeStartedInvalid = + test "formatRunTime: started is invalid (0), finished one second ago" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) 0 (currentTime - 1) + |> Expect.equal "--:--" + + +testFormatRunTimeStartedInvalid2 : Test +testFormatRunTimeStartedInvalid2 = + test "formatRunTime: started is invalid (-1), finished one second ago" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) -1 (currentTime - 1) + |> Expect.equal "--:--" + + +testFormatRunTimeFinishedBeforeStarted : Test +testFormatRunTimeFinishedBeforeStarted = + test "formatRunTime: finished time is before started time" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) (currentTime - 1) (currentTime - 2) + |> Expect.equal "--:--"