feat: workflow run metrics view (#912)

* feat: add callbacks for workflow run completed

* add tenant id to resolve row

* add finishedBefore, finishedAfter to workflow runs query

* add more callbacks

* feat: tenant ids and loggers in callback

* feat: workflow run metrics frontend

* fix: frontend build
This commit is contained in:
abelanger5
2024-09-27 07:38:15 -04:00
committed by GitHub
parent c24d65b76e
commit 925b2654c8
29 changed files with 1350 additions and 126 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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(), &params.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(), &params.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(), &params.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

View File

@@ -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",

View File

@@ -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

View File

@@ -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<AreaChartProps, TooltipData>(
({
data,
kind,
gradientColor = background2,
width,
height,
@@ -164,6 +166,12 @@ export default withTooltip<AreaChartProps, TooltipData>(
[showTooltip, yScale, dateScale, data],
);
let barWidth = innerWidth / data.length;
if (barWidth <= 5) {
barWidth = 6;
}
return (
<div>
<svg width={width} height={height} overflow={'visible'}>
@@ -210,17 +218,47 @@ export default withTooltip<AreaChartProps, TooltipData>(
toOpacity={0.2}
height={innerHeight}
/>
<AreaClosed<MetricValue>
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 (
<Bar
key={i}
x={dateScale(getDate(d)) || 0}
y={yScale(getValue(d)) || 0}
width={(barWidth - 4) / 2}
height={innerHeight - yScale(getValue(d)) || 0}
fill="url(#gradient)"
rx={2}
/>
);
}
return (
<Bar
key={i}
x={(dateScale(getDate(d)) || 0) - barWidth / 2}
y={yScale(getValue(d)) || 0}
width={barWidth - 4}
height={innerHeight - yScale(getValue(d)) || 0}
fill="url(#gradient)"
rx={2}
/>
);
})}
{kind == 'area' && (
<AreaClosed<MetricValue>
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 && (
<AxisBottom
top={height}

View File

@@ -0,0 +1,218 @@
import { useState, useMemo, useRef } from 'react';
import {
CartesianGrid,
XAxis,
YAxis,
ReferenceArea,
ResponsiveContainer,
Bar,
BarChart,
} from 'recharts';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { capitalize } from '@/lib/utils';
export type DataPoint<T extends string> = Record<T, number> & {
date: string;
};
const getNextActiveLabel = (activeLabel: string, data: DataPoint<string>[]) => {
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<string>[]) => {
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<T extends string> = {
data: DataPoint<T>[];
colors?: Record<string, string>;
zoom?: (startTime: string, endTime: string) => void;
showYAxis?: boolean;
};
export function ZoomableChart<T extends string>({
data,
colors,
zoom,
showYAxis = true,
}: ZoomableChartProps<T>) {
const [refAreaLeft, setRefAreaLeft] = useState<string | null>(null);
const [refAreaRight, setRefAreaRight] = useState<string | null>(null);
const [actualRefAreaLeft, setActualRefAreaLeft] = useState<string | null>(
null,
);
const [actualRefAreaRight, setActualRefAreaRight] = useState<string | null>(
null,
);
const [isSelecting, setIsSelecting] = useState(false);
const chartRef = useRef<HTMLDivElement>(null);
const chartConfig = useMemo<ChartConfig>(() => {
const keys = Object.keys(data[0] || {}).filter((key) => key !== 'date');
return keys.reduce<ChartConfig>((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 (
<ChartContainer
config={chartConfig}
className="w-full h-[200px] min-h-[200px]"
>
<div className="h-full" ref={chartRef} style={{ touchAction: 'none' }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickFormatter={formatXAxis}
tickLine={false}
axisLine={false}
tickMargin={4}
minTickGap={16}
style={{ fontSize: '10px', userSelect: 'none' }}
/>
{showYAxis && (
<YAxis
tickLine={false}
axisLine={false}
tickMargin={4}
style={{ fontSize: '10px', userSelect: 'none' }}
/>
)}
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px] sm:w-[200px] font-mono text-xs sm:text-xs"
labelFormatter={(value) => new Date(value).toLocaleString()}
/>
}
/>
{dataKeys.map((key) => (
<Bar
key={key}
type="monotone"
dataKey={key}
stroke={chartConfig[key].color}
fillOpacity={1}
fill={chartConfig[key].color}
isAnimationActive={false}
/>
))}
{refAreaLeft && refAreaRight && (
<ReferenceArea
x1={refAreaLeft}
x2={refAreaRight}
strokeOpacity={0.3}
fill="hsl(var(--foreground))"
fillOpacity={0.1}
/>
)}
</BarChart>
</ResponsiveContainer>
</div>
</ChartContainer>
);
}

View File

@@ -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 | undefined>(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 (
<Popover>
<Popover
onOpenChange={(isOpen) => {
if (!isOpen && selectedDate !== date) {
setDate(selectedDate);
}
}}
>
<PopoverTrigger asChild>
<Button
variant={'outline'}
className={cn(
'w-[280px] justify-start text-left font-normal',
'w-fit justify-start text-left font-normal text-xs',
!date && 'text-muted-foreground',
)}
>
@@ -61,7 +70,7 @@ export function DateTimePicker({ date, setDate, label }: DateTimePickerProps) {
initialFocus
/>
<div className="p-3 border-t border-border">
<TimePicker setDate={setDate} date={date} />
<TimePicker setDate={setSelectedDate} date={selectedDate} />
</div>
</PopoverContent>
</Popover>

View File

@@ -27,7 +27,6 @@ const TimePickerInput = React.forwardRef<
(
{
className,
type = 'datetime-local',
value,
id,
name,
@@ -113,22 +112,28 @@ const TimePickerInput = React.forwardRef<
}
};
const valueToTwoDigits = (value: number) => {
return value < 10 ? `0${value}` : value;
};
return (
<Input
ref={ref}
id={id || picker}
name={name || picker}
className={cn(
'w-[48px] text-center font-mono text-base tabular-nums caret-transparent focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none',
'w-[48px] text-xs text-center font-mono tabular-nums caret-transparent focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none',
className,
)}
value={value || calculatedValue}
value={value || valueToTwoDigits(parseInt(calculatedValue))}
onChange={(e) => {
e.preventDefault();
onChange?.(e);
}}
type={type}
inputMode="decimal"
min={0}
max={59}
type="number"
inputMode="numeric"
onKeyDown={(e) => {
onKeyDown?.(e);
handleKeyDown(e);

View File

@@ -1,7 +1,6 @@
import * as React from 'react';
import { Label } from '@/components/ui/label';
import { TimePickerInput } from './time-picker-input';
import { ClockIcon } from '@radix-ui/react-icons';
interface TimePickerProps {
date: Date | undefined;
@@ -52,9 +51,6 @@ export function TimePicker({ date, setDate }: TimePickerProps) {
onLeftFocus={() => minuteRef.current?.focus()}
/>
</div>
<div className="flex h-10 items-center">
<ClockIcon className="ml-2 h-4 w-4" />
</div>
</div>
);
}

View File

@@ -47,7 +47,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-700 dark:text-gray-300', className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));

View File

@@ -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<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
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 (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
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 (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
ref={ref}
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = 'ChartTooltip';
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = 'ChartLegend';
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -1467,6 +1467,18 @@ export class Api<SecurityDataType = unknown> extends HttpClient<SecurityDataType
* @example "2021-01-01T00:00:00Z"
*/
createdBefore?: string;
/**
* The time after the workflow run was finished
* @format date-time
* @example "2021-01-01T00:00:00Z"
*/
finishedAfter?: string;
/**
* The time before the workflow run was finished
* @format date-time
* @example "2021-01-01T00:00:00Z"
*/
finishedBefore?: string;
/** The order by field */
orderByField?: WorkflowRunOrderByField;
/** The order by direction */

View File

@@ -29,6 +29,7 @@ import {
TenantSubscription,
UpdateTenantSubscription,
VectorPushRequest,
WorkflowRunEventsMetricsCounts,
} from "./data-contracts";
import { ContentType, HttpClient, RequestParams } from "./http-client";
@@ -565,4 +566,39 @@ export class Api<SecurityDataType = unknown> extends HttpClient<SecurityDataType
format: "json",
...params,
});
/**
* @description Get a minute by minute breakdown of workflow run metrics for a tenant
*
* @tags Workflow
* @name WorkflowRunEventsGetMetrics
* @summary Get workflow runs
* @request GET:/api/v1/cloud/tenants/{tenant}/runs-metrics
* @secure
*/
workflowRunEventsGetMetrics = (
tenant: string,
query?: {
/**
* The time after the workflow run was created
* @format date-time
* @example "2021-01-01T00:00:00Z"
*/
createdAfter?: string;
/**
* The time before the workflow run was completed
* @format date-time
* @example "2021-01-01T00:00:00Z"
*/
finishedBefore?: string;
},
params: RequestParams = {},
) =>
this.request<WorkflowRunEventsMetricsCounts, APIErrors>({
path: `/api/v1/cloud/tenants/${tenant}/runs-metrics`,
method: "GET",
query: query,
secure: true,
format: "json",
...params,
});
}

View File

@@ -20,6 +20,11 @@ export interface APICloudMetadata {
* @example true
*/
canLinkGithub?: boolean;
/**
* whether metrics are enabled for the tenant
* @example true
*/
metricsEnabled?: boolean;
}
export interface APIErrors {
@@ -481,3 +486,17 @@ export interface InstanceList {
* @example {"flag1":"value1","flag2":"value2"}
*/
export type FeatureFlags = Record<string, string>;
export interface WorkflowRunEventsMetric {
/** @format date-time */
time: string;
PENDING: number;
RUNNING: number;
SUCCEEDED: number;
FAILED: number;
QUEUED: number;
}
export interface WorkflowRunEventsMetricsCounts {
results?: WorkflowRunEventsMetric[];
}

View File

@@ -11,6 +11,9 @@ type ListWorkflowRunsQuery = Parameters<typeof api.workflowRunList>[1];
export type ListCloudLogsQuery = Parameters<typeof cloudApi.logList>[1];
export type GetCloudMetricsQuery = Parameters<typeof cloudApi.metricsCpuGet>[1];
type WorkflowRunMetrics = Parameters<typeof api.workflowRunGetMetrics>[1];
type WorkflowRunEventsMetrics = Parameters<
typeof cloudApi.workflowRunEventsGetMetrics
>[1];
export const queries = createQueryKeyStore({
cloud: {
@@ -76,6 +79,11 @@ export const queries = createQueryKeyStore({
queryFn: async () =>
(await cloudApi.managedWorkerEventsList(managedWorkerId)).data,
}),
workflowRunMetrics: (tenant: string, query: WorkflowRunEventsMetrics) => ({
queryKey: ['workflow-run:metrics', tenant, query],
queryFn: async () =>
(await cloudApi.workflowRunEventsGetMetrics(tenant, query)).data,
}),
},
user: {
current: {

View File

@@ -210,6 +210,7 @@ function MetricsChart({
className="w-full max-h-[25rem] min-h-[25rem] ml-8 px-14"
>
<AreaChart
kind="area"
hideBottomAxis={false}
data={values}
width={width}
@@ -230,6 +231,7 @@ function MetricsPlaceholder({ start, end }: { start: Date; end: Date }) {
className="w-full max-h-[25rem] min-h-[25rem] ml-8 px-14"
>
<AreaChart
kind="area"
hideBottomAxis={true}
hideLeftAxis={true}
data={[]}

View File

@@ -29,6 +29,7 @@ import { Button } from '@/components/ui/button';
import {
ArrowPathIcon,
ArrowPathRoundedSquareIcon,
XCircleIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { WorkflowRunsMetricsView } from './workflow-runs-metrics';
@@ -52,6 +53,12 @@ import {
} from '@/components/ui/dialog';
import { CodeHighlighter } from '@/components/ui/code-highlighter';
import { Separator } from '@/components/ui/separator';
import {
DataPoint,
ZoomableChart,
} from '@/components/molecules/charts/zoomable';
import { DateTimePicker } from '@/components/molecules/time-picker/date-time-picker';
import useCloudApiMeta from '@/pages/auth/hooks/use-cloud-api-meta';
export interface WorkflowRunsTableProps {
createdAfter?: string;
@@ -89,20 +96,58 @@ export function WorkflowRunsTable({
const { tenant } = useOutletContext<TenantContextType>();
invariant(tenant);
const cloudMeta = useCloudApiMeta();
const [viewQueueMetrics, setViewQueueMetrics] = useState(false);
const [timeRange, setTimeRange] = useAtom(lastTimeRangeAtom);
const [defaultTimeRange, setDefaultTimeRange] = useAtom(lastTimeRangeAtom);
// customTimeRange does not get set in the atom,
const [customTimeRange, setCustomTimeRange] = useState<string[] | undefined>(
() => {
const timeRangeParam = searchParams.get('customTimeRange');
if (timeRangeParam) {
return timeRangeParam.split(',').map((param) => {
return new Date(param).toISOString();
});
}
return undefined;
},
);
const [createdAfter, setCreatedAfter] = useState<string | undefined>(
getCreatedAfterFromTimeRange(timeRange) ||
getCreatedAfterFromTimeRange(defaultTimeRange) ||
new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
);
const [finishedBefore, setFinishedBefore] = useState<string | undefined>();
// create a timer which updates the createdAfter date every minute
useEffect(() => {
const interval = setInterval(() => {
if (customTimeRange) {
return;
}
setCreatedAfter(
getCreatedAfterFromTimeRange(defaultTimeRange) ||
new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
);
}, 60 * 1000);
return () => clearInterval(interval);
}, [defaultTimeRange, customTimeRange]);
// whenever the time range changes, update the createdAfter date
useEffect(() => {
if (timeRange) {
setCreatedAfter(getCreatedAfterFromTimeRange(timeRange));
if (customTimeRange && customTimeRange.length === 2) {
setCreatedAfter(customTimeRange[0]);
setFinishedBefore(customTimeRange[1]);
} else if (defaultTimeRange) {
setCreatedAfter(getCreatedAfterFromTimeRange(defaultTimeRange));
setFinishedBefore(undefined);
}
}, [timeRange, setCreatedAfter]);
}, [defaultTimeRange, customTimeRange, setCreatedAfter]);
const [sorting, setSorting] = useState<SortingState>(() => {
const sortParam = searchParams.get('sort');
@@ -150,10 +195,23 @@ export function WorkflowRunsTable({
newSearchParams.set('pageIndex', pagination.pageIndex.toString());
newSearchParams.set('pageSize', pagination.pageSize.toString());
if (customTimeRange && customTimeRange.length === 2) {
newSearchParams.set('customTimeRange', customTimeRange?.join(','));
} else {
newSearchParams.delete('customTimeRange');
}
if (newSearchParams.toString() !== searchParams.toString()) {
setSearchParams(newSearchParams, { replace: true });
}
}, [sorting, columnFilters, pagination, setSearchParams, searchParams]);
}, [
sorting,
columnFilters,
pagination,
customTimeRange,
setSearchParams,
searchParams,
]);
const [pageSize, setPageSize] = useState<number>(50);
@@ -242,6 +300,7 @@ export function WorkflowRunsTable({
orderByField,
additionalMetadata: AdditionalMetadataFilter,
createdAfter,
finishedBefore,
}),
refetchInterval,
});
@@ -460,6 +519,76 @@ export function WorkflowRunsTable({
{tenantMetricsQuery.isLoading && <Skeleton className="w-full h-36" />}
</DialogContent>
</Dialog>
<div className="flex flex-row justify-end items-center my-4 gap-2">
{customTimeRange && [
<Button
key="clear"
onClick={() => {
setCustomTimeRange(undefined);
}}
variant="outline"
size="sm"
className="text-xs h-9 py-2"
>
<XCircleIcon className="h-[18px] w-[18px] mr-2" />
Clear
</Button>,
<DateTimePicker
key="after"
label="After"
date={createdAfter ? new Date(createdAfter) : undefined}
setDate={(date) => {
setCreatedAfter(date?.toISOString());
}}
/>,
<DateTimePicker
key="before"
label="Before"
date={finishedBefore ? new Date(finishedBefore) : undefined}
setDate={(date) => {
setFinishedBefore(date?.toISOString());
}}
/>,
]}
<Select
value={customTimeRange ? 'custom' : defaultTimeRange}
onValueChange={(value) => {
if (value !== 'custom') {
setDefaultTimeRange(value);
setCustomTimeRange(undefined);
} else {
setCustomTimeRange([
getCreatedAfterFromTimeRange(value) ||
new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
new Date().toISOString(),
]);
}
}}
>
<SelectTrigger className="w-fit">
<SelectValue id="timerange" placeholder="Choose time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">1 hour</SelectItem>
<SelectItem value="6h">6 hours</SelectItem>
<SelectItem value="1d">1 day</SelectItem>
<SelectItem value="7d">7 days</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
{cloudMeta && cloudMeta.data?.metricsEnabled && (
<GetWorkflowChart
tenantId={tenant.metadata.id}
createdAfter={createdAfter}
zoom={(createdAfter, createdBefore) => {
setCustomTimeRange([createdAfter, createdBefore]);
}}
finishedBefore={finishedBefore}
refetchInterval={refetchInterval}
/>
)}
<div className="flex flex-row justify-between items-center my-4">
{metricsQuery.data ? (
<WorkflowRunsMetricsView
@@ -496,17 +625,6 @@ export function WorkflowRunsTable({
) : (
<Skeleton className="max-w-[800px] w-[40vw] h-8" />
)}
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-fit">
<SelectValue id="timerange" placeholder="Choose time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">1 hour</SelectItem>
<SelectItem value="6h">6 hours</SelectItem>
<SelectItem value="1d">1 day</SelectItem>
<SelectItem value="7d">7 days</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
emptyState={<>No workflow runs found with the given filters.</>}
@@ -533,3 +651,55 @@ export function WorkflowRunsTable({
</>
);
}
const GetWorkflowChart = ({
tenantId,
createdAfter,
finishedBefore,
refetchInterval,
zoom,
}: {
tenantId: string;
createdAfter?: string;
finishedBefore?: string;
refetchInterval?: number;
zoom: (startTime: string, endTime: string) => void;
}) => {
const workflowRunEventsMetricsQuery = useQuery({
...queries.cloud.workflowRunMetrics(tenantId, {
createdAfter,
finishedBefore,
}),
refetchInterval,
});
if (workflowRunEventsMetricsQuery.isLoading) {
return <Skeleton className="w-full h-36" />;
}
if (!workflowRunEventsMetricsQuery.data) {
return null;
}
return (
<div className="">
<ZoomableChart
data={
workflowRunEventsMetricsQuery.data?.results?.map(
(result): DataPoint<'SUCCEEDED' | 'FAILED'> => ({
date: result.time,
SUCCEEDED: result.SUCCEEDED,
FAILED: result.FAILED,
}),
) || []
}
colors={{
SUCCEEDED: 'rgb(34 197 94 / 0.5)',
FAILED: 'hsl(var(--destructive))',
}}
zoom={zoom}
showYAxis={false}
/>
</div>
);
};

View File

@@ -1466,6 +1466,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"`
@@ -6062,6 +6068,38 @@ func NewWorkflowRunListRequest(server string, tenant openapi_types.UUID, params
}
if params.FinishedAfter != nil {
if queryFrag, err := runtime.StyleParamWithLocation("form", true, "finishedAfter", runtime.ParamLocationQuery, *params.FinishedAfter); err != nil {
return nil, err
} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
return nil, err
} else {
for k, v := range parsed {
for _, v2 := range v {
queryValues.Add(k, v2)
}
}
}
}
if params.FinishedBefore != nil {
if queryFrag, err := runtime.StyleParamWithLocation("form", true, "finishedBefore", runtime.ParamLocationQuery, *params.FinishedBefore); err != nil {
return nil, err
} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
return nil, err
} else {
for k, v := range parsed {
for _, v2 := range v {
queryValues.Add(k, v2)
}
}
}
}
if params.OrderByField != nil {
if queryFrag, err := runtime.StyleParamWithLocation("form", true, "orderByField", runtime.ParamLocationQuery, *params.OrderByField); err != nil {

View File

@@ -5,6 +5,8 @@ import (
"github.com/hatchet-dev/hatchet/pkg/repository/prisma/db"
"github.com/hatchet-dev/hatchet/pkg/repository/prisma/dbsqlc"
"github.com/jackc/pgx/v5/pgtype"
)
type UpdateJobRunLookupDataOpts struct {
@@ -25,6 +27,8 @@ func JobRunStatusPtr(status db.JobRunStatus) *db.JobRunStatus {
}
type JobRunAPIRepository interface {
RegisterWorkflowRunRunningCallback(callback Callback[pgtype.UUID])
// SetJobRunStatusRunning resets the status of a job run to a RUNNING status. This is useful if a step
// run is being manually replayed, but shouldn't be used by most callers.
SetJobRunStatusRunning(tenantId, jobRunId string) error
@@ -33,6 +37,8 @@ type JobRunAPIRepository interface {
}
type JobRunEngineRepository interface {
RegisterWorkflowRunRunningCallback(callback Callback[pgtype.UUID])
// SetJobRunStatusRunning resets the status of a job run to a RUNNING status. This is useful if a step
// run is being manually replayed, but shouldn't be used by most callers.
SetJobRunStatusRunning(ctx context.Context, tenantId, jobRunId string) error

View File

@@ -1,6 +1,6 @@
-- name: CountWorkflowRuns :one
WITH runs AS (
SELECT runs."id", runs."createdAt"
SELECT runs."id", runs."createdAt", runs."finishedAt", runs."startedAt", runs."duration"
FROM
"WorkflowRun" as runs
LEFT JOIN
@@ -69,11 +69,22 @@ WITH runs AS (
) AND
(
sqlc.narg('finishedAfter')::timestamp IS NULL OR
runs."finishedAt" > sqlc.narg('finishedAfter')::timestamp
runs."finishedAt" > sqlc.narg('finishedAfter')::timestamp OR
runs."finishedAt" IS NULL
) AND
(
sqlc.narg('finishedBefore')::timestamp IS NULL OR
runs."finishedAt" <= sqlc.narg('finishedBefore')::timestamp
)
ORDER BY
case when @orderBy = 'createdAt ASC' THEN runs."createdAt" END ASC ,
case when @orderBy = 'createdAt DESC' then runs."createdAt" END DESC,
case when @orderBy = 'createdAt DESC' THEN runs."createdAt" END DESC,
case when @orderBy = 'finishedAt ASC' THEN runs."finishedAt" END ASC ,
case when @orderBy = 'finishedAt DESC' THEN runs."finishedAt" END DESC,
case when @orderBy = 'startedAt ASC' THEN runs."startedAt" END ASC ,
case when @orderBy = 'startedAt DESC' THEN runs."startedAt" END DESC,
case when @orderBy = 'duration ASC' THEN runs."duration" END ASC NULLS FIRST,
case when @orderBy = 'duration DESC' THEN runs."duration" END DESC NULLS LAST,
runs."id" ASC
LIMIT 10000
)
@@ -214,7 +225,12 @@ WHERE
) AND
(
sqlc.narg('finishedAfter')::timestamp IS NULL OR
runs."finishedAt" > sqlc.narg('finishedAfter')::timestamp
runs."finishedAt" > sqlc.narg('finishedAfter')::timestamp OR
runs."finishedAt" IS NULL
) AND
(
sqlc.narg('finishedBefore')::timestamp IS NULL OR
runs."finishedAt" <= sqlc.narg('finishedBefore')::timestamp
)
ORDER BY
case when @orderBy = 'createdAt ASC' THEN runs."createdAt" END ASC ,
@@ -424,10 +440,10 @@ WITH jobRuns AS (
WHERE
wr."id" = j."workflowRunId"
AND "tenantId" = @tenantId::uuid
RETURNING wr."id", wr."status"
RETURNING wr."id", wr."status", wr."tenantId"
)
-- Return distinct workflow run ids in a final state
SELECT DISTINCT "id", "status"
SELECT DISTINCT "id", "status", "tenantId"
FROM updated_workflow_runs
WHERE "status" IN ('SUCCEEDED', 'FAILED');

View File

@@ -92,7 +92,7 @@ func (q *Queries) BulkCreateWorkflowRunEvent(ctx context.Context, db DBTX, arg B
const countWorkflowRuns = `-- name: CountWorkflowRuns :one
WITH runs AS (
SELECT runs."id", runs."createdAt"
SELECT runs."id", runs."createdAt", runs."finishedAt", runs."startedAt", runs."duration"
FROM
"WorkflowRun" as runs
LEFT JOIN
@@ -161,11 +161,22 @@ WITH runs AS (
) AND
(
$14::timestamp IS NULL OR
runs."finishedAt" > $14::timestamp
runs."finishedAt" > $14::timestamp OR
runs."finishedAt" IS NULL
) AND
(
$15::timestamp IS NULL OR
runs."finishedAt" <= $15::timestamp
)
ORDER BY
case when $15 = 'createdAt ASC' THEN runs."createdAt" END ASC ,
case when $15 = 'createdAt DESC' then runs."createdAt" END DESC,
case when $16 = 'createdAt ASC' THEN runs."createdAt" END ASC ,
case when $16 = 'createdAt DESC' THEN runs."createdAt" END DESC,
case when $16 = 'finishedAt ASC' THEN runs."finishedAt" END ASC ,
case when $16 = 'finishedAt DESC' THEN runs."finishedAt" END DESC,
case when $16 = 'startedAt ASC' THEN runs."startedAt" END ASC ,
case when $16 = 'startedAt DESC' THEN runs."startedAt" END DESC,
case when $16 = 'duration ASC' THEN runs."duration" END ASC NULLS FIRST,
case when $16 = 'duration DESC' THEN runs."duration" END DESC NULLS LAST,
runs."id" ASC
LIMIT 10000
)
@@ -190,6 +201,7 @@ type CountWorkflowRunsParams struct {
CreatedAfter pgtype.Timestamp `json:"createdAfter"`
CreatedBefore pgtype.Timestamp `json:"createdBefore"`
FinishedAfter pgtype.Timestamp `json:"finishedAfter"`
FinishedBefore pgtype.Timestamp `json:"finishedBefore"`
Orderby interface{} `json:"orderby"`
}
@@ -209,6 +221,7 @@ func (q *Queries) CountWorkflowRuns(ctx context.Context, db DBTX, arg CountWorkf
arg.CreatedAfter,
arg.CreatedBefore,
arg.FinishedAfter,
arg.FinishedBefore,
arg.Orderby,
)
var total int64
@@ -1578,22 +1591,27 @@ WHERE
) AND
(
$14::timestamp IS NULL OR
runs."finishedAt" > $14::timestamp
runs."finishedAt" > $14::timestamp OR
runs."finishedAt" IS NULL
) AND
(
$15::timestamp IS NULL OR
runs."finishedAt" <= $15::timestamp
)
ORDER BY
case when $15 = 'createdAt ASC' THEN runs."createdAt" END ASC ,
case when $15 = 'createdAt DESC' THEN runs."createdAt" END DESC,
case when $15 = 'finishedAt ASC' THEN runs."finishedAt" END ASC ,
case when $15 = 'finishedAt DESC' THEN runs."finishedAt" END DESC,
case when $15 = 'startedAt ASC' THEN runs."startedAt" END ASC ,
case when $15 = 'startedAt DESC' THEN runs."startedAt" END DESC,
case when $15 = 'duration ASC' THEN runs."duration" END ASC NULLS FIRST,
case when $15 = 'duration DESC' THEN runs."duration" END DESC NULLS LAST,
case when $16 = 'createdAt ASC' THEN runs."createdAt" END ASC ,
case when $16 = 'createdAt DESC' THEN runs."createdAt" END DESC,
case when $16 = 'finishedAt ASC' THEN runs."finishedAt" END ASC ,
case when $16 = 'finishedAt DESC' THEN runs."finishedAt" END DESC,
case when $16 = 'startedAt ASC' THEN runs."startedAt" END ASC ,
case when $16 = 'startedAt DESC' THEN runs."startedAt" END DESC,
case when $16 = 'duration ASC' THEN runs."duration" END ASC NULLS FIRST,
case when $16 = 'duration DESC' THEN runs."duration" END DESC NULLS LAST,
runs."id" ASC
OFFSET
COALESCE($16, 0)
COALESCE($17, 0)
LIMIT
COALESCE($17, 50)
COALESCE($18, 50)
`
type ListWorkflowRunsParams struct {
@@ -1611,6 +1629,7 @@ type ListWorkflowRunsParams struct {
CreatedAfter pgtype.Timestamp `json:"createdAfter"`
CreatedBefore pgtype.Timestamp `json:"createdBefore"`
FinishedAfter pgtype.Timestamp `json:"finishedAfter"`
FinishedBefore pgtype.Timestamp `json:"finishedBefore"`
Orderby interface{} `json:"orderby"`
Offset interface{} `json:"offset"`
Limit interface{} `json:"limit"`
@@ -1643,6 +1662,7 @@ func (q *Queries) ListWorkflowRuns(ctx context.Context, db DBTX, arg ListWorkflo
arg.CreatedAfter,
arg.CreatedBefore,
arg.FinishedAfter,
arg.FinishedBefore,
arg.Orderby,
arg.Offset,
arg.Limit,
@@ -1939,9 +1959,9 @@ WITH jobRuns AS (
WHERE
wr."id" = j."workflowRunId"
AND "tenantId" = $2::uuid
RETURNING wr."id", wr."status"
RETURNING wr."id", wr."status", wr."tenantId"
)
SELECT DISTINCT "id", "status"
SELECT DISTINCT "id", "status", "tenantId"
FROM updated_workflow_runs
WHERE "status" IN ('SUCCEEDED', 'FAILED')
`
@@ -1952,8 +1972,9 @@ type ResolveWorkflowRunStatusParams struct {
}
type ResolveWorkflowRunStatusRow struct {
ID pgtype.UUID `json:"id"`
Status WorkflowRunStatus `json:"status"`
ID pgtype.UUID `json:"id"`
Status WorkflowRunStatus `json:"status"`
TenantId pgtype.UUID `json:"tenantId"`
}
// Return distinct workflow run ids in a final state
@@ -1966,7 +1987,7 @@ func (q *Queries) ResolveWorkflowRunStatus(ctx context.Context, db DBTX, arg Res
var items []*ResolveWorkflowRunStatusRow
for rows.Next() {
var i ResolveWorkflowRunStatusRow
if err := rows.Scan(&i.ID, &i.Status); err != nil {
if err := rows.Scan(&i.ID, &i.Status, &i.TenantId); err != nil {
return nil, err
}
items = append(items, &i)

View File

@@ -276,7 +276,7 @@ func (r *eventEngineRepository) CreateEvent(ctx context.Context, opts *repositor
}
for _, cb := range r.callbacks {
cb.Do(e) // nolint: errcheck
cb.Do(r.l, opts.TenantId, e)
}
id := sqlchelpers.UUIDToStr(e.ID)
@@ -354,10 +354,7 @@ func (r *eventEngineRepository) BulkCreateEvent(ctx context.Context, opts *repos
for _, e := range events {
for _, cb := range r.callbacks {
err = cb.Do(e)
if err != nil {
return nil, nil, fmt.Errorf("could not execute callback: %w", err)
}
cb.Do(r.l, opts.TenantId, e)
}
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
@@ -21,6 +22,8 @@ type jobRunAPIRepository struct {
v validator.Validator
queries *dbsqlc.Queries
l *zerolog.Logger
wrRunningCallbacks []repository.Callback[pgtype.UUID]
}
func NewJobRunAPIRepository(client *db.PrismaClient, pool *pgxpool.Pool, v validator.Validator, l *zerolog.Logger) repository.JobRunAPIRepository {
@@ -35,8 +38,26 @@ func NewJobRunAPIRepository(client *db.PrismaClient, pool *pgxpool.Pool, v valid
}
}
func (j *jobRunAPIRepository) RegisterWorkflowRunRunningCallback(callback repository.Callback[pgtype.UUID]) {
if j.wrRunningCallbacks == nil {
j.wrRunningCallbacks = make([]repository.Callback[pgtype.UUID], 0)
}
j.wrRunningCallbacks = append(j.wrRunningCallbacks, callback)
}
func (j *jobRunAPIRepository) SetJobRunStatusRunning(tenantId, jobRunId string) error {
return setJobRunStatusRunning(context.Background(), j.pool, j.queries, j.l, tenantId, jobRunId)
wrId, err := setJobRunStatusRunning(context.Background(), j.pool, j.queries, j.l, tenantId, jobRunId)
if err != nil {
return err
}
for _, cb := range j.wrRunningCallbacks {
cb.Do(j.l, tenantId, *wrId)
}
return nil
}
func (j *jobRunAPIRepository) ListJobRunByWorkflowRunId(ctx context.Context, tenantId, workflowRunId string) ([]*dbsqlc.ListJobRunsForWorkflowRunFullRow, error) {
@@ -53,6 +74,8 @@ type jobRunEngineRepository struct {
v validator.Validator
queries *dbsqlc.Queries
l *zerolog.Logger
wrRunningCallbacks []repository.Callback[pgtype.UUID]
}
func NewJobRunEngineRepository(pool *pgxpool.Pool, v validator.Validator, l *zerolog.Logger) repository.JobRunEngineRepository {
@@ -66,8 +89,26 @@ func NewJobRunEngineRepository(pool *pgxpool.Pool, v validator.Validator, l *zer
}
}
func (j *jobRunEngineRepository) RegisterWorkflowRunRunningCallback(callback repository.Callback[pgtype.UUID]) {
if j.wrRunningCallbacks == nil {
j.wrRunningCallbacks = make([]repository.Callback[pgtype.UUID], 0)
}
j.wrRunningCallbacks = append(j.wrRunningCallbacks, callback)
}
func (j *jobRunEngineRepository) SetJobRunStatusRunning(ctx context.Context, tenantId, jobRunId string) error {
return setJobRunStatusRunning(ctx, j.pool, j.queries, j.l, tenantId, jobRunId)
wrId, err := setJobRunStatusRunning(ctx, j.pool, j.queries, j.l, tenantId, jobRunId)
if err != nil {
return err
}
for _, cb := range j.wrRunningCallbacks {
cb.Do(j.l, tenantId, *wrId)
}
return nil
}
func (j *jobRunEngineRepository) ListJobRunsForWorkflowRun(ctx context.Context, tenantId, workflowRunId string) ([]*dbsqlc.ListJobRunsForWorkflowRunRow, error) {
@@ -82,11 +123,11 @@ func (j *jobRunEngineRepository) GetJobRunByWorkflowRunIdAndJobId(ctx context.Co
})
}
func setJobRunStatusRunning(ctx context.Context, pool *pgxpool.Pool, queries *dbsqlc.Queries, l *zerolog.Logger, tenantId, jobRunId string) error {
func setJobRunStatusRunning(ctx context.Context, pool *pgxpool.Pool, queries *dbsqlc.Queries, l *zerolog.Logger, tenantId, jobRunId string) (*pgtype.UUID, error) {
tx, err := pool.Begin(ctx)
if err != nil {
return err
return nil, err
}
defer deferRollback(context.Background(), l, tx.Rollback)
@@ -98,10 +139,10 @@ func setJobRunStatusRunning(ctx context.Context, pool *pgxpool.Pool, queries *db
})
if err != nil {
return err
return nil, err
}
_, err = queries.UpdateWorkflowRun(
wr, err := queries.UpdateWorkflowRun(
context.Background(),
tx,
dbsqlc.UpdateWorkflowRunParams{
@@ -115,10 +156,14 @@ func setJobRunStatusRunning(ctx context.Context, pool *pgxpool.Pool, queries *db
)
if err != nil {
return err
return nil, err
}
return tx.Commit(context.Background())
if err := tx.Commit(context.Background()); err != nil {
return nil, err
}
return &wr.ID, nil
}
func (r *jobRunEngineRepository) ClearJobRunPayloadData(ctx context.Context, tenantId string) (bool, error) {

View File

@@ -261,6 +261,7 @@ type stepRunEngineRepository struct {
queries *dbsqlc.Queries
cf *server.ConfigFileRuntime
cachedMinQueuedIds sync.Map
callbacks []repository.Callback[*dbsqlc.ResolveWorkflowRunStatusRow]
}
func NewStepRunEngineRepository(pool *pgxpool.Pool, v validator.Validator, l *zerolog.Logger, cf *server.ConfigFileRuntime) repository.StepRunEngineRepository {
@@ -275,6 +276,14 @@ func NewStepRunEngineRepository(pool *pgxpool.Pool, v validator.Validator, l *ze
}
}
func (s *stepRunEngineRepository) RegisterWorkflowRunCompletedCallback(callback repository.Callback[*dbsqlc.ResolveWorkflowRunStatusRow]) {
if s.callbacks == nil {
s.callbacks = make([]repository.Callback[*dbsqlc.ResolveWorkflowRunStatusRow], 0)
}
s.callbacks = append(s.callbacks, callback)
}
func (s *stepRunEngineRepository) GetStepRunMetaForEngine(ctx context.Context, tenantId, stepRunId string) (*dbsqlc.GetStepRunMetaRow, error) {
return s.queries.GetStepRunMeta(ctx, s.pool, dbsqlc.GetStepRunMetaParams{
Steprunid: sqlchelpers.UUIDFromStr(stepRunId),
@@ -1783,6 +1792,13 @@ func (s *stepRunEngineRepository) ProcessStepRunUpdates(ctx context.Context, qlp
return emptyRes, fmt.Errorf("could not commit transaction: %w", err)
}
for _, cb := range s.callbacks {
for _, wr := range completedWorkflowRuns {
wrCp := wr
cb.Do(s.l, tenantId, wrCp)
}
}
defer printProcessStepRunUpdateInfo(ql, tenantId, startedAt, len(stepRunIds), durationUpdateStepRuns, durationResolveJobRunStatus, durationResolveWorkflowRuns, durationMarkQueueItemsProcessed, durationRunEvents)
return repository.ProcessStepRunUpdatesResult{

View File

@@ -33,7 +33,7 @@ type workflowRunAPIRepository struct {
l *zerolog.Logger
m *metered.Metered
callbacks []repository.Callback[*dbsqlc.WorkflowRun]
createCallbacks []repository.Callback[*dbsqlc.WorkflowRun]
}
func NewWorkflowRunRepository(client *db.PrismaClient, pool *pgxpool.Pool, v validator.Validator, l *zerolog.Logger, m *metered.Metered) repository.WorkflowRunAPIRepository {
@@ -50,11 +50,11 @@ func NewWorkflowRunRepository(client *db.PrismaClient, pool *pgxpool.Pool, v val
}
func (w *workflowRunAPIRepository) RegisterCreateCallback(callback repository.Callback[*dbsqlc.WorkflowRun]) {
if w.callbacks == nil {
w.callbacks = make([]repository.Callback[*dbsqlc.WorkflowRun], 0)
if w.createCallbacks == nil {
w.createCallbacks = make([]repository.Callback[*dbsqlc.WorkflowRun], 0)
}
w.callbacks = append(w.callbacks, callback)
w.createCallbacks = append(w.createCallbacks, callback)
}
func (w *workflowRunAPIRepository) ListWorkflowRuns(ctx context.Context, tenantId string, opts *repository.ListWorkflowRunsOpts) (*repository.ListWorkflowRunsResult, error) {
@@ -107,8 +107,8 @@ func (w *workflowRunAPIRepository) CreateNewWorkflowRun(ctx context.Context, ten
id := sqlchelpers.UUIDToStr(workflowRun.ID)
for _, cb := range w.callbacks {
cb.Do(workflowRun) // nolint: errcheck
for _, cb := range w.createCallbacks {
cb.Do(w.l, tenantId, workflowRun)
}
return &id, workflowRun, nil
@@ -303,28 +303,37 @@ type workflowRunEngineRepository struct {
l *zerolog.Logger
m *metered.Metered
callbacks []repository.Callback[*dbsqlc.WorkflowRun]
createCallbacks []repository.Callback[*dbsqlc.WorkflowRun]
queuedCallbacks []repository.Callback[pgtype.UUID]
}
func NewWorkflowRunEngineRepository(pool *pgxpool.Pool, v validator.Validator, l *zerolog.Logger, m *metered.Metered, cbs ...repository.Callback[*dbsqlc.WorkflowRun]) repository.WorkflowRunEngineRepository {
queries := dbsqlc.New()
return &workflowRunEngineRepository{
v: v,
pool: pool,
queries: queries,
l: l,
m: m,
callbacks: cbs,
v: v,
pool: pool,
queries: queries,
l: l,
m: m,
createCallbacks: cbs,
}
}
func (w *workflowRunEngineRepository) RegisterCreateCallback(callback repository.Callback[*dbsqlc.WorkflowRun]) {
if w.callbacks == nil {
w.callbacks = make([]repository.Callback[*dbsqlc.WorkflowRun], 0)
if w.createCallbacks == nil {
w.createCallbacks = make([]repository.Callback[*dbsqlc.WorkflowRun], 0)
}
w.callbacks = append(w.callbacks, callback)
w.createCallbacks = append(w.createCallbacks, callback)
}
func (w *workflowRunEngineRepository) RegisterQueuedCallback(callback repository.Callback[pgtype.UUID]) {
if w.queuedCallbacks == nil {
w.queuedCallbacks = make([]repository.Callback[pgtype.UUID], 0)
}
w.queuedCallbacks = append(w.queuedCallbacks, callback)
}
func (w *workflowRunEngineRepository) GetWorkflowRunById(ctx context.Context, tenantId, id string) (*dbsqlc.GetWorkflowRunRow, error) {
@@ -446,8 +455,8 @@ func (w *workflowRunEngineRepository) CreateNewWorkflowRun(ctx context.Context,
return "", err
}
for _, cb := range w.callbacks {
cb.Do(wfr) // nolint: errcheck
for _, cb := range w.createCallbacks {
cb.Do(w.l, tenantId, wfr)
}
id := sqlchelpers.UUIDToStr(wfr.ID)
@@ -658,6 +667,10 @@ func (s *workflowRunEngineRepository) UpdateWorkflowRunFromGroupKeyEval(ctx cont
return fmt.Errorf("could not update workflow run group key from expr: %w", err)
}
for _, cb := range s.queuedCallbacks {
cb.Do(s.l, tenantId, pgWorkflowRunId)
}
defer insertWorkflowRunQueueItem( // nolint: errcheck
ctx,
s.pool,
@@ -795,6 +808,11 @@ func listWorkflowRuns(ctx context.Context, pool *pgxpool.Pool, queries *dbsqlc.Q
queryParams.FinishedAfter = sqlchelpers.TimestampFromTime(*opts.FinishedAfter)
}
if opts.FinishedBefore != nil {
countParams.FinishedBefore = sqlchelpers.TimestampFromTime(*opts.FinishedBefore)
queryParams.FinishedBefore = sqlchelpers.TimestampFromTime(*opts.FinishedBefore)
}
orderByField := "createdAt"
if opts.OrderBy != nil {

View File

@@ -1,6 +1,8 @@
package repository
import "fmt"
import (
"github.com/rs/zerolog"
)
type APIRepository interface {
Health() HealthRepository
@@ -57,17 +59,23 @@ func StringPtr(s string) *string {
return &s
}
type Callback[T any] func(T) error
type Callback[T any] func(string, T) error
func (c Callback[T]) Do(v T) (err error) {
func (c Callback[T]) Do(l *zerolog.Logger, tenantId string, v T) {
// wrap in panic recover to avoid panics in the callback
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic in callback: %v", r)
if l != nil {
l.Error().Interface("panic", r).Msg("panic in callback")
}
}
}()
go c(v) // nolint: errcheck
go func() {
err := c(tenantId, v)
return err
if err != nil {
l.Error().Err(err).Msg("callback failed")
}
}()
}

View File

@@ -167,6 +167,8 @@ type ProcessStepRunUpdatesResult struct {
}
type StepRunEngineRepository interface {
RegisterWorkflowRunCompletedCallback(callback Callback[*dbsqlc.ResolveWorkflowRunStatusRow])
// ListStepRunsForWorkflowRun returns a list of step runs for a workflow run.
ListStepRuns(ctx context.Context, tenantId string, opts *ListStepRunsOpts) ([]*dbsqlc.GetStepRunForEngineRow, error)

View File

@@ -10,6 +10,8 @@ import (
"github.com/hatchet-dev/hatchet/pkg/repository/prisma/db"
"github.com/hatchet-dev/hatchet/pkg/repository/prisma/dbsqlc"
"github.com/hatchet-dev/hatchet/pkg/repository/prisma/sqlchelpers"
"github.com/jackc/pgx/v5/pgtype"
)
type CreateWorkflowRunOpts struct {
@@ -297,9 +299,12 @@ type ListWorkflowRunsOpts struct {
// (optional) a time before which the run was created
CreatedBefore *time.Time
// (optional) a time before which the run was finished
// (optional) a time after which the run was finished
FinishedAfter *time.Time
// (optional) a time before which the run was finished
FinishedBefore *time.Time
// (optional) exact metadata to filter by
AdditionalMetadata map[string]interface{} `validate:"omitempty"`
}
@@ -420,6 +425,7 @@ type UpdateWorkflowRunFromGroupKeyEvalOpts struct {
type WorkflowRunEngineRepository interface {
RegisterCreateCallback(callback Callback[*dbsqlc.WorkflowRun])
RegisterQueuedCallback(callback Callback[pgtype.UUID])
// ListWorkflowRuns returns workflow runs for a given workflow version id.
ListWorkflowRuns(ctx context.Context, tenantId string, opts *ListWorkflowRunsOpts) (*ListWorkflowRunsResult, error)