From c633f977c5989ef0f4b96884696c782f70f95857 Mon Sep 17 00:00:00 2001 From: david may <1301201+wass3r@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:59:00 +0000 Subject: [PATCH] feat(insights): add insights tab to repo view (#832) Co-authored-by: David May <49894298+wass3rw3rk@users.noreply.github.com> --- cypress/integration/insights.spec.js | 231 +++++++++ elm.json | 19 + src/elm/Api/Endpoint.elm | 6 +- src/elm/Api/Operations.elm | 29 ++ src/elm/Components/BarChart.elm | 253 ++++++++++ src/elm/Components/Tabs.elm | 5 + src/elm/Effect.elm | 24 +- src/elm/Layouts/Default/Build.elm | 4 + src/elm/Layouts/Default/Repo.elm | 1 + src/elm/Main.elm | 72 +++ src/elm/Main/Pages/Model.elm | 2 + src/elm/Main/Pages/Msg.elm | 2 + src/elm/Metrics/BuildMetrics.elm | 505 +++++++++++++++++++ src/elm/Metrics/TimeSeriesMetrics.elm | 105 ++++ src/elm/Pages/Org_/Repo_.elm | 8 + src/elm/Pages/Org_/Repo_/Build_.elm | 1 - src/elm/Pages/Org_/Repo_/Build_/Services.elm | 1 - src/elm/Pages/Org_/Repo_/Insights.elm | 320 ++++++++++++ src/elm/Route/Path.elm | 11 + src/elm/Shared/Msg.elm | 2 +- src/elm/Utils/Helpers.elm | 69 +++ src/scss/_chart.scss | 55 ++ src/scss/style.scss | 1 + tests/BuildMetricsTest.elm | 210 ++++++++ tests/HelpersTest.elm | 116 +++-- tests/TimeSeriesMetricsTest.elm | 124 +++++ 26 files changed, 2114 insertions(+), 62 deletions(-) create mode 100644 cypress/integration/insights.spec.js create mode 100644 src/elm/Components/BarChart.elm create mode 100644 src/elm/Metrics/BuildMetrics.elm create mode 100644 src/elm/Metrics/TimeSeriesMetrics.elm create mode 100644 src/elm/Pages/Org_/Repo_/Insights.elm create mode 100644 src/scss/_chart.scss create mode 100644 tests/BuildMetricsTest.elm create mode 100644 tests/TimeSeriesMetricsTest.elm diff --git a/cypress/integration/insights.spec.js b/cypress/integration/insights.spec.js new file mode 100644 index 000000000..e3d30f465 --- /dev/null +++ b/cypress/integration/insights.spec.js @@ -0,0 +1,231 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +const dayInSeconds = 24 * 60 * 60; + +/** + * Creates a build object with the provided properties. + * @param {Object} props - The properties to include in the build object. + * @param {number} props.enqueued - The timestamp when the build was enqueued. + * @param {number} props.created - The timestamp when the build was created. + * @param {number} props.started - The timestamp when the build was started. + * @param {number} props.finished - The timestamp when the build was finished. + * @param {string} props.status - The status of the build, defaulting to "success". + * @param {number} [props.number=1] - The build number, defaulting to 1. + * @returns {Object} The created build object. + */ +function createBuild({ + enqueued, + created, + started, + finished, + status = 'success', + number = 1, +}) { + return { + id: number, + repo_id: 1, + number: number, + parent: 1, + event: 'push', + status: status, + error: '', + enqueued: enqueued, + created: created, + started: started, + finished: finished, + deploy: '', + link: `/github/octocat/${number}`, + clone: 'https://github.com/github/octocat.git', + source: + 'https://github.com/github/octocat/commit/9b1d8bded6e992ab660eaee527c5e3232d0a2441', + title: 'push received from https://github.com/github/octocat', + message: 'fixing docker params', + commit: '9b1d8bded6e992ab660eaee527c5e3232d0a2441', + sender: 'CookieCat', + author: 'CookieCat', + branch: 'infra', + ref: 'refs/heads/infra', + base_ref: '', + host: '', + runtime: 'docker', + distribution: 'linux', + }; +} + +/** + * Returns the current Unix timestamp with an optional offset in seconds. + * @param {number} [offsetSeconds=0] - The number of seconds to offset the timestamp by. + * @returns {number} The current Unix timestamp plus the optional offset. + */ +function getUnixTime(offsetSeconds = 0) { + return Math.floor(Date.now() / 1000) + offsetSeconds; +} + +context('insights', () => { + context('no builds', () => { + beforeEach(() => { + cy.server(); + cy.route({ + method: 'GET', + url: '*api/v1/repos/*/*/builds*', + response: [], + }); + cy.login('/github/octocat/insights'); + }); + + it('should show no builds message', () => { + cy.get('[data-test=no-builds]').should('be.visible'); + }); + }); + + context('varying builds', () => { + beforeEach(() => { + let builds = []; + + builds.push( + createBuild({ + enqueued: getUnixTime(-3 * dayInSeconds), + created: getUnixTime(-3 * dayInSeconds), + started: getUnixTime(-3 * dayInSeconds), + finished: getUnixTime(-3 * dayInSeconds + 30 * 60), + status: 'success', + number: 1, + }), + ); + + builds.push( + createBuild({ + enqueued: getUnixTime(-2 * dayInSeconds), + created: getUnixTime(-2 * dayInSeconds), + started: getUnixTime(-2 * dayInSeconds), + finished: getUnixTime(-2 * dayInSeconds + 30 * 60), + status: 'failure', + number: 2, + }), + ); + + builds.push( + createBuild({ + enqueued: getUnixTime(-2 * dayInSeconds + 600), + created: getUnixTime(-2 * dayInSeconds + 600), + started: getUnixTime(-2 * dayInSeconds + 600), + finished: getUnixTime(-2 * dayInSeconds + 600 + 15 * 60), + status: 'success', + number: 3, + }), + ); + + builds.push( + createBuild({ + enqueued: getUnixTime(-dayInSeconds), + created: getUnixTime(-dayInSeconds), + started: getUnixTime(-dayInSeconds), + finished: getUnixTime(-dayInSeconds + 45 * 60), + status: 'success', + number: 4, + }), + ); + + cy.server(); + cy.route({ + method: 'GET', + url: '*api/v1/repos/*/*/builds*', + response: builds, + }); + cy.login('/github/octocat/insights'); + }); + + it('daily average should be 2', () => { + cy.get( + '[data-test=metrics-quicklist-activity] > :nth-child(1) > .metric-value', + ).should('have.text', '2'); + }); + + it('average build time should be 30m 0s', () => { + cy.get( + '[data-test=metrics-quicklist-duration] > :nth-child(1) > .metric-value', + ).should('have.text', '30m 0s'); + }); + + it('reliability should be 75% success', () => { + cy.get( + '[data-test=metrics-quicklist-reliability] > :nth-child(1) > .metric-value', + ).should('have.text', '75.0%'); + }); + + it('time to recover should be 10 minutes', () => { + cy.get( + '[data-test=metrics-quicklist-reliability] > :nth-child(3) > .metric-value', + ).should('have.text', '10m 0s'); + }); + + it('average queue time should be 0 seconds', () => { + cy.get( + '[data-test=metrics-quicklist-queue] > :nth-child(1) > .metric-value', + ).should('have.text', '0s'); + }); + }); + + context('one identical build a day', () => { + beforeEach(() => { + const epochTime = getUnixTime(-6 * dayInSeconds); + + const builds = Array.from({ length: 7 }, (_, index) => { + const created = epochTime + index * dayInSeconds; + const enqueued = created + 10; + const started = enqueued + 10; + const finished = started + 30; + + return createBuild({ + enqueued, + created, + started, + finished, + number: index + 1, + }); + }); + + cy.server(); + cy.route({ + method: 'GET', + url: '*api/v1/repos/*/*/builds*', + response: builds, + }); + cy.login('/github/octocat/insights'); + }); + + it('should show 4 metric quicklists', () => { + cy.get('[data-test^=metrics-quicklist-]').should('have.length', 4); + }); + + it('should show 4 charts', () => { + cy.get('[data-test=metrics-chart]').should('have.length', 4); + }); + + it('daily average should be 1', () => { + cy.get( + '[data-test=metrics-quicklist-activity] > :nth-child(1) > .metric-value', + ).should('have.text', '1'); + }); + + it('average build time should be 30 seconds', () => { + cy.get( + '[data-test=metrics-quicklist-duration] > :nth-child(1) > .metric-value', + ).should('have.text', '30s'); + }); + + it('reliability should be 100% success', () => { + cy.get( + '[data-test=metrics-quicklist-reliability] > :nth-child(1) > .metric-value', + ).should('have.text', '100.0%'); + }); + + it('average queue time should be 10 seconds', () => { + cy.get( + '[data-test=metrics-quicklist-queue] > :nth-child(1) > .metric-value', + ).should('have.text', '10s'); + }); + }); +}); diff --git a/elm.json b/elm.json index 401a1c798..d10d47ddf 100644 --- a/elm.json +++ b/elm.json @@ -19,8 +19,10 @@ "elm/url": "1.0.0", "elm-community/graph": "6.0.0", "elm-community/json-extra": "4.3.0", + "elm-community/typed-svg": "7.0.0", "elmcraft/core-extra": "2.0.0", "feathericons/elm-feather": "1.5.0", + "gampleman/elm-visualization": "2.4.2", "jackfranklin/elm-parse-link-header": "2.0.2", "jzxhuang/http-extras": "2.1.0", "krisajenkins/remotedata": "6.0.1", @@ -29,13 +31,30 @@ "vito/elm-ansi": "9.0.2" }, "indirect": { + "avh4/elm-color": "1.0.0", "avh4/elm-fifo": "1.0.4", "elm/parser": "1.1.0", "elm/random": "1.0.0", "elm/regex": "1.0.0", "elm/virtual-dom": "1.0.3", "elm-community/intdict": "3.1.0", + "elm-community/list-extra": "8.7.0", + "folkertdev/elm-deque": "3.0.1", + "folkertdev/one-true-path-experiment": "6.0.1", + "folkertdev/svg-path-lowlevel": "4.0.1", + "gampleman/elm-rosetree": "1.1.0", + "ianmackenzie/elm-1d-parameter": "1.0.1", + "ianmackenzie/elm-float-extra": "1.1.0", + "ianmackenzie/elm-geometry": "3.11.0", + "ianmackenzie/elm-interval": "3.1.0", + "ianmackenzie/elm-triangular-mesh": "1.1.0", + "ianmackenzie/elm-units": "2.10.0", + "ianmackenzie/elm-units-interval": "3.2.0", + "ianmackenzie/elm-units-prefixed": "2.8.0", + "justinmimbs/date": "4.1.0", + "justinmimbs/time-extra": "1.2.0", "myrho/elm-round": "1.0.5", + "rtfeldman/elm-hex": "1.0.0", "rtfeldman/elm-iso8601-date-strings": "1.1.4" } }, diff --git a/src/elm/Api/Endpoint.elm b/src/elm/Api/Endpoint.elm index 581a71c81..c2dbe343d 100644 --- a/src/elm/Api/Endpoint.elm +++ b/src/elm/Api/Endpoint.elm @@ -39,7 +39,7 @@ type Endpoint | Hooks (Maybe Pagination.Page) (Maybe Pagination.PerPage) Vela.Org Vela.Repo | Hook Vela.Org Vela.Repo Vela.HookNumber | OrgBuilds (Maybe Pagination.Page) (Maybe Pagination.PerPage) (Maybe Vela.Event) Vela.Org - | Builds (Maybe Pagination.Page) (Maybe Pagination.PerPage) (Maybe Vela.Event) Vela.Org Vela.Repo + | Builds (Maybe Pagination.Page) (Maybe Pagination.PerPage) (Maybe Vela.Event) (Maybe Int) Vela.Org Vela.Repo | Build Vela.Org Vela.Repo Vela.BuildNumber | CancelBuild Vela.Org Vela.Repo Vela.BuildNumber | ApproveBuild Vela.Org Vela.Repo Vela.BuildNumber @@ -106,8 +106,8 @@ toUrl api endpoint = OrgBuilds maybePage maybePerPage maybeEvent org -> url api [ "repos", org, "builds" ] <| Pagination.toQueryParams maybePage maybePerPage ++ [ UB.string "event" <| Maybe.withDefault "" maybeEvent ] - Builds maybePage maybePerPage maybeEvent org repo -> - url api [ "repos", org, repo, "builds" ] <| Pagination.toQueryParams maybePage maybePerPage ++ [ UB.string "event" <| Maybe.withDefault "" maybeEvent ] + Builds maybePage maybePerPage maybeEvent maybeAfter org repo -> + url api [ "repos", org, repo, "builds" ] <| Pagination.toQueryParams maybePage maybePerPage ++ [ UB.string "event" <| Maybe.withDefault "" maybeEvent, UB.int "after" <| Maybe.withDefault 0 maybeAfter ] Build org repo build -> url api [ "repos", org, repo, "builds", build ] [] diff --git a/src/elm/Api/Operations.elm b/src/elm/Api/Operations.elm index a807775c6..31c950f39 100644 --- a/src/elm/Api/Operations.elm +++ b/src/elm/Api/Operations.elm @@ -22,6 +22,7 @@ module Api.Operations exposing , finishAuthentication , getAllBuildServices , getAllBuildSteps + , getAllBuilds , getBuild , getBuildGraph , getBuildServiceLog @@ -279,6 +280,7 @@ getRepoBuilds : , pageNumber : Maybe Int , perPage : Maybe Int , maybeEvent : Maybe String + , maybeAfter : Maybe Int } -> Request (List Vela.Build) getRepoBuilds baseUrl session options = @@ -287,6 +289,7 @@ getRepoBuilds baseUrl session options = options.pageNumber options.perPage options.maybeEvent + options.maybeAfter options.org options.repo ) @@ -684,6 +687,32 @@ getBuildSteps baseUrl session options = |> withAuth session +{-| getAllBuilds : retrieves all builds. +-} +getAllBuilds : + String + -> Session + -> + { a + | org : String + , repo : String + , after : Int + } + -> Request Vela.Build +getAllBuilds baseUrl session options = + get baseUrl + (Api.Endpoint.Builds + (Just 1) + (Just 100) + Nothing + (Just options.after) + options.org + options.repo + ) + Vela.decodeBuild + |> withAuth session + + {-| getAllBuildSteps : retrieves all steps for a build. -} getAllBuildSteps : diff --git a/src/elm/Components/BarChart.elm b/src/elm/Components/BarChart.elm new file mode 100644 index 000000000..45f76044b --- /dev/null +++ b/src/elm/Components/BarChart.elm @@ -0,0 +1,253 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Components.BarChart exposing + ( newBarChartConfig + , view + , withData + , withHeight + , withMaxY + , withNumberUnit + , withPadding + , withPercentUnit + , withTitle + , withWidth + ) + +import Axis +import DateFormat +import Float.Extra +import Html exposing (Html, div, text) +import Html.Attributes exposing (class) +import Scale exposing (defaultBandConfig) +import Time +import TypedSvg +import TypedSvg.Attributes +import TypedSvg.Attributes.InPx +import TypedSvg.Core exposing (Svg) +import TypedSvg.Types exposing (AnchorAlignment(..), Transform(..)) +import Utils.Helpers as Util + + + +-- TYPES + + +{-| UnitFormat defines what format the values in the chart should be displayed in. +-} +type UnitFormat + = Unit + { suffix : String + , decimals : Int + , formatter : Float -> String + } + + +{-| percent: a unit format that displays values as percentages. note: 1 decimal place is displayed. +-} +percent : UnitFormat +percent = + Unit + { suffix = "%" + , decimals = 1 + , formatter = \value -> Float.Extra.toFixedDecimalPlaces 1 value + } + + +{-| minutes: a unit format that displays values as hours/minutes/seconds. +-} +minutes : UnitFormat +minutes = + Unit + { suffix = "" + , decimals = 0 + , formatter = \value -> Util.formatTimeFromFloat (value * 60) + } + + +{-| number: a unit format that displays values as numbers with the specified number of decimals. +-} +number : Int -> UnitFormat +number decimals = + Unit + { suffix = "" + , decimals = decimals + , formatter = \value -> Float.Extra.toFixedDecimalPlaces decimals value + } + + +{-| BarChartConfig is an opaque type that configures a chart. +-} +type BarChartConfig + = BarChartConfig + { title : String + , width : Float + , height : Float + , padding : Float + , data : List ( Time.Posix, Float ) + , maybeMaxY : Maybe Float + , unit : UnitFormat + } + + +{-| newBarChartConfig : creates a new BarChart configuration with default values. +-} +newBarChartConfig : BarChartConfig +newBarChartConfig = + BarChartConfig + { title = "BarChart" + , width = 900 + , height = 400 + , padding = 30 + , data = [] + , maybeMaxY = Nothing + , unit = minutes + } + + +{-| withWidth : override the width of the chart (in pixels). +-} +withWidth : Float -> BarChartConfig -> BarChartConfig +withWidth v (BarChartConfig config) = + BarChartConfig { config | width = v } + + +{-| withHeight : override the height of the chart (in pixels). +-} +withHeight : Float -> BarChartConfig -> BarChartConfig +withHeight v (BarChartConfig config) = + BarChartConfig { config | height = v } + + +{-| withPadding : override the padding of the chart (in pixels). +-} +withPadding : Float -> BarChartConfig -> BarChartConfig +withPadding v (BarChartConfig config) = + BarChartConfig { config | padding = v } + + +{-| withTitle : override the title of the chart. +-} +withTitle : String -> BarChartConfig -> BarChartConfig +withTitle v (BarChartConfig config) = + BarChartConfig { config | title = v } + + +{-| withData : set the data for the chart. +-} +withData : List ( Time.Posix, Float ) -> BarChartConfig -> BarChartConfig +withData v (BarChartConfig config) = + BarChartConfig { config | data = v } + + +{-| withMaxY : override the max y-axis value (default value is inferred based on dataset). +-} +withMaxY : Float -> BarChartConfig -> BarChartConfig +withMaxY v (BarChartConfig config) = + BarChartConfig { config | maybeMaxY = Just v } + + +{-| withPercentUnit : override unit for the values in the dataset to be percentages (default is time values). +-} +withPercentUnit : BarChartConfig -> BarChartConfig +withPercentUnit (BarChartConfig config) = + BarChartConfig { config | unit = percent } + + +{-| withNumberUnit : override unit for the values in the dataset to be plain number format +with the given decimal places. +-} +withNumberUnit : Int -> BarChartConfig -> BarChartConfig +withNumberUnit v (BarChartConfig config) = + BarChartConfig { config | unit = number v } + + + +-- VIEW + + +{-| view : takes title, width (optional), height (optional), data, optional maximum y-axis value, +unit as string, and returns a chart. +-} +view : BarChartConfig -> Html msg +view (BarChartConfig { title, width, height, padding, data, maybeMaxY, unit }) = + let + maxY = + case maybeMaxY of + Just max -> + max + + Nothing -> + List.maximum (List.map Tuple.second data) + |> Maybe.withDefault 0 + + xScale : List ( Time.Posix, Float ) -> Scale.BandScale Time.Posix + xScale m = + List.map Tuple.first m + |> Scale.band { defaultBandConfig | paddingInner = 0.1, paddingOuter = 0.1 } ( 0, width - 2 * padding ) + + yScale : Scale.ContinuousScale Float + yScale = + Scale.linear ( height - 2 * padding, 0 ) ( 0, maxY ) + + dateFormat : Time.Posix -> String + dateFormat = + DateFormat.format [ DateFormat.dayOfMonthFixed, DateFormat.text " ", DateFormat.monthNameAbbreviated ] Time.utc + + xAxis : List ( Time.Posix, Float ) -> Svg msg + xAxis m = + Axis.bottom [] (Scale.toRenderable dateFormat (xScale m)) + + yAxis : Svg msg + yAxis = + Axis.left [ Axis.tickCount 5 ] yScale + + column : Scale.BandScale Time.Posix -> ( Time.Posix, Float ) -> Svg msg + column scale ( date, value ) = + let + stringValue = + case unit of + Unit { formatter, suffix } -> + formatter value ++ suffix + in + TypedSvg.g [ TypedSvg.Attributes.class [ "column" ] ] + [ TypedSvg.rect + [ TypedSvg.Attributes.InPx.x <| Scale.convert scale date + , TypedSvg.Attributes.InPx.y <| Scale.convert yScale value + , TypedSvg.Attributes.InPx.width <| Scale.bandwidth scale + , TypedSvg.Attributes.InPx.height <| height - Scale.convert yScale value - 2 * padding + ] + [] + , TypedSvg.text_ + [ TypedSvg.Attributes.InPx.x <| Scale.convert (Scale.toRenderable dateFormat scale) date + , TypedSvg.Attributes.InPx.y <| Scale.convert yScale value - 5 + , TypedSvg.Attributes.textAnchor AnchorMiddle + ] + [ text <| stringValue ] + ] + in + div [ class "metrics-chart", Util.testAttribute "metrics-chart" ] + [ div [ class "chart-header" ] [ text title ] + , TypedSvg.svg [ TypedSvg.Attributes.viewBox 0 0 width height ] + [ TypedSvg.g + [ TypedSvg.Attributes.transform [ Translate (padding - 1) (height - padding) ] + , TypedSvg.Attributes.InPx.strokeWidth 2 + , TypedSvg.Attributes.class [ "axis" ] + ] + [ xAxis data ] + , TypedSvg.g + [ TypedSvg.Attributes.transform [ Translate (padding - 1) padding ] + , TypedSvg.Attributes.InPx.strokeWidth 2 + , TypedSvg.Attributes.class [ "axis" ] + ] + [ yAxis ] + , TypedSvg.g + [ TypedSvg.Attributes.transform [ Translate padding padding ] + , TypedSvg.Attributes.class [ "series" ] + ] + <| + List.map (column (xScale data)) data + ] + ] diff --git a/src/elm/Components/Tabs.elm b/src/elm/Components/Tabs.elm index d5ba6b65b..4fdeacb83 100644 --- a/src/elm/Components/Tabs.elm +++ b/src/elm/Components/Tabs.elm @@ -231,6 +231,11 @@ viewRepoTabs shared props = , isAlerting = False , show = showSchedules } + , { name = "Insights" + , toPath = Route.Path.Org__Repo__Insights { org = props.org, repo = props.repo } + , isAlerting = False + , show = True + } , { name = "Audit" , toPath = Route.Path.Org__Repo__Hooks { org = props.org, repo = props.repo } , isAlerting = auditAlerting diff --git a/src/elm/Effect.elm b/src/elm/Effect.elm index 0e1678869..af7bc7817 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, 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 + , 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, getAllBuilds, 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 ) {-| @@ -437,6 +437,7 @@ getRepoBuildsShared : { pageNumber : Maybe Int , perPage : Maybe Int , maybeEvent : Maybe String + , maybeAfter : Maybe Int , org : String , repo : String } @@ -796,6 +797,26 @@ getBuildSteps options = |> sendCmd +getAllBuilds : + { baseUrl : String + , session : Auth.Session.Session + , onResponse : Result (Http.Detailed.Error String) ( Http.Metadata, List Vela.Build ) -> msg + , org : String + , repo : String + , after : Int + } + -> Effect msg +getAllBuilds options = + Api.tryAll + options.onResponse + (Api.Operations.getAllBuilds + options.baseUrl + options.session + options + ) + |> sendCmd + + getAllBuildSteps : { baseUrl : String , session : Auth.Session.Session @@ -1303,6 +1324,7 @@ getRepoBuilds : , pageNumber : Maybe Int , perPage : Maybe Int , maybeEvent : Maybe String + , maybeAfter : Maybe Int , org : String , repo : String } diff --git a/src/elm/Layouts/Default/Build.elm b/src/elm/Layouts/Default/Build.elm index 9c1aef9bd..98879c7bd 100644 --- a/src/elm/Layouts/Default/Build.elm +++ b/src/elm/Layouts/Default/Build.elm @@ -105,6 +105,7 @@ init props shared route _ = { pageNumber = Nothing , perPage = Nothing , maybeEvent = Nothing + , maybeAfter = Nothing , org = props.org , repo = props.repo } @@ -157,6 +158,7 @@ update props shared route msg model = { pageNumber = Nothing , perPage = Nothing , maybeEvent = Nothing + , maybeAfter = Nothing , org = props.org , repo = props.repo } @@ -225,6 +227,7 @@ update props shared route msg model = { pageNumber = Nothing , perPage = Nothing , maybeEvent = Nothing + , maybeAfter = Nothing , org = props.org , repo = props.repo } @@ -342,6 +345,7 @@ update props shared route msg model = { pageNumber = Nothing , perPage = Nothing , maybeEvent = Nothing + , maybeAfter = Nothing , org = props.org , repo = props.repo } diff --git a/src/elm/Layouts/Default/Repo.elm b/src/elm/Layouts/Default/Repo.elm index 3727ff4ad..423dcfcb7 100644 --- a/src/elm/Layouts/Default/Repo.elm +++ b/src/elm/Layouts/Default/Repo.elm @@ -93,6 +93,7 @@ init props shared route _ = { pageNumber = Nothing , perPage = Nothing , maybeEvent = Nothing + , maybeAfter = Nothing , org = props.org , repo = props.repo } diff --git a/src/elm/Main.elm b/src/elm/Main.elm index d99ca23a9..df8866c0c 100644 --- a/src/elm/Main.elm +++ b/src/elm/Main.elm @@ -57,6 +57,7 @@ import Pages.Org_.Repo_.Build_.Services import Pages.Org_.Repo_.Deployments import Pages.Org_.Repo_.Deployments.Add import Pages.Org_.Repo_.Hooks +import Pages.Org_.Repo_.Insights import Pages.Org_.Repo_.Pulls import Pages.Org_.Repo_.Schedules import Pages.Org_.Repo_.Schedules.Add @@ -1136,6 +1137,30 @@ initPageAndLayout model = } ) + Route.Path.Org__Repo__Insights params -> + runWhenAuthenticatedWithLayout + model + (\user -> + let + page : Page.Page Pages.Org_.Repo_.Insights.Model Pages.Org_.Repo_.Insights.Msg + page = + Pages.Org_.Repo_.Insights.page user model.shared (Route.fromUrl params model.url) + + ( pageModel, pageEffect ) = + Page.init page () + in + { page = + Tuple.mapBoth + (Main.Pages.Model.Org__Repo__Insights params) + (Effect.map Main.Pages.Msg.Org__Repo__Insights >> fromPageEffect model) + ( pageModel, pageEffect ) + , layout = + Page.layout pageModel page + |> Maybe.map (Layouts.map (Main.Pages.Msg.Org__Repo__Insights >> Page)) + |> Maybe.map (initLayout model) + } + ) + Route.Path.Org__Repo__Pulls params -> let page : Page.Page Pages.Org_.Repo_.Pulls.Model Pages.Org_.Repo_.Pulls.Msg @@ -1850,6 +1875,16 @@ updateFromPage msg model = (Page.update (Pages.Org_.Repo_.Hooks.page user model.shared (Route.fromUrl params model.url)) pageMsg pageModel) ) + ( Main.Pages.Msg.Org__Repo__Insights pageMsg, Main.Pages.Model.Org__Repo__Insights params pageModel ) -> + runWhenAuthenticated + model + (\user -> + Tuple.mapBoth + (Main.Pages.Model.Org__Repo__Insights params) + (Effect.map Main.Pages.Msg.Org__Repo__Insights >> fromPageEffect model) + (Page.update (Pages.Org_.Repo_.Insights.page user model.shared (Route.fromUrl params model.url)) pageMsg pageModel) + ) + ( Main.Pages.Msg.Org__Repo__Pulls pageMsg, Main.Pages.Model.Org__Repo__Pulls params pageModel ) -> Tuple.mapBoth (Main.Pages.Model.Org__Repo__Pulls params) @@ -2195,6 +2230,12 @@ toLayoutFromPage model = |> Maybe.andThen (Page.layout pageModel) |> Maybe.map (Layouts.map (Main.Pages.Msg.Org__Repo__Hooks >> Page)) + Main.Pages.Model.Org__Repo__Insights params pageModel -> + Route.fromUrl params model.url + |> toAuthProtectedPage model Pages.Org_.Repo_.Insights.page + |> Maybe.andThen (Page.layout pageModel) + |> Maybe.map (Layouts.map (Main.Pages.Msg.Org__Repo__Insights >> Page)) + Main.Pages.Model.Org__Repo__Pulls params pageModel -> Route.fromUrl params model.url |> Pages.Org_.Repo_.Pulls.page model.shared @@ -2519,6 +2560,15 @@ subscriptions model = ) (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Org__Repo__Insights params pageModel -> + Auth.Action.subscriptions + (\user -> + Page.subscriptions (Pages.Org_.Repo_.Insights.page user model.shared (Route.fromUrl params model.url)) pageModel + |> Sub.map Main.Pages.Msg.Org__Repo__Insights + |> Sub.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Org__Repo__Pulls params pageModel -> Page.subscriptions (Pages.Org_.Repo_.Pulls.page model.shared (Route.fromUrl params model.url)) pageModel |> Sub.map Main.Pages.Msg.Org__Repo__Pulls @@ -3029,6 +3079,15 @@ viewPage model = ) (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Org__Repo__Insights params pageModel -> + Auth.Action.view (View.map never (Auth.viewCustomPage model.shared (Route.fromUrl () model.url))) + (\user -> + Page.view (Pages.Org_.Repo_.Insights.page user model.shared (Route.fromUrl params model.url)) pageModel + |> View.map Main.Pages.Msg.Org__Repo__Insights + |> View.map Page + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Org__Repo__Pulls params pageModel -> Page.view (Pages.Org_.Repo_.Pulls.page model.shared (Route.fromUrl params model.url)) pageModel |> View.map Main.Pages.Msg.Org__Repo__Pulls @@ -3418,6 +3477,16 @@ toPageUrlHookCmd model routes = ) (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Org__Repo__Insights params pageModel -> + Auth.Action.command + (\user -> + Page.toUrlMessages routes (Pages.Org_.Repo_.Insights.page user model.shared (Route.fromUrl params model.url)) + |> List.map Main.Pages.Msg.Org__Repo__Insights + |> List.map Page + |> toCommands + ) + (Auth.onPageLoad model.shared (Route.fromUrl () model.url)) + Main.Pages.Model.Org__Repo__Pulls params pageModel -> Page.toUrlMessages routes (Pages.Org_.Repo_.Pulls.page model.shared (Route.fromUrl params model.url)) |> List.map Main.Pages.Msg.Org__Repo__Pulls @@ -3742,6 +3811,9 @@ isAuthProtected routePath = Route.Path.Org__Repo__Hooks _ -> True + Route.Path.Org__Repo__Insights _ -> + True + Route.Path.Org__Repo__Pulls _ -> False diff --git a/src/elm/Main/Pages/Model.elm b/src/elm/Main/Pages/Model.elm index 48ef00adf..29ee85ba2 100644 --- a/src/elm/Main/Pages/Model.elm +++ b/src/elm/Main/Pages/Model.elm @@ -34,6 +34,7 @@ import Pages.Org_.Repo_.Build_.Services import Pages.Org_.Repo_.Deployments import Pages.Org_.Repo_.Deployments.Add import Pages.Org_.Repo_.Hooks +import Pages.Org_.Repo_.Insights import Pages.Org_.Repo_.Pulls import Pages.Org_.Repo_.Schedules import Pages.Org_.Repo_.Schedules.Add @@ -70,6 +71,7 @@ type Model | Org__Repo__Deployments { org : String, repo : String } Pages.Org_.Repo_.Deployments.Model | Org__Repo__Deployments_Add { org : String, repo : String } Pages.Org_.Repo_.Deployments.Add.Model | Org__Repo__Hooks { org : String, repo : String } Pages.Org_.Repo_.Hooks.Model + | Org__Repo__Insights { org : String, repo : String } Pages.Org_.Repo_.Insights.Model | Org__Repo__Pulls { org : String, repo : String } Pages.Org_.Repo_.Pulls.Model | Org__Repo__Schedules { org : String, repo : String } Pages.Org_.Repo_.Schedules.Model | Org__Repo__Schedules_Add { org : String, repo : String } Pages.Org_.Repo_.Schedules.Add.Model diff --git a/src/elm/Main/Pages/Msg.elm b/src/elm/Main/Pages/Msg.elm index 5e123569c..905848792 100644 --- a/src/elm/Main/Pages/Msg.elm +++ b/src/elm/Main/Pages/Msg.elm @@ -34,6 +34,7 @@ import Pages.Org_.Repo_.Build_.Services import Pages.Org_.Repo_.Deployments import Pages.Org_.Repo_.Deployments.Add import Pages.Org_.Repo_.Hooks +import Pages.Org_.Repo_.Insights import Pages.Org_.Repo_.Pulls import Pages.Org_.Repo_.Schedules import Pages.Org_.Repo_.Schedules.Add @@ -69,6 +70,7 @@ type Msg | Org__Repo__Deployments Pages.Org_.Repo_.Deployments.Msg | Org__Repo__Deployments_Add Pages.Org_.Repo_.Deployments.Add.Msg | Org__Repo__Hooks Pages.Org_.Repo_.Hooks.Msg + | Org__Repo__Insights Pages.Org_.Repo_.Insights.Msg | Org__Repo__Pulls Pages.Org_.Repo_.Pulls.Msg | Org__Repo__Schedules Pages.Org_.Repo_.Schedules.Msg | Org__Repo__Schedules_Add Pages.Org_.Repo_.Schedules.Add.Msg diff --git a/src/elm/Metrics/BuildMetrics.elm b/src/elm/Metrics/BuildMetrics.elm new file mode 100644 index 000000000..61af562d8 --- /dev/null +++ b/src/elm/Metrics/BuildMetrics.elm @@ -0,0 +1,505 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Metrics.BuildMetrics exposing + ( Metrics + , calculateAverageRuntime + , calculateAverageTimeToRecovery + , calculateBuildFrequency + , calculateEventBranchMetrics + , calculateFailureRate + , calculateMetrics + , filterCompletedBuilds + ) + +import Dict exposing (Dict) +import Html exposing (b) +import Statistics +import Vela + + +type alias Metrics = + { overall : OverallMetrics + , byStatus : Dict String StatusMetrics + } + + +type alias StatusMetrics = + { averageRuntime : Float + , medianRuntime : Float + , buildFrequency : Int + , eventBranchMetrics : Dict ( String, String ) EventBranchMetrics + } + + +type alias OverallMetrics = + { -- frequency metrics + buildFrequency : Int + , deployFrequency : Int + + -- duration metrics + , averageRuntime : Float + , stdDeviationRuntime : Float + , medianRuntime : Float + , timeUsedOnFailedBuilds : Float + + -- relability + , successRate : Float + , failureRate : Float + , averageTimeToRecovery : Float + + -- queue metrics + , averageQueueTime : Float + , medianQueueTime : Float + + -- aggregrates + , eventBranchMetrics : Dict ( String, String ) EventBranchMetrics + } + + +type alias EventBranchMetrics = + { medianRuntime : Float + , buildTimesOverTime : List TimeSeriesData + } + + +type alias TimeSeriesData = + { timestamp : Int + , value : Float + } + + +{-| calculateMetrics : calculates metrics based on the list of builds passed in. +returns Nothing when the list is empty. +-} +calculateMetrics : List Vela.Build -> Maybe Metrics +calculateMetrics builds = + if List.isEmpty builds then + Nothing + + else + let + completedBuilds = + filterCompletedBuilds builds + + -- frequency + buildFrequency = + calculateBuildFrequency builds + + deployFrequency = + calculateDeployFrequency builds + + -- duration + averageRuntime = + calculateAverageRuntime completedBuilds + + stdDeviationRuntime = + calculateStdDeviationRuntime completedBuilds + + medianRuntime = + calculateMedianRuntime completedBuilds + + timeUsedOnFailedBuilds = + calculateTimeUsedOnFailedBuilds builds + + -- reliability + successRate = + calculateSuccessRate builds + + failureRate = + calculateFailureRate builds + + averageTimeToRecovery = + calculateAverageTimeToRecovery builds + + -- queue metrics + averageQueueTime = + calculateAverageQueueTime builds + + medianQueueTime = + calculateMedianQueueTime builds + + -- aggregrates + eventBranchMetrics = + calculateEventBranchMetrics builds + + byStatus = + calculateMetricsByStatus builds + in + Just + { overall = + { buildFrequency = buildFrequency + , deployFrequency = deployFrequency + , averageRuntime = averageRuntime + , stdDeviationRuntime = stdDeviationRuntime + , medianRuntime = medianRuntime + , timeUsedOnFailedBuilds = timeUsedOnFailedBuilds + , successRate = successRate + , failureRate = failureRate + , averageTimeToRecovery = averageTimeToRecovery + , averageQueueTime = averageQueueTime + , medianQueueTime = medianQueueTime + , eventBranchMetrics = eventBranchMetrics + } + , byStatus = byStatus + } + + +filterCompletedBuilds : List Vela.Build -> List Vela.Build +filterCompletedBuilds builds = + builds + |> List.filter (\build -> build.status /= Vela.Pending) + |> List.filter (\build -> build.status /= Vela.PendingApproval) + |> List.filter (\build -> build.status /= Vela.Running) + + + +-- frequency calculations + + +calculateBuildFrequency : List Vela.Build -> Int +calculateBuildFrequency builds = + let + sortedByCreated = + List.sortBy .created builds + + firstBuildTime = + List.head sortedByCreated |> Maybe.map .created |> Maybe.withDefault 0 + + lastBuildTime = + List.reverse sortedByCreated |> List.head |> Maybe.map .created |> Maybe.withDefault 0 + + totalSeconds = + lastBuildTime - firstBuildTime + + totalDays = + -- if we start a new day, we just count the whole day + max 1 (ceiling (toFloat totalSeconds / (24 * 60 * 60))) + + totalBuilds = + List.length builds + in + if totalDays == 0 then + 0 + + else + totalBuilds // totalDays + + +calculateDeployFrequency : List Vela.Build -> Int +calculateDeployFrequency builds = + let + sortedByCreated = + builds + |> List.filter (\build -> build.event == "deployment") + |> List.sortBy .created + + firstBuildTime = + List.head sortedByCreated |> Maybe.map .created |> Maybe.withDefault 0 + + lastBuildTime = + List.reverse sortedByCreated |> List.head |> Maybe.map .created |> Maybe.withDefault 0 + + totalSeconds = + lastBuildTime - firstBuildTime + + totalDays = + max 1 (totalSeconds // (24 * 60 * 60)) + + totalBuilds = + List.length sortedByCreated + in + if totalDays == 0 then + 0 + + else + totalBuilds // totalDays + + + +-- duration calculations + + +calculateAverageRuntime : List Vela.Build -> Float +calculateAverageRuntime builds = + let + total = + List.foldl (\build acc -> acc + (build.finished - build.started)) 0 builds + + count = + List.length builds + in + if count == 0 then + 0 + + else + toFloat (total // count) + + +calculateStdDeviationRuntime : List Vela.Build -> Float +calculateStdDeviationRuntime builds = + builds + |> List.map (\build -> toFloat (build.finished - build.started)) + |> calculateStdDeviation + + +calculateMedianRuntime : List Vela.Build -> Float +calculateMedianRuntime builds = + builds + |> List.map (\build -> toFloat (build.finished - build.started)) + |> calculateMedian + + +calculateTimeUsedOnFailedBuilds : List Vela.Build -> Float +calculateTimeUsedOnFailedBuilds builds = + builds + |> List.filter (\build -> build.status == Vela.Failure) + |> List.foldl (\build acc -> acc + (build.finished - build.started)) 0 + |> toFloat + + + +-- reliability calculations + + +calculateSuccessRate : List Vela.Build -> Float +calculateSuccessRate builds = + let + total = + builds + |> List.length + |> toFloat + + succeeded = + builds + |> List.filter (\build -> build.status == Vela.Success) + |> List.length + |> toFloat + in + if total == 0 then + 0 + + else + (succeeded / total) * 100 + + +calculateFailureRate : List Vela.Build -> Float +calculateFailureRate builds = + let + totalFailures = + builds + |> List.filter (\build -> build.status == Vela.Failure) + |> List.length + |> toFloat + + count = + builds + |> List.length + |> toFloat + in + if count == 0 then + 0 + + else + (totalFailures / count) * 100 + + +calculateAverageTimeToRecovery : List Vela.Build -> Float +calculateAverageTimeToRecovery builds = + let + failedBuilds = + List.filter (\build -> build.status == Vela.Failure) builds + + successfulBuilds = + List.filter (\build -> build.status == Vela.Success) builds + + -- group builds by branch + groupByBranch b = + List.foldl + (\build acc -> + Dict.update build.branch (Maybe.map (\lst -> Just (build :: lst)) >> Maybe.withDefault (Just [ build ])) acc + ) + Dict.empty + b + + groupedFailedBuilds = + groupByBranch failedBuilds + + groupedSuccessfulBuilds = + groupByBranch successfulBuilds + + -- find pairs of failed and subsequent successful builds within each branch + findRecoveryTimes f s = + case ( f, s ) of + ( [], _ ) -> + [] + + ( _, [] ) -> + [] + + ( failed :: restFailed, success :: restSuccess ) -> + if success.created > failed.created then + (success.created - failed.created) :: findRecoveryTimes restFailed restSuccess + + else + findRecoveryTimes f restSuccess + + -- calculate the time differences for each branch + calculateBranchRecoveryTimes branch = + let + f = + Dict.get branch groupedFailedBuilds |> Maybe.withDefault [] + + s = + Dict.get branch groupedSuccessfulBuilds |> Maybe.withDefault [] + in + findRecoveryTimes (List.sortBy .created f) (List.sortBy .created s) + + -- aggregate recovery times across all branches + allRecoveryTimes = + Dict.keys groupedFailedBuilds + |> List.concatMap calculateBranchRecoveryTimes + + -- compute the average of the time differences + totalRecoveryTime = + toFloat (List.sum allRecoveryTimes) + + count = + toFloat (List.length allRecoveryTimes) + in + if count == 0 then + 0 + + else + totalRecoveryTime / count + + + +-- queue time calculations + + +calculateAverageQueueTime : List Vela.Build -> Float +calculateAverageQueueTime builds = + let + total = + builds + |> List.filter (\build -> build.started > 0) + |> List.foldl (\build acc -> acc + (build.started - build.enqueued)) 0 + + count = + List.length builds + in + if count == 0 then + 0 + + else + toFloat (total // count) + + +calculateMedianQueueTime : List Vela.Build -> Float +calculateMedianQueueTime builds = + builds + |> List.filter (\build -> build.started > 0) + |> List.map (\build -> toFloat (build.started - build.enqueued)) + |> calculateMedian + + +calculateMetricsByStatus : List Vela.Build -> Dict String StatusMetrics +calculateMetricsByStatus builds = + let + -- group builds by status + groupedBuilds = + List.foldl + (\build acc -> + let + key = + Vela.statusToString build.status + in + Dict.update key (Maybe.map (\lst -> Just (build :: lst)) >> Maybe.withDefault (Just [ build ])) acc + ) + Dict.empty + builds + + calculateMetricsForGroup b = + let + buildTimes = + List.map (\build -> toFloat (build.finished - build.started)) b + + medianRuntime = + calculateMedian buildTimes + + averageRuntime = + calculateAverageRuntime b + + buildFrequency = + calculateBuildFrequency b + + eventBranchMetrics = + calculateEventBranchMetrics b + in + { averageRuntime = averageRuntime + , medianRuntime = medianRuntime + , buildFrequency = buildFrequency + , eventBranchMetrics = eventBranchMetrics + } + in + Dict.map (\_ buildList -> calculateMetricsForGroup buildList) groupedBuilds + + +calculateEventBranchMetrics : List Vela.Build -> Dict ( String, String ) EventBranchMetrics +calculateEventBranchMetrics builds = + let + -- group builds by (event, branch) + groupedBuilds = + List.foldl + (\build acc -> + let + key = + ( build.event, build.branch ) + in + Dict.update key (Maybe.map (\lst -> Just (build :: lst)) >> Maybe.withDefault (Just [ build ])) acc + ) + Dict.empty + builds + + -- calculate metrics for each group + calculateMetricsForGroup b = + let + buildTimes = + List.map (\build -> toFloat (build.finished - build.started)) b + + medianRuntime = + calculateMedian buildTimes + + buildTimesOverTime = + List.foldl + (\build acc -> + { timestamp = build.created, value = toFloat (build.finished - build.started) } :: acc + ) + [] + b + in + { medianRuntime = medianRuntime + , buildTimesOverTime = buildTimesOverTime + } + in + Dict.map (\_ buildsList -> calculateMetricsForGroup buildsList) groupedBuilds + + + +-- generic helpers + + +calculateMedian : List Float -> Float +calculateMedian list = + List.sort list + |> Statistics.quantile 0.5 + |> Maybe.withDefault 0 + + +calculateStdDeviation : List Float -> Float +calculateStdDeviation list = + Statistics.deviation list + |> Maybe.withDefault 0 diff --git a/src/elm/Metrics/TimeSeriesMetrics.elm b/src/elm/Metrics/TimeSeriesMetrics.elm new file mode 100644 index 000000000..20a556a38 --- /dev/null +++ b/src/elm/Metrics/TimeSeriesMetrics.elm @@ -0,0 +1,105 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Metrics.TimeSeriesMetrics exposing (calculateAveragePerDay, calculateCountPerDay) + +import Dict +import Time +import Utils.Helpers as Util + + +{-| calculateCountPerDay : creates a list of tuples to show ocurrence count of items per day. it takes current time, number of days, +a function to get the timestamp of an item, and a list of items. +-} +calculateCountPerDay : + Time.Posix + -> Int + -> (a -> Int) + -> List a + -> List ( Time.Posix, Float ) +calculateCountPerDay now daysToShow getTimestamp items = + let + today = + Time.posixToMillis now // Util.oneDayMillis + + emptyDays = + List.range 0 (daysToShow - 1) + |> List.map (\offset -> today - offset) + |> List.map (\day -> ( day, 0 )) + |> Dict.fromList + + filledDays = + items + |> List.map (\item -> getTimestamp item // Util.oneDaySeconds) + |> List.foldl + (\day acc -> + Dict.update day + (Maybe.map (\count -> count + 1)) + acc + ) + emptyDays + in + filledDays + |> Dict.toList + |> List.map + (\( day, count ) -> + ( Time.millisToPosix (day * Util.oneDayMillis), toFloat count ) + ) + |> List.sortBy (Time.posixToMillis << Tuple.first) + + +{-| calculateAveragePerDay : creates a list of tuples to show average of a given value per day. it takes current time, +number of days, a function to get the timestamp of an item, a function to get the value to capture, +a function to transform the value, and a list of items. +-} +calculateAveragePerDay : + Time.Posix + -> Int + -> (a -> Int) + -> (a -> Float) + -> (Float -> Float) + -> List a + -> List ( Time.Posix, Float ) +calculateAveragePerDay now daysToShow getTimestamp getValue transformValue items = + let + today = + Time.posixToMillis now // Util.oneDayMillis + + emptyDays = + List.range 0 (daysToShow - 1) + |> List.map (\offset -> today - offset) + |> List.map (\day -> ( day, [] )) + |> Dict.fromList + + filledDays = + items + |> List.map (\item -> ( getTimestamp item // Util.oneDaySeconds, getValue item )) + |> List.foldl + (\( day, value ) acc -> + let + currentValues = + Dict.get day acc + |> Maybe.withDefault [] + in + Dict.insert day (value :: currentValues) acc + ) + emptyDays + in + filledDays + |> Dict.toList + |> List.map + (\( day, values ) -> + let + average = + case values of + [] -> + 0 + + _ -> + List.sum values / toFloat (List.length values) + in + ( Time.millisToPosix (day * Util.oneDayMillis), transformValue average ) + ) + |> List.sortBy (Time.posixToMillis << Tuple.first) diff --git a/src/elm/Pages/Org_/Repo_.elm b/src/elm/Pages/Org_/Repo_.elm index 135e40b04..3c06e6ec0 100644 --- a/src/elm/Pages/Org_/Repo_.elm +++ b/src/elm/Pages/Org_/Repo_.elm @@ -110,6 +110,7 @@ init shared route () = , pageNumber = Dict.get "page" route.query |> Maybe.andThen String.toInt , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt , maybeEvent = Dict.get "event" route.query + , maybeAfter = Dict.get "after" route.query |> Maybe.andThen String.toInt , org = route.params.org , repo = route.params.repo } @@ -156,6 +157,7 @@ update shared route msg model = , pageNumber = Dict.get "page" route.query |> Maybe.andThen String.toInt , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt , maybeEvent = options.to + , maybeAfter = Dict.get "after" route.query |> Maybe.andThen String.toInt , org = route.params.org , repo = route.params.repo } @@ -197,6 +199,7 @@ update shared route msg model = , pageNumber = Just pageNumber , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt , maybeEvent = Dict.get "event" route.query + , maybeAfter = Dict.get "after" route.query |> Maybe.andThen String.toInt , org = route.params.org , repo = route.params.repo } @@ -238,6 +241,7 @@ update shared route msg model = , pageNumber = Dict.get "page" route.query |> Maybe.andThen String.toInt , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt , maybeEvent = Dict.get "event" route.query + , maybeAfter = Dict.get "after" route.query |> Maybe.andThen String.toInt , org = route.params.org , repo = route.params.repo } @@ -281,6 +285,7 @@ update shared route msg model = , pageNumber = Dict.get "page" route.query |> Maybe.andThen String.toInt , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt , maybeEvent = Dict.get "event" route.query + , maybeAfter = Dict.get "after" route.query |> Maybe.andThen String.toInt , org = route.params.org , repo = route.params.repo } @@ -324,6 +329,7 @@ update shared route msg model = , pageNumber = Dict.get "page" route.query |> Maybe.andThen String.toInt , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt , maybeEvent = Dict.get "event" route.query + , maybeAfter = Dict.get "after" route.query |> Maybe.andThen String.toInt , org = route.params.org , repo = route.params.repo } @@ -392,6 +398,7 @@ update shared route msg model = , pageNumber = Nothing , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt , maybeEvent = maybeEvent + , maybeAfter = Dict.get "after" route.query |> Maybe.andThen String.toInt , org = route.params.org , repo = route.params.repo } @@ -411,6 +418,7 @@ update shared route msg model = , pageNumber = Dict.get "page" route.query |> Maybe.andThen String.toInt , perPage = Dict.get "perPage" route.query |> Maybe.andThen String.toInt , maybeEvent = Dict.get "event" route.query + , maybeAfter = Dict.get "after" route.query |> Maybe.andThen String.toInt , org = route.params.org , repo = route.params.repo } diff --git a/src/elm/Pages/Org_/Repo_/Build_.elm b/src/elm/Pages/Org_/Repo_/Build_.elm index 6546e87c2..e7ce460e5 100644 --- a/src/elm/Pages/Org_/Repo_/Build_.elm +++ b/src/elm/Pages/Org_/Repo_/Build_.elm @@ -10,7 +10,6 @@ import Browser.Dom exposing (focus) import Components.Loading import Components.Logs import Components.Svgs -import Debug exposing (log) import Dict exposing (Dict) import Effect exposing (Effect) import FeatherIcons diff --git a/src/elm/Pages/Org_/Repo_/Build_/Services.elm b/src/elm/Pages/Org_/Repo_/Build_/Services.elm index 2933502d0..443f19405 100644 --- a/src/elm/Pages/Org_/Repo_/Build_/Services.elm +++ b/src/elm/Pages/Org_/Repo_/Build_/Services.elm @@ -10,7 +10,6 @@ import Browser.Dom exposing (focus) import Components.Loading import Components.Logs import Components.Svgs -import Debug exposing (log) import Dict exposing (Dict) import Effect exposing (Effect) import FeatherIcons diff --git a/src/elm/Pages/Org_/Repo_/Insights.elm b/src/elm/Pages/Org_/Repo_/Insights.elm new file mode 100644 index 000000000..af658fa40 --- /dev/null +++ b/src/elm/Pages/Org_/Repo_/Insights.elm @@ -0,0 +1,320 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Pages.Org_.Repo_.Insights exposing (Model, Msg, page) + +import Auth +import Components.BarChart +import Components.Loading exposing (viewSmallLoader) +import Effect exposing (Effect) +import Float.Extra +import Html exposing (Html, div, h1, h2, h3, p, section, strong, text) +import Html.Attributes exposing (class) +import Http +import Http.Detailed +import Layouts +import List +import Metrics.BuildMetrics as BuildMetrics exposing (Metrics) +import Metrics.TimeSeriesMetrics as TimeSeriesMetrics +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 Helpers +import Vela +import View exposing (View) + + +page : Auth.User -> Shared.Model -> Route { org : String, repo : 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, route, model, and passes the insights page info to Layouts. +-} +toLayout : Auth.User -> Route { org : String, repo : String } -> Model -> Layouts.Layout Msg +toLayout user route model = + Layouts.Default_Repo + { navButtons = [] + , utilButtons = [] + , helpCommands = [] + , crumbs = + [ ( "Overview", Just Route.Path.Home_ ) + , ( route.params.org, Just <| Route.Path.Org_ { org = route.params.org } ) + , ( route.params.repo, Nothing ) + ] + , org = route.params.org + , repo = route.params.repo + } + + + +-- INIT + + +{-| Model : alias for a model object for an insights page. +we store the builds and calculated metrics. +-} +type alias Model = + { builds : WebData (List Vela.Build) + , metrics : Maybe Metrics + } + + +{-| init : takes shared model, route, and initializes an insights page input arguments. +-} +init : Shared.Model -> Route { org : String, repo : String } -> () -> ( Model, Effect Msg ) +init shared route () = + let + currentTimeInSeconds = + Time.posixToMillis shared.time // 1000 + + sevenDaysInSeconds = + 7 * Helpers.oneDaySeconds + + timeMinusSevenDaysInSeconds : Int + timeMinusSevenDaysInSeconds = + currentTimeInSeconds - sevenDaysInSeconds + in + ( { builds = RemoteData.Loading + , metrics = Nothing + } + , Effect.getAllBuilds + { baseUrl = shared.velaAPIBaseURL + , session = shared.session + , onResponse = GetRepoBuildsResponse + , after = timeMinusSevenDaysInSeconds + , org = route.params.org + , repo = route.params.repo + } + ) + + + +-- UPDATE + + +{-| Msg : custom type with possible messages. +-} +type Msg + = GetRepoBuildsResponse (Result (Http.Detailed.Error String) ( Http.Metadata, List Vela.Build )) + + +{-| update : takes current models, route, message, and returns an updated model and effect. +-} +update : Shared.Model -> Route { org : String, repo : String } -> Msg -> Model -> ( Model, Effect Msg ) +update shared route msg model = + case msg of + GetRepoBuildsResponse response -> + case response of + Ok ( meta, builds ) -> + let + metrics = + BuildMetrics.calculateMetrics builds + in + ( { model + | builds = RemoteData.succeed builds + , metrics = metrics + } + , Effect.none + ) + + Err error -> + ( { model | builds = Errors.toFailure error } + , Effect.handleHttpError + { error = error + , shouldShowAlertFn = Errors.showAlertAlways + } + ) + + + +-- SUBSCRIPTIONS + + +{-| subscriptions : takes model and returns the subscriptions. +-} +subscriptions : Model -> Sub Msg +subscriptions model = + Sub.none + + + +-- VIEW + + +{-| view : takes models, route, and creates the html for the insights page. +-} +view : Shared.Model -> Route { org : String, repo : String } -> Model -> View Msg +view shared route model = + { title = "Pipeline Insights" + , body = + h2 [] [ text "Pipeline Insights (Last 7 Days)" ] + :: (case model.builds of + RemoteData.Loading -> + [ viewSmallLoader ] + + RemoteData.NotAsked -> + [ viewSmallLoader ] + + RemoteData.Failure _ -> + viewError + + RemoteData.Success builds -> + case builds of + [] -> + viewEmpty + + _ -> + viewInsights model shared.time + ) + } + + +{-| viewInsights : take model and current time and renders metrics. +-} +viewInsights : Model -> Time.Posix -> List (Html Msg) +viewInsights model now = + case ( model.metrics, model.builds ) of + ( Just m, RemoteData.Success builds ) -> + let + chartDataBuildsPerDay = + TimeSeriesMetrics.calculateCountPerDay now 7 .created builds + + chartDataAvgBuildTimePerDay = + TimeSeriesMetrics.calculateAveragePerDay + now + 7 + .created + (\build -> toFloat (build.finished - build.started)) + (\seconds -> seconds / 60) + builds + + chartDataSuccessRatePerDay = + TimeSeriesMetrics.calculateAveragePerDay + now + 7 + .created + (\build -> + if build.status == Vela.Success then + 100 + + else + 0 + ) + identity + builds + + chartDataAvgQueueTimePerDay = + TimeSeriesMetrics.calculateAveragePerDay + now + 7 + .created + (\build -> toFloat (build.started - build.enqueued)) + (\seconds -> seconds / 60) + (List.filter (\build -> build.started > 0) builds) + + barChart = + Components.BarChart.newBarChartConfig + in + [ h3 [] [ text "Build Activity" ] + , section [ class "metrics" ] + [ div [ class "metrics-quicklist", Helpers.testAttribute "metrics-quicklist-activity" ] + [ viewMetric (String.fromInt m.overall.buildFrequency) "average build(s) per day" + , viewMetric (String.fromInt m.overall.deployFrequency) "average deployment(s) per day" + ] + , Components.BarChart.view + (barChart + |> Components.BarChart.withTitle "Builds per day (all branches)" + |> Components.BarChart.withData chartDataBuildsPerDay + |> Components.BarChart.withNumberUnit 0 + ) + ] + , h3 [] [ text "Build Duration" ] + , section [ class "metrics" ] + [ div [ class "metrics-quicklist", Helpers.testAttribute "metrics-quicklist-duration" ] + [ viewMetric (Helpers.formatTimeFromFloat m.overall.averageRuntime) "average" + , viewMetric (Helpers.formatTimeFromFloat m.overall.stdDeviationRuntime) "standard deviation" + , viewMetric (Helpers.formatTimeFromFloat m.overall.medianRuntime) "median" + , viewMetric (Helpers.formatTimeFromFloat m.overall.timeUsedOnFailedBuilds) "time used on failed builds" + ] + , Components.BarChart.view + (barChart + |> Components.BarChart.withTitle "Average build duration per day (in minutes)" + |> Components.BarChart.withData chartDataAvgBuildTimePerDay + ) + ] + , h3 [] [ text "Build Reliability" ] + , section [ class "metrics" ] + [ div [ class "metrics-quicklist", Helpers.testAttribute "metrics-quicklist-reliability" ] + [ viewMetric (Float.Extra.toFixedDecimalPlaces 1 m.overall.successRate ++ "%") "success rate" + , viewMetric (Float.Extra.toFixedDecimalPlaces 1 m.overall.failureRate ++ "%") "failure rate" + , viewMetric (Helpers.formatTimeFromFloat m.overall.averageTimeToRecovery) "average time to recover from failures" + ] + , Components.BarChart.view + (barChart + |> Components.BarChart.withTitle "Average success rate per day" + |> Components.BarChart.withData chartDataSuccessRatePerDay + |> Components.BarChart.withMaxY 100 + |> Components.BarChart.withPercentUnit + ) + ] + , h3 [] [ text "Queue Performance" ] + , section [ class "metrics" ] + [ div [ class "metrics-quicklist", Helpers.testAttribute "metrics-quicklist-queue" ] + [ viewMetric (Helpers.formatTimeFromFloat m.overall.averageQueueTime) "average time in queue" + , viewMetric (Helpers.formatTimeFromFloat m.overall.medianQueueTime) "median time in queue" + ] + , Components.BarChart.view + (barChart + |> Components.BarChart.withTitle "Average time in queue per day (in minutes)" + |> Components.BarChart.withData chartDataAvgQueueTimePerDay + ) + ] + ] + + ( _, _ ) -> + [ h3 [] [ text "No Metrics to Show" ] ] + + +{-| viewMetrics : takes a value and description as strings and renders a quick metric. +-} +viewMetric : String -> String -> Html msg +viewMetric value description = + div [ class "metric" ] + [ strong [ class "metric-value" ] [ text value ] + , p [ class "metric-description" ] [ text description ] + ] + + +{-| viewEmpty : renders information when there are no builds returned. +-} +viewEmpty : List (Html msg) +viewEmpty = + [ h3 [ Helpers.testAttribute "no-builds" ] [ text "No builds found" ] + , p [] [ text "Run some builds and reload the page." ] + ] + + +{-| viewError : renders information when there was an error retrieving the builds. +-} +viewError : List (Html msg) +viewError = + [ h3 [] [ text "There was an error retrieving builds :(" ] + , p [] [ text "Try again in a little bit." ] + ] diff --git a/src/elm/Route/Path.elm b/src/elm/Route/Path.elm index 4a32b6c13..92886b6eb 100644 --- a/src/elm/Route/Path.elm +++ b/src/elm/Route/Path.elm @@ -37,6 +37,7 @@ type Path | Org__Repo__Deployments { org : String, repo : String } | Org__Repo__Deployments_Add { org : String, repo : String } | Org__Repo__Hooks { org : String, repo : String } + | Org__Repo__Insights { org : String, repo : String } | Org__Repo__Pulls { org : String, repo : String } | Org__Repo__Schedules { org : String, repo : String } | Org__Repo__Schedules_Add { org : String, repo : String } @@ -211,6 +212,13 @@ fromString urlPath = } |> Just + org_ :: repo_ :: "insights" :: [] -> + Org__Repo__Insights + { org = org_ + , repo = repo_ + } + |> Just + org_ :: repo_ :: "pulls" :: [] -> Org__Repo__Pulls { org = org_ @@ -376,6 +384,9 @@ toString path = Org__Repo__Hooks params -> [ params.org, params.repo, "hooks" ] + Org__Repo__Insights params -> + [ params.org, params.repo, "insights" ] + Org__Repo__Pulls params -> [ params.org, params.repo, "pulls" ] diff --git a/src/elm/Shared/Msg.elm b/src/elm/Shared/Msg.elm index 1392ba7ce..564ba5225 100644 --- a/src/elm/Shared/Msg.elm +++ b/src/elm/Shared/Msg.elm @@ -54,7 +54,7 @@ type Msg | AddFavorites { favorites : List { org : String, maybeRepo : Maybe String } } | AddFavoritesResponse { favorites : List { org : String, maybeRepo : Maybe String } } (Result (Http.Detailed.Error String) ( Http.Metadata, Vela.User )) -- BUILDS - | GetRepoBuilds { org : String, repo : String, pageNumber : Maybe Int, perPage : Maybe Int, maybeEvent : Maybe String } + | GetRepoBuilds { org : String, repo : String, pageNumber : Maybe Int, perPage : Maybe Int, maybeEvent : Maybe String, maybeAfter : Maybe Int } | GetRepoBuildsResponse (Result (Http.Detailed.Error String) ( Http.Metadata, List Vela.Build )) -- HOOKS | GetRepoHooks { org : String, repo : String, pageNumber : Maybe Int, perPage : Maybe Int, maybeEvent : Maybe String } diff --git a/src/elm/Utils/Helpers.elm b/src/elm/Utils/Helpers.elm index 6b3b8f532..cb5db125a 100644 --- a/src/elm/Utils/Helpers.elm +++ b/src/elm/Utils/Helpers.elm @@ -22,6 +22,7 @@ module Utils.Helpers exposing , formatFilesize , formatRunTime , formatTestTag + , formatTimeFromFloat , getNameFromRef , humanReadableDateTimeFormatter , humanReadableDateTimeWithDefault @@ -32,6 +33,8 @@ module Utils.Helpers exposing , noBlanks , onClickPreventDefault , onMouseDownSubscription + , oneDayMillis + , oneDaySeconds , oneSecondMillis , open , orgRepoFromBuildLink @@ -216,6 +219,58 @@ noSomeSecondsAgo _ = "just now" +{-| formatTimeFromFloat : takes a float (seconds) and passes it to formatTime +for a string representation. the value is floored since we don't measure +at sub-second accuraacy. +-} +formatTimeFromFloat : Float -> String +formatTimeFromFloat number = + number + |> floor + |> formatTime + + +{-| formatTime : takes an int (seconds) and converts it to a string representation. + +example: 4000 -> 1h 6m 40s + +-} +formatTime : Int -> String +formatTime totalSeconds = + let + hours = + totalSeconds // 3600 + + remainingSeconds = + Basics.remainderBy 3600 totalSeconds + + minutes = + remainingSeconds // 60 + + seconds = + Basics.remainderBy 60 remainingSeconds + + hoursString = + if hours > 0 then + String.fromInt hours ++ "h " + + else + "" + + minutesString = + if minutes > 0 then + String.fromInt minutes ++ "m " + + else + "" + in + if totalSeconds < 1 then + "0s" + + else + hoursString ++ minutesString ++ String.fromInt seconds ++ "s" + + {-| formatRunTime : calculates build runtime using current application time and build times. -} formatRunTime : Posix -> Int -> Int -> String @@ -375,6 +430,20 @@ fiveSecondsMillis = oneSecondMillis * 5 +{-| oneDaySeconds : 86400 seconds in a day +-} +oneDaySeconds : Int +oneDaySeconds = + 86400 + + +{-| oneDayMillis : oneDaySeconds in milliseconds +-} +oneDayMillis : Int +oneDayMillis = + secondsToMillis oneDaySeconds + + {-| isLoaded : takes WebData and returns true if it is in a 'loaded' state, meaning its anything but NotAsked or Loading. -} isLoaded : WebData a -> Bool diff --git a/src/scss/_chart.scss b/src/scss/_chart.scss new file mode 100644 index 000000000..4abe0b13d --- /dev/null +++ b/src/scss/_chart.scss @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +.metrics { + max-width: 66%; + padding: 1rem 2rem; + + background-color: var(--color-bg-dark); +} + +.metric { + flex: 1; +} + +.metrics-quicklist { + display: flex; + gap: 2rem; + margin-bottom: 1.5rem; +} + +.metric-value { + font-size: xx-large; +} + +.metric-description { + margin: 0; + + font-size: small; +} + +.metrics svg path, +.metrics svg line { + stroke: var(--color-text); +} + +.metrics svg text { + fill: var(--color-text); +} + +.metrics .column rect { + fill: var(--color-cyan-dark); +} + +.metrics .column text { + display: none; +} + +.metrics .column:hover rect, +.metrics .column:focus rect { + fill: var(--color-cyan); +} + +.metrics .column:hover text, +.metrics .column:focus text { + display: inline; +} diff --git a/src/scss/style.scss b/src/scss/style.scss index 245da2544..757c734d5 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -24,6 +24,7 @@ @import 'pipelines'; @import 'graph'; @import 'dashboards'; +@import 'chart'; // general @import 'main'; diff --git a/tests/BuildMetricsTest.elm b/tests/BuildMetricsTest.elm new file mode 100644 index 000000000..cbccddc1c --- /dev/null +++ b/tests/BuildMetricsTest.elm @@ -0,0 +1,210 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module BuildMetricsTest exposing (suite) + +import Dict +import Expect +import Metrics.BuildMetrics exposing (..) +import Test exposing (..) +import Vela + + + +-- Test Data + + +createSampleBuild : Int -> Int -> Int -> Vela.Status -> String -> String -> Vela.Build +createSampleBuild created duration queueTime buildStatus event branch = + let + now = + created + + enqueuedAt = + now + queueTime + + startedAt = + enqueuedAt + queueTime + + finishedAt = + startedAt + duration + in + { id = 1 + , repository_id = 1 + , number = 1 + , parent = 0 + , event = event + , status = buildStatus + , error = "" + , enqueued = enqueuedAt + , created = now + , started = startedAt + , finished = finishedAt + , deploy = "" + , clone = "" + , source = "" + , title = "Test Build" + , message = "" + , commit = "abc123" + , sender = "test-user" + , author = "test-user" + , branch = branch + , link = "" + , ref = "" + , base_ref = "" + , host = "" + , runtime = "" + , distribution = "" + , approved_at = 0 + , approved_by = "" + , deploy_payload = Nothing + } + + +suite : Test +suite = + describe "BuildMetrics" + [ describe "calculateBuildFrequency" + [ test "calculates daily build frequency" <| + \_ -> + let + builds = + [ createSampleBuild 0 0 10 Vela.Success "push" "main" + , createSampleBuild 86400 0 10 Vela.Success "push" "main" + , createSampleBuild 172800 0 10 Vela.Success "push" "main" + ] + in + calculateBuildFrequency builds + |> Expect.equal 1 + , test "calculates daily build frequency (complex)" <| + \_ -> + let + builds = + [ createSampleBuild 0 0 10 Vela.Success "push" "main" + , createSampleBuild 14 0 10 Vela.Success "push" "main" + , createSampleBuild 86401 0 10 Vela.Success "push" "main" + , createSampleBuild 86403 0 10 Vela.Success "push" "main" + , createSampleBuild 86404 0 10 Vela.Success "push" "main" + , createSampleBuild 86405 0 10 Vela.Success "push" "main" + , createSampleBuild 172800 0 10 Vela.Success "push" "main" + , createSampleBuild 172801 0 10 Vela.Success "push" "main" + , createSampleBuild 172802 0 10 Vela.Success "push" "main" + , createSampleBuild 172803 0 10 Vela.Success "push" "main" + , createSampleBuild 172804 0 10 Vela.Success "push" "main" + , createSampleBuild 172805 0 10 Vela.Success "push" "main" + , createSampleBuild 172806 0 10 Vela.Success "push" "main" + ] + in + calculateBuildFrequency builds + |> Expect.equal 4 + , test "handles empty build list" <| + \_ -> + calculateBuildFrequency [] + |> Expect.equal 0 + ] + , describe "calculateFailureRate" + [ test "calculates failure rate percentage" <| + \_ -> + let + builds = + [ createSampleBuild 0 0 10 Vela.Success "push" "main" + , createSampleBuild 1 0 10 Vela.Failure "push" "main" + , createSampleBuild 2 0 10 Vela.Success "push" "main" + ] + in + calculateFailureRate builds + |> Expect.within (Expect.Absolute 0.01) 33.33 + , test "returns 0 for empty build list" <| + \_ -> + calculateFailureRate [] + |> Expect.equal 0 + ] + , describe "calculateAverageRuntime" + [ test "calculates average runtime excluding pending/running builds" <| + \_ -> + let + builds = + [ createSampleBuild 0 15 10 Vela.Success "push" "main" + , createSampleBuild 1 15 10 Vela.Success "push" "main" + , createSampleBuild 2 15 0 Vela.Pending "push" "main" + ] + in + calculateAverageRuntime (filterCompletedBuilds builds) + |> Expect.equal 15 + , test "calculates average runtime for varied build run times" <| + \_ -> + let + builds = + [ createSampleBuild 0 234 10 Vela.Success "push" "main" + , createSampleBuild 1 123 10 Vela.Success "push" "main" + , createSampleBuild 2 567 0 Vela.Pending "push" "main" + ] + in + calculateAverageRuntime (filterCompletedBuilds builds) + |> Expect.equal 178 + ] + , describe "calculateMetrics" + [ test "returns Nothing for empty build list" <| + \_ -> + calculateMetrics [] + |> Expect.equal Nothing + , test "calculates all metrics for valid builds" <| + \_ -> + let + builds = + [ createSampleBuild 0 0 10 Vela.Success "push" "main" + , createSampleBuild 1 0 10 Vela.Success "push" "main" + ] + in + calculateMetrics builds + |> Maybe.map .overall + |> Maybe.map .successRate + |> Expect.equal (Just 100) + ] + , describe "calculateAverageTimeToRecovery" + [ test "calculates average time between failure and success" <| + \_ -> + let + builds = + [ createSampleBuild 0 0 10 Vela.Failure "push" "main" + , createSampleBuild 100 0 10 Vela.Success "push" "main" + ] + in + calculateAverageTimeToRecovery builds + |> Expect.equal 100 + , test "calculates average time between failure and success (complex)" <| + \_ -> + let + builds = + [ createSampleBuild 0 0 10 Vela.Failure "push" "main" + , createSampleBuild 100 0 10 Vela.Success "push" "main" + , createSampleBuild 201 0 10 Vela.Success "push" "main" + , createSampleBuild 202 0 10 Vela.Success "push" "main" + , createSampleBuild 209 0 10 Vela.Success "push" "dev" + , createSampleBuild 210 0 10 Vela.Failure "push" "dev" + , createSampleBuild 250 0 10 Vela.Success "push" "main" + , createSampleBuild 300 0 10 Vela.Success "push" "dev" + , createSampleBuild 405 0 10 Vela.Success "push" "main" + ] + in + calculateAverageTimeToRecovery builds + |> Expect.equal 95 + ] + , describe "calculateEventBranchMetrics" + [ test "groups metrics by event and branch" <| + \_ -> + let + builds = + [ createSampleBuild 0 0 10 Vela.Success "push" "main" + , createSampleBuild 1 0 10 Vela.Success "pull_request" "feature" + , createSampleBuild 2 0 10 Vela.Success "push" "main" + , createSampleBuild 3 0 10 Vela.Success "pull_request" "feature" + ] + in + calculateEventBranchMetrics builds + |> Dict.size + |> Expect.equal 2 + ] + ] diff --git a/tests/HelpersTest.elm b/tests/HelpersTest.elm index e31b2d32f..86034d7e4 100644 --- a/tests/HelpersTest.elm +++ b/tests/HelpersTest.elm @@ -3,7 +3,7 @@ SPDX-License-Identifier: Apache-2.0 --} -module HelpersTest exposing (..) +module HelpersTest exposing (suite) import Expect import Test exposing (..) @@ -25,57 +25,63 @@ 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 "--:--" +suite : Test +suite = + describe "Helpers" + [ describe "formatRunTime" + [ test "started 1 second ago, finished is invalid (-1)" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) (currentTime - 1) -1 + |> Expect.equal "00:01" + , test "started 1 second ago, finished is invalid (0)" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) (currentTime - 1) 0 + |> Expect.equal "00:01" + , test "started and finished have invalid value (-1)" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) -1 -1 + |> Expect.equal "--:--" + , test "started and finished have invalid value (0)" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) 0 0 + |> Expect.equal "--:--" + , test "started is invalid (0), finished one second ago" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) 0 (currentTime - 1) + |> Expect.equal "--:--" + , test "started is invalid (-1), finished one second ago" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) -1 (currentTime - 1) + |> Expect.equal "--:--" + , test "finished time is before started time" <| + \_ -> + Utils.Helpers.formatRunTime (Time.millisToPosix currentTimeMillis) (currentTime - 1) (currentTime - 2) + |> Expect.equal "--:--" + ] + , describe "formatTimeFromFloat" + [ test "zero seconds" <| + \_ -> + Utils.Helpers.formatTimeFromFloat 0 + |> Expect.equal "0s" + , test "one minute" <| + \_ -> + Utils.Helpers.formatTimeFromFloat 60 + |> Expect.equal "1m 0s" + , test "one hour" <| + \_ -> + Utils.Helpers.formatTimeFromFloat 3600 + |> Expect.equal "1h 0s" + , test "negative value" <| + \_ -> + Utils.Helpers.formatTimeFromFloat -10.5 + |> Expect.equal "0s" + , test "decimal seconds" <| + \_ -> + Utils.Helpers.formatTimeFromFloat 125.7 + |> Expect.equal "2m 5s" + , test "large number" <| + \_ -> + Utils.Helpers.formatTimeFromFloat 7384 + |> Expect.equal "2h 3m 4s" + ] + ] diff --git a/tests/TimeSeriesMetricsTest.elm b/tests/TimeSeriesMetricsTest.elm new file mode 100644 index 000000000..79c8bebce --- /dev/null +++ b/tests/TimeSeriesMetricsTest.elm @@ -0,0 +1,124 @@ +module TimeSeriesMetricsTest exposing (suite) + +import Expect +import Metrics.TimeSeriesMetrics exposing (calculateAveragePerDay, calculateCountPerDay) +import Test exposing (Test, describe, test) +import Time +import Utils.Helpers as Util + + +suite : Test +suite = + describe "TimeSeriesMetrics" + [ describe "calculateCountPerDay" + [ test "returns zero counts for empty list" <| + \_ -> + let + now = + Time.millisToPosix (Util.oneDayMillis * 5) + + result = + calculateCountPerDay now 3 identity [] + in + Expect.equal (List.length result) 3 + , test "counts single item correctly" <| + \_ -> + let + now = + Time.millisToPosix (Util.oneDayMillis * 5) + + items = + [ Util.oneDaySeconds * 4 ] + + result = + calculateCountPerDay now 3 identity items + in + Expect.equal + (List.map Tuple.second result) + [ 0, 1, 0 ] + , test "aggregates multiple items on same day" <| + \_ -> + let + now = + Time.millisToPosix (Util.oneDayMillis * 5) + + items = + [ Util.oneDaySeconds * 4, Util.oneDaySeconds * 4, Util.oneDaySeconds * 4 ] + + result = + calculateCountPerDay now 3 identity items + in + Expect.equal + (List.map Tuple.second result) + [ 0, 3, 0 ] + ] + , describe "calculateAveragePerDay" + [ test "returns zero averages for empty list" <| + \_ -> + let + now = + Time.millisToPosix (Util.oneDayMillis * 5) + + result = + calculateAveragePerDay now 3 identity toFloat identity [] + in + Expect.equal + (List.map Tuple.second result) + [ 0, 0, 0 ] + , test "calculates average for single value per day" <| + \_ -> + let + now = + Time.millisToPosix (Util.oneDayMillis * 5) + + items = + [ Util.oneDaySeconds * 4 ] + + result = + calculateAveragePerDay now 3 identity (always 10.0) identity items + in + Expect.equal + (List.map Tuple.second result) + [ 0, 10.0, 0 ] + , test "calculates average for multiple values per day" <| + \_ -> + let + now = + Time.millisToPosix (Util.oneDayMillis * 5) + + items = + [ Util.oneDaySeconds * 4, Util.oneDaySeconds * 4, Util.oneDaySeconds * 4 ] + + result = + calculateAveragePerDay now + 3 + identity + (\_ -> 15.0) + identity + items + in + Expect.equal + (List.map Tuple.second result) + [ 0, 15.0, 0 ] + , test "applies value transformer correctly" <| + \_ -> + let + now = + Time.millisToPosix (Util.oneDayMillis * 5) + + items = + [ Util.oneDaySeconds * 4 ] + + result = + calculateAveragePerDay now + 3 + identity + (always 10.0) + ((*) 2) + items + in + Expect.equal + (List.map Tuple.second result) + [ 0, 20.0, 0 ] + ] + ]