diff --git a/api-contracts/openapi/paths/workflow/workflow.yaml b/api-contracts/openapi/paths/workflow/workflow.yaml index cc39e74ce..00527927b 100644 --- a/api-contracts/openapi/paths/workflow/workflow.yaml +++ b/api-contracts/openapi/paths/workflow/workflow.yaml @@ -441,6 +441,22 @@ workflowRuns: schema: type: string format: date-time + - description: The time after the workflow run was finished + in: query + name: finishedAfter + example: "2021-01-01T00:00:00Z" + required: false + schema: + type: string + format: date-time + - description: The time before the workflow run was finished + in: query + name: finishedBefore + example: "2021-01-01T00:00:00Z" + required: false + schema: + type: string + format: date-time - description: The order by field in: query name: orderByField diff --git a/api/v1/server/handlers/workflows/list_runs.go b/api/v1/server/handlers/workflows/list_runs.go index 95bd20855..e5a200b17 100644 --- a/api/v1/server/handlers/workflows/list_runs.go +++ b/api/v1/server/handlers/workflows/list_runs.go @@ -49,6 +49,14 @@ func (t *WorkflowService) WorkflowRunList(ctx echo.Context, request gen.Workflow listOpts.CreatedBefore = request.Params.CreatedBefore } + if request.Params.FinishedAfter != nil { + listOpts.FinishedAfter = request.Params.FinishedAfter + } + + if request.Params.FinishedBefore != nil { + listOpts.FinishedBefore = request.Params.FinishedBefore + } + if request.Params.Limit != nil { limit = int(*request.Params.Limit) listOpts.Limit = &limit diff --git a/api/v1/server/oas/gen/openapi.gen.go b/api/v1/server/oas/gen/openapi.gen.go index e5dcce4d7..583bb1a86 100644 --- a/api/v1/server/oas/gen/openapi.gen.go +++ b/api/v1/server/oas/gen/openapi.gen.go @@ -1469,6 +1469,12 @@ type WorkflowRunListParams struct { // CreatedBefore The time before the workflow run was created CreatedBefore *time.Time `form:"createdBefore,omitempty" json:"createdBefore,omitempty"` + // FinishedAfter The time after the workflow run was finished + FinishedAfter *time.Time `form:"finishedAfter,omitempty" json:"finishedAfter,omitempty"` + + // FinishedBefore The time before the workflow run was finished + FinishedBefore *time.Time `form:"finishedBefore,omitempty" json:"finishedBefore,omitempty"` + // OrderByField The order by field OrderByField *WorkflowRunOrderByField `form:"orderByField,omitempty" json:"orderByField,omitempty"` @@ -3301,6 +3307,20 @@ func (w *ServerInterfaceWrapper) WorkflowRunList(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter createdBefore: %s", err)) } + // ------------- Optional query parameter "finishedAfter" ------------- + + err = runtime.BindQueryParameter("form", true, false, "finishedAfter", ctx.QueryParams(), ¶ms.FinishedAfter) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter finishedAfter: %s", err)) + } + + // ------------- Optional query parameter "finishedBefore" ------------- + + err = runtime.BindQueryParameter("form", true, false, "finishedBefore", ctx.QueryParams(), ¶ms.FinishedBefore) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter finishedBefore: %s", err)) + } + // ------------- Optional query parameter "orderByField" ------------- err = runtime.BindQueryParameter("form", true, false, "orderByField", ctx.QueryParams(), ¶ms.OrderByField) @@ -9456,27 +9476,27 @@ var swaggerSpec = []string{ "bLbhbMAHbHDbM1Q92JstA11xtF0ujibf7aJzziDJtna//l1+d1s3MXvIZJfNAxeDhCLNcFssgcUbf1eF", "8Zbg08R6a2ET97LNwtXXhkQ4mACSYmhVe1W2ta5Vp5q6WF+hydoAd4dC3woq1rA1SF9R6DdD85pKv+oL", "c6MFdMCUAlrxV9ArpQgfVJfgHh8eH+0d0v9dHx6esP/9jwH3onufTqAnXh8QuEehcG1LiVOIJ3AaJXCT", - "IH9kM6wJZllz0pmKspJrqzypcJB97cnJ0+bKT1YB2lYByrJGs7zZvNNya+zmm7kYM1O5TVF04AjQqPgt", - "Mr9aJd3SI/aaa6R3ymGnHG5fOew0nk7jeRFfOF7xTQEmgLoHBV7ofM+84DJTZc+L0pA0HPWsjfSnytQV", - "cA9QACYBZAe9HNd40H+GhGcY4FM246t3fTe5vV+5M6uwWUvyOicVTj4drxt4vYCk5YJhiuyfYpjgAy9N", - "EljP2bwSr2jo0G7aWryfITkVg22Q7ljN2XZ0xiDukihfPonStuQwpfsSuRVLDlfJeIbIPJ0ceCAIJnWV", - "3k8jqnAR2KK69Gc2NCsvfSqHb1292RPzbqJ+cwF3coF1FZuN6FtvWW4FcbIu987UvG5X4lqDsCiaBXAz", - "9MaG/sXpjaNvzfSWI+6XozfxhL1FUZXCS8f5G4SNxzcdQX1eGbtbese57QNExQV2+qL1sao+q4uyPS6/", - "r2KkvQPgeTAmNTVC2fd2L8XzPu5mQoz44JXHzQ1hQTXUx1e+00+Jv/xTPhzblb23p68EsnCnmmoA9Hs7", - "+uJ93E3l1tPB10BffOUdfTVUPqRIWoK+gmiGakqhnkcz7KDQAexs3K9RMM7ZQJuhJXYE0/G3VJ3I6h4d", - "RLMZ9B3UvSKwW9fn4rFOqcb2nhxEsyglDcwQpcSOG6L05W09gkajHcvV7Yi0QRll1GNLtuK1/TmKW1yB", - "lE521yB+hHzLu4lQ240SuH7S9vchFUXdnWiZO5GKwWaSjAHGD1Him2UpF5NCkjqyfZ1IvZJjbk7HOJ2D", - "cJZNtEvKhscg8zNEdeL8FYlzTlZFSrdgogTOqCBL6i59vAWu1UiyMqebYhsJxi4xjERe5+Z6FXq6JCFb", - "nYe/lLoJD0P+XupuOhgaRE1Lj0OpLvnBT/HDM18hHaq61jPIcdpYYJk3tAlTkQW5jQEh2URbjgepe/QH", - "5nvclSPewXLEnPwsyhH3MvqyY44DgWeb+5ZsKmsT1HOMOEKtH+/dWb5Zf5VwgZplyvxn29Wx547W+c+3", - "qC2PZrzJ/rApW6oxbnAKsyxPKoLN6mIX2RSvt0j4ErGKO6Ze70x18BbFwXuSdNjTFoB48xqzSS0h81av", - "hpY3cCtlCCicG3Ulvem9Q6Jse1W8LXmNQ9Zxmp7TBEOswmyl06Qc5N98E1JLTlkl7LW4F+1kpPz7Njcj", - "CWBXS21LtdQuDKXTBLEqFLNknHzPqqCrFSe0ULneQsLIkkkiHW+9NG+p2ShrSUDRppvZJJVTuihmj2fM", - "cw8TzOsfGJmxRRL5LvCkJrmXF25ZQ+2X5Su/6AGbJVEas1TjHAS5UUZQWKev8KkAzEtIphUzVgXpdUmr", - "uyiwsl3ZmOAiCZrN6jyZ17yBA5wQPixXFN7+NcedlFzXGnbZd4ZTZkDDKaUO6PdENWYCMcl4CmFnCok3", - "h76pIEUu+Hf83i7IQNnVNu+2l+r+bvcmb/tmTuERy66+/cuKxJ179EvKwYbq/rYveFiIZiEbsO07HVLq", - "WInlP3jjV3TL+xXk8oalnNjUFVXBTt7tlAqYk+KyKmA5TGUCQQKTLEylpw1cgcm9lAdpErgnrvv84/n/", - "BwAA//8sKDhMu7wBAA==", + "IH9kM6wT5hosT1GI8Hx5mGX/reJ5XUCvFdOyuqczFQU811bjU5FV9lU+J0+bK/RZBWhbpT7LuuPyDoru", + "PlHjodiMCYI5JWzKzwNHgEYPuiL7q/XoLX2Pr7kafaeGd2r49tXwTrfsdMsXiTrAK77ewARQ93TDC53v", + "WbyBzAna86I0JA1HPWsjPdcySQjcAxSASQDZQS/HNR70nyHhuRz4lM346oMMmgIMXrnbsLBZS/I6JxVO", + "Ph2vG3i9gKTlwo6K7J9imOADL00SWM/ZvOaxaOjQbtqqx58hORWDbZDuWHXfdnTGIO7SVV8+XdW2uDOl", + "+xK5FYs7V8l4hsg8nRx4IAgmdTX1TyOqcBHYoo73ZzY0K+R9KodvXSfbE/NuolJ2AXdygXW1sY3oW28B", + "dAVxsgL6zlQXb1dMXIOwKJoFcDP0xob+xemNo2/N9JYj7pejN/6usk35msKb0vlrj43HNx1Bfcgau1t6", + "MbvtU0/FBXb6ovWxqj5gjLI9Lr9kY6S9A+B5MCY11VjZ93Zv8vM+7maCufjglWfkDQFYNdTHV77Tj7a/", + "/KNJHNuVvbenrwSywLKaugv0ezv64n3cTVUxoIOvgb74yjv6aqgxSZG0BH0F0QzVFJ09j2bYQaED2Nm4", + "X6NgnLOBNkNL7Aim42+pDpTVPTqIZjPoO6h7r2G3rs/FY51Sje09OYhmUUoamCFKiR03ROnL23oEjUY7", + "lhXdEWmDMsqox5Zs+fP5eI7iFlcgpZPdNYgfId/ybiKoeaMErp+0/X1IRVF3J1rmTqRisJkkY4DxQ5T4", + "ZlnKxaSQpI5sXydSr+SYm9MxTucgnGUT7ZKy4THI/AxRnTh/ReKck1WR0i2YKIEzKsiSuksfb4FrNZKs", + "oOym2EaCsUsMI5HXublehZ4uSchW5+Fv0m7Cw5C/TLubDoYGUdPS41CqAH/wU/zwzFdIh6qu9QxynDaW", + "suYNbcJUZOlzY0BINtGW40HqnleC+R53hZ93sPAzJz+Lws+9jL7smONA4NnmviWbyioQ9RwjjlDrZ5J3", + "lm/WX49doGaZBxWy7erYc0dfVMi3qC2PZrzJ/rApEKsxbnAKsywEK4LN6mIX2RSvtxz7ErGKO6Ze70wd", + "9hZl2HuSdNgjIoB48xqzSS0h81avhpY3cCtlCCicG3XF0+m9Q6Jse/XSLXmNQ9Zxmp7TBEOswmyl06Qc", + "5N98E1KLe1kl7LW4F+1kpPz7NjcjCWBXtW5LVesuDEXqBLEqFLNknHzPqnSuFSe0ULneQsLIkkkiHW+9", + "NG+p2ShrSUDRppvZJJVTuihmj2fMcw8TzOsfGJmxRRL5LvCkJrmXl8hZQ5Wd5Wvs6AGbJVEas1TjHAS5", + "UUZQWKev8KkAzEtIphUzVgXpdUmruyiwsl3ZmOAiCZrN6jyZ17yBA5wQPixXft/+3cydlFzXGnbZd4ZT", + "ZkDDKaUO6PdE3WsCMcl4CmFnCok3h76pIEUu+Hf83i7IQNnVNi/klyosb/cmb/s6UeG50O4lgZcViTv3", + "vJqUgw3vKNi+lWIhmoVswLYvokipYyWW/+CNX9Et71eQyxuWcmJTV1QFO3m3UypgTorLqoDlMJUJBAlM", + "sjCVnjZwBSb3Uh6kSeCeuO7zj+f/HwAA///pYTfLJb4BAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/frontend/app/package.json b/frontend/app/package.json index 94aa5cd7e..805c633a8 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -73,6 +73,7 @@ "dompurify": "^3.1.6", "jotai": "^2.6.0", "js-confetti": "^0.12.0", + "lucide-react": "^0.446.0", "monaco-themes": "^0.4.4", "prism-react-renderer": "^2.3.0", "qs": "^6.11.2", @@ -84,6 +85,7 @@ "react-router-dom": "^6.20.0", "react-syntax-highlighter": "^15.5.0", "reactflow": "^11.10.3", + "recharts": "^2.12.7", "tailwind-merge": "^2.0.0", "tailwindcss-animate": "^1.0.7", "timeago-react": "^3.0.6", diff --git a/frontend/app/pnpm-lock.yaml b/frontend/app/pnpm-lock.yaml index 08a2ebea1..3738a8ac1 100644 --- a/frontend/app/pnpm-lock.yaml +++ b/frontend/app/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: js-confetti: specifier: ^0.12.0 version: 0.12.0 + lucide-react: + specifier: ^0.446.0 + version: 0.446.0(react@18.2.0) monaco-themes: specifier: ^0.4.4 version: 0.4.4 @@ -212,6 +215,9 @@ importers: reactflow: specifier: ^11.10.3 version: 11.10.4(@types/react@18.2.73)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + recharts: + specifier: ^2.12.7 + version: 2.12.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0) tailwind-merge: specifier: ^2.0.0 version: 2.2.2 @@ -2284,6 +2290,10 @@ packages: d3-path@1.0.9: resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + d3-random@2.2.2: resolution: {integrity: sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw==} @@ -2298,6 +2308,10 @@ packages: d3-shape@1.3.7: resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + d3-time-format@4.1.0: resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} engines: {node: '>=12'} @@ -2358,6 +2372,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2397,6 +2414,9 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dompurify@3.1.6: resolution: {integrity: sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==} @@ -2631,12 +2651,19 @@ packages: resolution: {integrity: sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==} engines: {node: '>=6.0.0'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -3127,6 +3154,11 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lucide-react@0.446.0: + resolution: {integrity: sha512-BU7gy8MfBMqvEdDPH79VhOXSEgyG8TSPOKWaExWGCQVqnGH7wGgDngPbofu+KdtVjPQBWbEmnfMTq90CTiiDRg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + luxon@3.4.4: resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} engines: {node: '>=12'} @@ -3534,6 +3566,12 @@ packages: peerDependencies: react: '>=16.8' + react-smooth@4.0.1: + resolution: {integrity: sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-style-singleton@2.2.1: resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} engines: {node: '>=10'} @@ -3549,6 +3587,12 @@ packages: peerDependencies: react: '>= 0.14.0' + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react-use-measure@2.1.1: resolution: {integrity: sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig==} peerDependencies: @@ -3572,6 +3616,16 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.12.7: + resolution: {integrity: sha512-hlLJMhPQfv4/3NBSAyq3gzGg4h2v69RJh6KU7b3pXYNNAELs9kEoXOjbkxdXpALqKBoVmVptGfLpxdaVYqjmXQ==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + reduce-css-calc@1.3.0: resolution: {integrity: sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==} @@ -3957,6 +4011,9 @@ packages: validate.io-number@1.0.3: resolution: {integrity: sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-plugin-eslint@1.8.1: resolution: {integrity: sha512-PqdMf3Y2fLO9FsNPmMX+//2BF5SF8nEWspZdgl4kSt7UvHDRHVVfHvxsD7ULYzZrJDGRxR81Nq7TOFgwMnUang==} peerDependencies: @@ -6311,6 +6368,8 @@ snapshots: d3-path@1.0.9: {} + d3-path@3.1.0: {} + d3-random@2.2.2: {} d3-scale@4.0.2: @@ -6327,6 +6386,10 @@ snapshots: dependencies: d3-path: 1.0.9 + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + d3-time-format@4.1.0: dependencies: d3-time: 3.1.0 @@ -6389,6 +6452,8 @@ snapshots: dependencies: ms: 2.1.2 + decimal.js-light@2.5.1: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -6427,6 +6492,11 @@ snapshots: dependencies: esutils: 2.0.3 + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.24.1 + csstype: 3.1.3 + dompurify@3.1.6: {} dotenv@16.4.5: {} @@ -6785,10 +6855,14 @@ snapshots: eta@2.2.0: {} + eventemitter3@4.0.7: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} + fast-equals@5.0.1: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7258,6 +7332,10 @@ snapshots: dependencies: yallist: 4.0.0 + lucide-react@0.446.0(react@18.2.0): + dependencies: + react: 18.2.0 + luxon@3.4.4: {} magic-string@0.27.0: @@ -7639,6 +7717,14 @@ snapshots: '@remix-run/router': 1.15.3 react: 18.2.0 + react-smooth@4.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + fast-equals: 5.0.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 4.4.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-style-singleton@2.2.1(@types/react@18.2.73)(react@18.2.0): dependencies: get-nonce: 1.0.1 @@ -7657,6 +7743,15 @@ snapshots: react: 18.2.0 refractor: 3.6.0 + react-transition-group@4.4.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + '@babel/runtime': 7.24.1 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-use-measure@2.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: debounce: 1.2.1 @@ -7689,6 +7784,23 @@ snapshots: dependencies: picomatch: 2.3.1 + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.12.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + clsx: 2.1.0 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 16.13.1 + react-smooth: 4.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + reduce-css-calc@1.3.0: dependencies: balanced-match: 0.4.2 @@ -8178,6 +8290,23 @@ snapshots: validate.io-number@1.0.3: {} + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-shape': 3.1.6 + '@types/d3-time': 3.0.3 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-plugin-eslint@1.8.1(eslint@8.57.0)(vite@5.2.7(@types/node@20.12.2)): dependencies: '@rollup/pluginutils': 4.2.1 diff --git a/frontend/app/src/components/molecules/brush-chart/area-chart.tsx b/frontend/app/src/components/molecules/brush-chart/area-chart.tsx index 0a10675b9..b4c1945ea 100644 --- a/frontend/app/src/components/molecules/brush-chart/area-chart.tsx +++ b/frontend/app/src/components/molecules/brush-chart/area-chart.tsx @@ -74,6 +74,7 @@ export const formatPercentTooltip = (d: number) => `${format2Dec(d)}%`; type AreaChartProps = { data: MetricValue[]; + kind: 'area' | 'bar'; gradientColor?: string; width: number; height: number; @@ -91,6 +92,7 @@ type AreaChartProps = { export default withTooltip( ({ data, + kind, gradientColor = background2, width, height, @@ -164,6 +166,12 @@ export default withTooltip( [showTooltip, yScale, dateScale, data], ); + let barWidth = innerWidth / data.length; + + if (barWidth <= 5) { + barWidth = 6; + } + return (
@@ -210,17 +218,47 @@ export default withTooltip( toOpacity={0.2} height={innerHeight} /> - - data={data} - x={(d) => dateScale(d.date) || 0} - y={(d) => yScale(d.value) || 0} - yScale={yScale} - strokeWidth={1} - stroke="url(#gradient)" - fill="url(#gradient)" - curve={curveMonotoneX} - height={innerHeight} - /> + {kind == 'bar' && + data.map((d, i) => { + if (i == 0) { + return ( + + ); + } + + return ( + + ); + })} + {kind == 'area' && ( + + data={data} + x={(d) => dateScale(d.date) || 0} + y={(d) => yScale(d.value) || 0} + yScale={yScale} + strokeWidth={1} + stroke="url(#gradient)" + fill="url(#gradient)" + curve={curveMonotoneX} + height={innerHeight} + /> + )} {!hideBottomAxis && ( = Record & { + date: string; +}; + +const getNextActiveLabel = (activeLabel: string, data: DataPoint[]) => { + const currentIndex = data.findIndex((d) => d.date === activeLabel); + if (currentIndex === -1) { + return null; + } + + // if we're at the end of the data, determine the time between the last two data points and add that to the last date + if (currentIndex === data.length - 1) { + const lastDate = new Date(data[currentIndex].date); + const secondLastDate = new Date(data[currentIndex - 1].date); + const diff = lastDate.getTime() - secondLastDate.getTime(); + return new Date(lastDate.getTime() + diff).toISOString(); + } + + return data[currentIndex + 1]?.date || activeLabel; +}; + +const getPrevActiveLabel = (activeLabel: string, data: DataPoint[]) => { + const currentIndex = data.findIndex((d) => d.date === activeLabel); + if (currentIndex === -1) { + return activeLabel; + } + + // if we're at the start of the data, determine the time between the first two data points and subtract that from the first date + if (currentIndex === 0) { + const firstDate = new Date(data[currentIndex].date); + const secondDate = new Date(data[currentIndex + 1].date); + const diff = secondDate.getTime() - firstDate.getTime(); + return new Date(firstDate.getTime() - diff).toISOString(); + } + + return data[currentIndex - 1]?.date || activeLabel; +}; + +type ZoomableChartProps = { + data: DataPoint[]; + colors?: Record; + zoom?: (startTime: string, endTime: string) => void; + showYAxis?: boolean; +}; + +export function ZoomableChart({ + data, + colors, + zoom, + showYAxis = true, +}: ZoomableChartProps) { + const [refAreaLeft, setRefAreaLeft] = useState(null); + const [refAreaRight, setRefAreaRight] = useState(null); + const [actualRefAreaLeft, setActualRefAreaLeft] = useState( + null, + ); + const [actualRefAreaRight, setActualRefAreaRight] = useState( + null, + ); + const [isSelecting, setIsSelecting] = useState(false); + const chartRef = useRef(null); + + const chartConfig = useMemo(() => { + const keys = Object.keys(data[0] || {}).filter((key) => key !== 'date'); + return keys.reduce((acc, key, index) => { + acc[key] = { + label: capitalize(key), + color: colors?.[key] || `hsl(${(index * 360) / keys.length}, 70%, 50%)`, + }; + return acc; + }, {}); + }, [data, colors]); + + const handleMouseDown = (e: any) => { + if (e.activeLabel) { + setRefAreaLeft(e.activeLabel); + setActualRefAreaLeft(getPrevActiveLabel(e.activeLabel, data)); + setIsSelecting(true); + } + }; + + const handleMouseMove = (e: any) => { + if (isSelecting && e.activeLabel) { + setRefAreaRight(e.activeLabel); + setActualRefAreaRight(getNextActiveLabel(e.activeLabel, data)); + } + }; + + const handleMouseUp = () => { + if (actualRefAreaLeft && actualRefAreaRight) { + const [left, right] = [actualRefAreaLeft, actualRefAreaRight].sort(); + zoom?.(left, right); + } + setRefAreaLeft(null); + setActualRefAreaLeft(null); + setRefAreaRight(null); + setActualRefAreaRight(null); + setIsSelecting(false); + }; + + const minDate = new Date( + Math.min(...data.map((d) => new Date(d.date).getTime())), + ); + const maxDate = new Date( + Math.max(...data.map((d) => new Date(d.date).getTime())), + ); + + const formatXAxis = (tickItem: string) => { + const date = new Date(tickItem); + const timeDiff = maxDate.getTime() - minDate.getTime(); + const oneDay = 24 * 60 * 60 * 1000; + const sevenDays = 7 * oneDay; + + if (timeDiff > sevenDays) { + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } else if (timeDiff > oneDay) { + return `${date.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + } else { + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + } + }; + + // remove date from dataKeys + const dataKeys = Object.keys(data[0] || {}).filter((key) => key !== 'date'); + + return ( + +
+ + + + + {showYAxis && ( + + )} + new Date(value).toLocaleString()} + /> + } + /> + {dataKeys.map((key) => ( + + ))} + + {refAreaLeft && refAreaRight && ( + + )} + + +
+ + ); +} diff --git a/frontend/app/src/components/molecules/time-picker/date-time-picker.tsx b/frontend/app/src/components/molecules/time-picker/date-time-picker.tsx index 701f5fa71..2a0e8ac0c 100644 --- a/frontend/app/src/components/molecules/time-picker/date-time-picker.tsx +++ b/frontend/app/src/components/molecules/time-picker/date-time-picker.tsx @@ -9,6 +9,7 @@ import { } from '@/components/ui/popover'; import { TimePicker } from './time-picker'; import { CalendarIcon } from '@radix-ui/react-icons'; +import { useState } from 'react'; type DateTimePickerProps = { date: Date | undefined; @@ -17,6 +18,8 @@ type DateTimePickerProps = { }; export function DateTimePicker({ date, setDate, label }: DateTimePickerProps) { + const [selectedDate, setSelectedDate] = useState(date); + /** * carry over the current time when a user clicks a new day * instead of resetting to 00:00 @@ -36,12 +39,18 @@ export function DateTimePicker({ date, setDate, label }: DateTimePickerProps) { }; return ( - + { + if (!isOpen && selectedDate !== date) { + setDate(selectedDate); + } + }} + >
-
- -
); } diff --git a/frontend/app/src/components/ui/card.tsx b/frontend/app/src/components/ui/card.tsx index 1907c92cd..cdfabe8e3 100644 --- a/frontend/app/src/components/ui/card.tsx +++ b/frontend/app/src/components/ui/card.tsx @@ -47,7 +47,7 @@ const CardDescription = React.forwardRef< >(({ className, ...props }, ref) => (

)); diff --git a/frontend/app/src/components/ui/chart.tsx b/frontend/app/src/components/ui/chart.tsx new file mode 100644 index 000000000..9208e0446 --- /dev/null +++ b/frontend/app/src/components/ui/chart.tsx @@ -0,0 +1,363 @@ +import * as React from 'react'; +import * as RechartsPrimitive from 'recharts'; + +import { cn } from '@/lib/utils'; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >['children']; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +

+ + + {children} + +
+ + ); +}); +ChartContainer.displayName = 'Chart'; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +