mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2026-03-13 13:38:53 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
129
frontend/app/pnpm-lock.yaml
generated
129
frontend/app/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
218
frontend/app/src/components/molecules/charts/zoomable.tsx
Normal file
218
frontend/app/src/components/molecules/charts/zoomable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
363
frontend/app/src/components/ui/chart.tsx
Normal file
363
frontend/app/src/components/ui/chart.tsx
Normal 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,
|
||||
};
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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={[]}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user