diff --git a/src/service/core/workflow/helpers.py b/src/service/core/workflow/helpers.py
index 23519004e..2fa42de0a 100644
--- a/src/service/core/workflow/helpers.py
+++ b/src/service/core/workflow/helpers.py
@@ -1,5 +1,5 @@
"""
-SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # pylint: disable=line-too-long
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -119,6 +119,8 @@ def get_tasks(workflow_id: str | None = None,
nodes: List[str] | None = None,
started_after: datetime.datetime | None = None,
started_before: datetime.datetime | None = None,
+ ended_after: datetime.datetime | None = None,
+ ended_before: datetime.datetime | None = None,
offset: int = 0,
limit: int = 20,
order: connectors.ListOrder = fastapi.Query(default=connectors.ListOrder.ASC),
@@ -181,6 +183,12 @@ def get_tasks(workflow_id: str | None = None,
if started_before:
commands.append('(tasks.start_time < %s AND tasks.start_time is not NULL)')
fetch_input.append(started_before.replace(microsecond=0).isoformat())
+ if ended_after:
+ commands.append('(tasks.end_time >= %s OR tasks.end_time IS NULL)')
+ fetch_input.append(ended_after.replace(microsecond=0).isoformat())
+ if ended_before:
+ commands.append('(tasks.end_time < %s AND tasks.end_time IS NOT NULL)')
+ fetch_input.append(ended_before.replace(microsecond=0).isoformat())
if priority:
commands.append('workflows.priority IN %s')
fetch_input.append(tuple(p.value for p in priority))
diff --git a/src/service/core/workflow/workflow_service.py b/src/service/core/workflow/workflow_service.py
index 747d87eac..51e743ebc 100644
--- a/src/service/core/workflow/workflow_service.py
+++ b/src/service/core/workflow/workflow_service.py
@@ -637,6 +637,8 @@ def list_task(workflow_id: str | None = None,
nodes: List[str] | None = fastapi.Query(default = None),
started_after: datetime.datetime | None = None,
started_before: datetime.datetime | None = None,
+ ended_after: datetime.datetime | None = None,
+ ended_before: datetime.datetime | None = None,
offset: int = 0,
limit: int = 20,
order: connectors.ListOrder = fastapi.Query(default=connectors.ListOrder.ASC),
@@ -665,8 +667,8 @@ def list_task(workflow_id: str | None = None,
if all_pools:
pools = []
rows = helpers.get_tasks(workflow_id, statuses, users, pools, nodes,
- started_after, started_before, offset, limit, order, summary,
- aggregate_by_workflow,
+ started_after, started_before, ended_after, ended_before,
+ offset, limit, order, summary, aggregate_by_workflow,
priority=priority, return_raw=True)
if summary:
return objects.ListTaskSummaryResponse.from_db_rows(rows)
diff --git a/src/ui/e2e/mocks/generated-mocks.ts b/src/ui/e2e/mocks/generated-mocks.ts
index 0fc09ad23..516616572 100644
--- a/src/ui/e2e/mocks/generated-mocks.ts
+++ b/src/ui/e2e/mocks/generated-mocks.ts
@@ -2111,6 +2111,8 @@ all_pools?: boolean;
nodes?: string[];
started_after?: string;
started_before?: string;
+ended_after?: string;
+ended_before?: string;
offset?: number;
limit?: number;
order?: ListOrder;
diff --git a/src/ui/openapi.json b/src/ui/openapi.json
index f9ffaf92c..df7633212 100644
--- a/src/ui/openapi.json
+++ b/src/ui/openapi.json
@@ -4985,6 +4985,26 @@
"name": "started_before",
"in": "query"
},
+ {
+ "required": false,
+ "schema": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Ended After"
+ },
+ "name": "ended_after",
+ "in": "query"
+ },
+ {
+ "required": false,
+ "schema": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Ended Before"
+ },
+ "name": "ended_before",
+ "in": "query"
+ },
{
"required": false,
"schema": {
diff --git a/src/ui/package.json b/src/ui/package.json
index 42f3a8c6c..33affe0c8 100644
--- a/src/ui/package.json
+++ b/src/ui/package.json
@@ -89,6 +89,7 @@
"react-dom": "19.2.3",
"react-error-boundary": "^6.0.1",
"react-hotkeys-hook": "^5.2.3",
+ "recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"usehooks-ts": "^3.1.1",
diff --git a/src/ui/pnpm-lock.yaml b/src/ui/pnpm-lock.yaml
index f4b8bd662..9920c0b06 100644
--- a/src/ui/pnpm-lock.yaml
+++ b/src/ui/pnpm-lock.yaml
@@ -179,6 +179,9 @@ importers:
react-hotkeys-hook:
specifier: ^5.2.3
version: 5.2.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ recharts:
+ specifier: ^2.15.4
+ version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -2083,18 +2086,39 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
+ '@types/d3-shape@3.1.8':
+ resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
@@ -2729,6 +2753,10 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
@@ -2745,14 +2773,38 @@ packages:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
+ d3-format@3.1.2:
+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+ engines: {node: '>=12'}
+
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
+ 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'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
@@ -2806,6 +2858,9 @@ packages:
supports-color:
optional: true
+ decimal.js-light@2.5.1:
+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -2831,6 +2886,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
+ dom-helpers@5.2.1:
+ resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
+
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -3054,6 +3112,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ eventemitter3@4.0.7:
+ resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
@@ -3065,6 +3126,10 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ fast-equals@5.4.0:
+ resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
+ engines: {node: '>=6.0.0'}
+
fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@@ -3350,6 +3415,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -3688,6 +3757,9 @@ packages:
lodash.uniq@4.5.0:
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
+ lodash@4.17.23:
+ resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -4299,6 +4371,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+ react-is@18.3.1:
+ resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@@ -4323,6 +4398,12 @@ packages:
'@types/react':
optional: true
+ react-smooth@4.0.4:
+ resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -4333,6 +4414,12 @@ packages:
'@types/react':
optional: true
+ react-transition-group@4.4.5:
+ resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
+ peerDependencies:
+ react: '>=16.6.0'
+ react-dom: '>=16.6.0'
+
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
@@ -4341,6 +4428,16 @@ packages:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
+ recharts-scale@0.4.5:
+ resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
+
+ recharts@2.15.4:
+ resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -4648,6 +4745,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -4843,6 +4943,9 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ victory-vendor@36.9.2:
+ resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
+
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -7013,18 +7116,36 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
+ '@types/d3-array@3.2.2': {}
+
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
+ '@types/d3-ease@3.0.2': {}
+
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
'@types/d3-selection@3.0.11': {}
+ '@types/d3-shape@3.1.8':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
@@ -7726,6 +7847,10 @@ snapshots:
csstype@3.2.3: {}
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
@@ -7737,12 +7862,36 @@ snapshots:
d3-ease@3.0.1: {}
+ d3-format@3.1.2: {}
+
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
+ d3-path@3.1.0: {}
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
d3-selection@3.0.0: {}
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
@@ -7797,6 +7946,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decimal.js-light@2.5.1: {}
+
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
@@ -7821,6 +7972,11 @@ snapshots:
dependencies:
esutils: 2.0.3
+ dom-helpers@5.2.1:
+ dependencies:
+ '@babel/runtime': 7.28.6
+ csstype: 3.2.3
+
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -8218,6 +8374,8 @@ snapshots:
esutils@2.0.3: {}
+ eventemitter3@4.0.7: {}
+
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@@ -8237,6 +8395,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
+ fast-equals@5.4.0: {}
+
fast-glob@3.3.1:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -8522,6 +8682,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
+ internmap@2.0.3: {}
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -8834,6 +8996,8 @@ snapshots:
lodash.uniq@4.5.0: {}
+ lodash@4.17.23: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -9428,6 +9592,8 @@ snapshots:
react-is@16.13.1: {}
+ react-is@18.3.1: {}
+
react-refresh@0.18.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.3):
@@ -9449,6 +9615,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
+ react-smooth@4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ fast-equals: 5.4.0
+ prop-types: 15.8.1
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ react-transition-group: 4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.3):
dependencies:
get-nonce: 1.0.1
@@ -9457,10 +9631,36 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
+ react-transition-group@4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ '@babel/runtime': 7.28.6
+ dom-helpers: 5.2.1
+ loose-envify: 1.4.0
+ prop-types: 15.8.1
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+
react@19.2.3: {}
readdirp@5.0.0: {}
+ recharts-scale@0.4.5:
+ dependencies:
+ decimal.js-light: 2.5.1
+
+ recharts@2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ clsx: 2.1.1
+ eventemitter3: 4.0.7
+ lodash: 4.17.23
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ react-is: 18.3.1
+ react-smooth: 4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ recharts-scale: 0.4.5
+ tiny-invariant: 1.3.3
+ victory-vendor: 36.9.2
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -9859,6 +10059,8 @@ snapshots:
tapable@2.3.0: {}
+ tiny-invariant@1.3.3: {}
+
tinybench@2.9.0: {}
tinyexec@1.0.2: {}
@@ -10068,6 +10270,23 @@ snapshots:
- '@types/react'
- '@types/react-dom'
+ victory-vendor@36.9.2:
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-ease': 3.0.2
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-scale': 4.0.9
+ '@types/d3-shape': 3.1.8
+ '@types/d3-time': 3.0.4
+ '@types/d3-timer': 3.0.2
+ d3-array: 3.2.4
+ 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@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2):
dependencies:
esbuild: 0.27.3
diff --git a/src/ui/react-doctor.config.json b/src/ui/react-doctor.config.json
new file mode 100644
index 000000000..17d747419
--- /dev/null
+++ b/src/ui/react-doctor.config.json
@@ -0,0 +1,5 @@
+{
+ "ignore": {
+ "files": ["src/components/shadcn/**"]
+ }
+}
diff --git a/src/ui/src/app/globals.css b/src/ui/src/app/globals.css
index 0e4cdc985..c5ed2ddb9 100644
--- a/src/ui/src/app/globals.css
+++ b/src/ui/src/app/globals.css
@@ -249,6 +249,12 @@
--sidebar-border: oklch(92.2% 0 0deg);
--sidebar-ring: oklch(70.8% 0 0deg);
+ /* Chart: utilization dashboard (NVIDIA-branded palette) */
+ --chart-gpu: #76b900;
+ --chart-cpu: oklch(60% 0.15 250deg);
+ --chart-memory: oklch(65% 0.15 300deg);
+ --chart-storage: oklch(75% 0.15 85deg);
+
/* Brand colors */
--nvidia-green: #76b900;
--nvidia-green-dark: #5a8c00;
@@ -368,6 +374,12 @@
--sidebar-border: oklch(100% 0 0deg / 10%);
--sidebar-ring: oklch(55.6% 0 0deg);
+ /* Chart: utilization dashboard (dark mode - adjusted lightness) */
+ --chart-gpu: #76b900;
+ --chart-cpu: oklch(70% 0.15 250deg);
+ --chart-memory: oklch(75% 0.15 300deg);
+ --chart-storage: oklch(80% 0.15 85deg);
+
/* Brand colors - same in dark mode except bg */
--nvidia-green: #76b900;
--nvidia-green-dark: #5a8c00;
diff --git a/src/ui/src/components/date-range-picker/date-range-picker.css b/src/ui/src/components/date-range-picker/date-range-picker.css
new file mode 100644
index 000000000..28ebad47f
--- /dev/null
+++ b/src/ui/src/components/date-range-picker/date-range-picker.css
@@ -0,0 +1,279 @@
+/**
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * DateRangePicker component styles.
+ * "Split Rail" layout: left preset list with active indicator, right custom range inputs.
+ *
+ * IMPORTANT: Color variables in globals.css store complete oklch() values
+ * (e.g., --primary: oklch(0.205 0 0)). Use var(--primary) directly -- do NOT
+ * wrap in hsl(). For opacity variants, use color-mix(in oklch, ..., transparent).
+ */
+@reference "../../app/globals.css";
+
+/* =============================================================================
+ CSS Custom Properties (Single Source of Truth)
+ ============================================================================= */
+
+:root {
+ /* Accent colors for interactive elements (matches FilterBar blue accent) */
+ --drp-accent: var(--search-accent);
+ --drp-accent-dark: rgb(29 78 216); /* blue-700 */
+
+ /* Column sizing */
+ --drp-right-min: 185px;
+ --drp-left-max: 270px;
+}
+
+:is(.dark *) {
+ --drp-accent: var(--search-accent);
+ --drp-accent-dark: rgb(147 197 253); /* blue-300 */
+}
+
+/* =============================================================================
+ Date Picker Panel — "Split Rail" layout
+ Left: preset list (label + right-aligned date hint) with active border indicator.
+ Right: stacked From/To inputs + Apply.
+ ============================================================================= */
+
+.fb-date-picker {
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+/*
+ Right column minimum:
+ datetime-local input content ≈ 160px (mm/dd/yyyy, --:-- -- at 0.75rem + calendar icon)
+ .fb-date-custom h-padding = 24px (0.75rem × 2)
+ → --drp-right-min = 184px (rounded up to 185px)
+
+ Left rail maximum (cap so extra space flows to the right column):
+ Row left-padding = 14px (0.875rem)
+ Longest label "last 365 days" ≈ 100px
+ Minimum gap (padding-left) = 16px (1rem, enforced on hint)
+ Longest hint "MMM DD 'YY – MMM DD" ≈ 130px (19 chars × ~6.6px/ch at 0.6875rem monospace)
+ Row right-padding = 10px (0.625rem)
+ ─────────────────────────────────────────────────────
+ Total ≈ 270px → --drp-left-max: 270px
+
+ Grid: left capped at --drp-left-max, right gets all remaining space (min --drp-right-min).
+
+ Hint hide threshold (= max content without right-padding):
+ 14px + 100px + 16px + 130px = 260px
+ Container query hides hints at rail < 260px so they are always fully shown or fully hidden.
+*/
+.fb-date-split {
+ display: grid;
+ grid-template-columns: minmax(0, var(--drp-left-max)) minmax(var(--drp-right-min), 1fr);
+ min-height: 12rem;
+}
+
+/* Left rail */
+.fb-date-rail {
+ border-right: 1px solid var(--border);
+ padding: 0.5rem 0;
+ display: flex;
+ flex-direction: column;
+ container-type: inline-size;
+ container-name: date-rail;
+}
+
+.fb-date-section-label {
+ padding: 0 0.75rem 0.375rem;
+ font-size: 0.6875rem;
+ line-height: 1rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted-foreground);
+ pointer-events: none;
+ user-select: none;
+ opacity: 0.55;
+}
+
+.fb-date-preset-row {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.4375rem 0.625rem 0.4375rem 0.875rem;
+ font-size: 0.8125rem;
+ line-height: 1.25rem;
+ color: var(--drp-accent);
+ cursor: pointer;
+ text-align: left;
+ border-left: 2px solid transparent;
+ transition:
+ background-color 150ms,
+ color 150ms,
+ border-color 150ms;
+}
+
+.fb-date-preset-row:hover {
+ background-color: var(--accent);
+}
+
+.fb-date-preset-row[data-active] {
+ border-left-color: var(--drp-accent);
+ background-color: color-mix(in oklch, var(--drp-accent) 10%, transparent);
+ color: var(--drp-accent);
+ font-weight: 500;
+ padding-left: 0.75rem; /* compensate for 2px border so text stays aligned */
+}
+
+.fb-date-preset-row:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: -2px;
+}
+
+/* Label: never wraps or shrinks — always takes its natural width */
+.fb-date-preset-label {
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+/* Hint: right-aligned, minimum gap from label via padding-left.
+ Collapses to nothing when there is no room (min-width: 0 + overflow: hidden).
+ Fully hidden via container query before any clipping can occur. */
+.fb-date-preset-hint {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-align: right;
+ /* padding-left enforces the minimum visual gap between label and hint */
+ padding-left: 1rem;
+ font-size: 0.6875rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ color: var(--foreground);
+ opacity: 0.5;
+}
+
+.fb-date-preset-row[data-active] .fb-date-preset-hint {
+ opacity: 0.7;
+ color: currentColor;
+}
+
+/* Hide hints when the rail is narrower than the space needed to show them fully.
+ Threshold = row-padding + longest-label + min-gap + longest-hint = 14+100+16+130 = 260px.
+ This guarantees hints are always fully visible or completely absent. */
+@container date-rail (max-width: 259px) {
+ .fb-date-preset-hint {
+ display: none;
+ }
+}
+
+/* Right column: custom range */
+.fb-date-custom {
+ padding: 0.5rem 0.75rem 0.625rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.fb-date-custom .fb-date-section-label {
+ padding-left: 0;
+ padding-right: 0;
+}
+
+.fb-date-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.fb-date-label {
+ font-size: 0.6875rem;
+ line-height: 1rem;
+ color: var(--foreground);
+ opacity: 0.7;
+}
+
+.fb-date-input {
+ width: 100%;
+ border-radius: 0.375rem;
+ border: 1px solid var(--border);
+ background-color: var(--background);
+ color: var(--foreground);
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ line-height: 1.25rem;
+ transition: border-color 150ms;
+}
+
+.fb-date-input:focus {
+ outline: none;
+ border-color: var(--ring);
+ box-shadow: 0 0 0 1px var(--ring);
+}
+
+.fb-date-input[data-error] {
+ border-color: var(--destructive);
+ box-shadow: 0 0 0 1px var(--destructive);
+}
+
+.fb-date-error {
+ font-size: 0.6875rem;
+ line-height: 1rem;
+ color: var(--destructive);
+}
+
+.fb-date-input::-webkit-calendar-picker-indicator {
+ opacity: 0.45;
+ cursor: pointer;
+}
+
+.fb-date-input::-webkit-calendar-picker-indicator:hover {
+ opacity: 1;
+}
+
+.fb-date-apply {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ border-radius: 0.375rem;
+ border: 1px solid var(--border);
+ background-color: var(--secondary);
+ color: var(--drp-accent);
+ padding: 0.375rem 0.75rem;
+ font-size: 0.8125rem;
+ line-height: 1.25rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ background-color 150ms,
+ color 150ms;
+ width: 100%;
+ margin-top: auto;
+}
+
+.fb-date-apply:hover:not(:disabled) {
+ background-color: var(--accent);
+ color: var(--accent-foreground);
+}
+
+.fb-date-apply:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.fb-date-apply:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
diff --git a/src/ui/src/components/date-range-picker/date-range-picker.tsx b/src/ui/src/components/date-range-picker/date-range-picker.tsx
new file mode 100644
index 000000000..a78f0632d
--- /dev/null
+++ b/src/ui/src/components/date-range-picker/date-range-picker.tsx
@@ -0,0 +1,186 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+"use client";
+
+import { useState, useCallback, useMemo, memo } from "react";
+import { MONTHS_SHORT } from "@/lib/format-date";
+import "@/components/date-range-picker/date-range-picker.css";
+
+export interface DateRangePresetItem {
+ label: string;
+ getValue: () => string;
+}
+
+export interface DateRangePickerResult {
+ /** Either a preset label, a single ISO datetime, or a "from..to" range */
+ value: string;
+ kind: "preset" | "custom";
+}
+
+interface DateRangePickerProps {
+ presets?: DateRangePresetItem[];
+ activePresetLabel?: string;
+ /** Pre-fill the "From" input (datetime-local format: "YYYY-MM-DDTHH:MM") */
+ initialFrom?: string;
+ /** Pre-fill the "To" input (datetime-local format: "YYYY-MM-DDTHH:MM") */
+ initialTo?: string;
+ onCommit: (result: DateRangePickerResult) => void;
+}
+
+function fmtUtcDate(isoDate: string, currentYear: number): string {
+ const d = new Date(`${isoDate}T00:00:00Z`);
+ const mon = MONTHS_SHORT[d.getUTCMonth()];
+ const day = d.getUTCDate();
+ const year = d.getUTCFullYear();
+ return year !== currentYear ? `${mon} ${day} '${String(year).slice(2)}` : `${mon} ${day}`;
+}
+
+function buildPresetHint(rawValue: string, currentYear: number): string {
+ if (rawValue.includes("..")) {
+ const sep = rawValue.indexOf("..");
+ return `${fmtUtcDate(rawValue.slice(0, sep), currentYear)} – ${fmtUtcDate(rawValue.slice(sep + 2), currentYear)}`;
+ }
+ return fmtUtcDate(rawValue, currentYear);
+}
+
+export const DateRangePicker = memo(function DateRangePicker({
+ presets,
+ activePresetLabel,
+ initialFrom,
+ initialTo,
+ onCommit,
+}: DateRangePickerProps) {
+ const [fromDate, setFromDate] = useState(initialFrom ?? "");
+ const [toDate, setToDate] = useState(initialTo ?? "");
+
+ const hasPresets = presets != null && presets.length > 0;
+
+ const currentYear = useMemo(() => new Date().getUTCFullYear(), []);
+
+ const presetHints = useMemo(
+ () =>
+ hasPresets ? Object.fromEntries(presets.map((p) => [p.label, buildPresetHint(p.getValue(), currentYear)])) : {},
+ [presets, currentYear, hasPresets],
+ );
+
+ const rangeError = !!fromDate && !!toDate && toDate <= fromDate;
+
+ const handleApply = useCallback(() => {
+ if (!fromDate || rangeError) return;
+ const value = toDate ? `${fromDate}..${toDate}` : fromDate;
+ onCommit({ value, kind: "custom" });
+ }, [fromDate, toDate, rangeError, onCommit]);
+
+ const handleFromChange = useCallback((value: string) => {
+ setFromDate(value);
+ setToDate((prev) => (prev && prev <= value ? "" : prev));
+ }, []);
+
+ const customRangePanel = (
+
+ {hasPresets &&
Custom range
}
+
+
+ handleFromChange(e.target.value)}
+ className="fb-date-input"
+ />
+
+
+
+ setToDate(e.target.value)}
+ min={fromDate || undefined}
+ className="fb-date-input"
+ data-error={rangeError ? "" : undefined}
+ aria-invalid={rangeError}
+ aria-describedby={rangeError ? "drp-range-error" : undefined}
+ />
+ {rangeError && (
+
+ “To” must be after “From”
+
+ )}
+
+
+
+ );
+
+ if (!hasPresets) {
+ return (
+
+ {customRangePanel}
+
+ );
+ }
+
+ return (
+
+
+
+
Presets
+ {presets.map((preset) => (
+
+ ))}
+
+ {customRangePanel}
+
+
+ );
+});
diff --git a/src/ui/src/components/filter-bar/filter-bar-date-picker.tsx b/src/ui/src/components/filter-bar/filter-bar-date-picker.tsx
index 4ded5b56c..28243f8eb 100644
--- a/src/ui/src/components/filter-bar/filter-bar-date-picker.tsx
+++ b/src/ui/src/components/filter-bar/filter-bar-date-picker.tsx
@@ -15,22 +15,17 @@
// SPDX-License-Identifier: Apache-2.0
/**
- * FilterBarDatePicker - Date/range picker panel rendered inside the FilterBar dropdown.
- *
- * B1 "Split Rail" layout: preset list on the left with active indicator,
- * stacked From/To date inputs on the right.
- *
- * Selecting a preset or applying custom dates calls onCommit(value) where value
- * is either a preset label ("last 7 days"), a single ISO date ("2026-03-11"),
- * or an ISO range ("2026-03-01..2026-03-11").
+ * FilterBarDatePicker - Thin wrapper around DateRangePicker that adds
+ * FilterBar-specific keyboard cycling, focus sentinels, and the
+ * onCommit(string) interface expected by the filter bar.
*/
"use client";
-import { useState, useCallback, useMemo, memo, useRef, useEffect } from "react";
+import { memo, useRef, useEffect, useCallback } from "react";
import { DATE_RANGE_PRESETS } from "@/lib/date-range-utils";
import { DATE_CUSTOM_FROM, DATE_CUSTOM_TO, DATE_CUSTOM_APPLY } from "@/components/filter-bar/lib/types";
-import { MONTHS_SHORT } from "@/lib/format-date";
+import { DateRangePicker, type DateRangePickerResult } from "@/components/date-range-picker/date-range-picker";
interface FilterBarDatePickerProps {
/** Called when a date or range is committed. Value is preset label, ISO date, or ISO range. */
@@ -43,182 +38,62 @@ interface FilterBarDatePickerProps {
onClose?: () => void;
}
-/** Format a UTC YYYY-MM-DD string as "Mar 4" or "Mar 4 '25" (if year differs from currentYear). */
-function fmtUtcDate(isoDate: string, currentYear: number): string {
- const d = new Date(`${isoDate}T00:00:00Z`);
- const mon = MONTHS_SHORT[d.getUTCMonth()];
- const day = d.getUTCDate();
- const year = d.getUTCFullYear();
- return year !== currentYear ? `${mon} ${day} '${String(year).slice(2)}` : `${mon} ${day}`;
-}
-
-/**
- * Build hint text from the raw preset value (before next-midnight adjustment).
- * Single date → "Mar 11"; range → "Mar 4 – Mar 11".
- */
-function buildPresetHint(rawValue: string, currentYear: number): string {
- if (rawValue.includes("..")) {
- const sep = rawValue.indexOf("..");
- return `${fmtUtcDate(rawValue.slice(0, sep), currentYear)} – ${fmtUtcDate(rawValue.slice(sep + 2), currentYear)}`;
- }
- return fmtUtcDate(rawValue, currentYear);
-}
-
export const FilterBarDatePicker = memo(function FilterBarDatePicker({
onCommit,
highlightedLabel,
onCycleStep,
onClose,
}: FilterBarDatePickerProps) {
- const [fromDate, setFromDate] = useState("");
- const [toDate, setToDate] = useState("");
+ const containerRef = useRef(null);
- const fromRef = useRef(null);
- const toRef = useRef(null);
- const applyRef = useRef(null);
-
- // When keyboard navigation highlights a custom input sentinel, move DOM focus there.
- // For the To input, also open the calendar picker — programmatic focus() always lands
- // at the first sub-field (MM), but entering backward should start at the calendar end.
+ // Focus the appropriate inner element when keyboard navigation highlights a sentinel.
+ // Queries the DOM directly since DateRangePicker owns the elements via stable ids/classes.
useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
if (highlightedLabel === DATE_CUSTOM_FROM) {
- fromRef.current?.focus();
+ container.querySelector("#drp-from")?.focus();
} else if (highlightedLabel === DATE_CUSTOM_TO) {
- toRef.current?.focus();
+ container.querySelector("#drp-to")?.focus();
} else if (highlightedLabel === DATE_CUSTOM_APPLY) {
- applyRef.current?.focus();
+ container.querySelector(".fb-date-apply")?.focus();
}
}, [highlightedLabel]);
- // Compute once per render (client-only component, only mounted on interaction).
- const currentYear = useMemo(() => new Date().getUTCFullYear(), []);
-
- const presetHints = useMemo(
- () => Object.fromEntries(DATE_RANGE_PRESETS.map((p) => [p.label, buildPresetHint(p.getValue(), currentYear)])),
- [currentYear],
+ const handleCommit = useCallback(
+ (result: DateRangePickerResult) => {
+ onCommit(result.value);
+ },
+ [onCommit],
);
- // toDate must be strictly after fromDate (same minute = zero-second window after +1min adjustment)
- const rangeError = !!fromDate && !!toDate && toDate <= fromDate;
-
- const handleApply = useCallback(() => {
- if (!fromDate || rangeError) return;
- if (toDate) {
- onCommit(`${fromDate}..${toDate}`);
- } else {
- onCommit(fromDate);
- }
- }, [fromDate, toDate, rangeError, onCommit]);
-
- const handleFromChange = useCallback((value: string) => {
- setFromDate(value);
- // Clear "to" if it's now at or before "from" (equal = invalid range after +1min adjustment)
- setToDate((prev) => (prev && prev <= value ? "" : prev));
- }, []);
-
return (
{
if (e.key === "Escape") {
e.stopPropagation();
e.preventDefault();
onClose?.();
+ } else if (
+ e.key === "Tab" &&
+ !e.shiftKey &&
+ e.target === containerRef.current?.querySelector(".fb-date-apply")
+ ) {
+ e.preventDefault();
+ onCycleStep?.("forward", DATE_CUSTOM_APPLY);
} else {
e.stopPropagation();
}
}}
>
-
- {/* Left rail: presets with right-aligned date hints */}
-
-
Presets
- {DATE_RANGE_PRESETS.map((preset) => (
-
- ))}
-
-
- {/* Right: custom range */}
-
-
Custom range
-
-
- handleFromChange(e.target.value)}
- className="fb-date-input"
- />
-
-
-
- setToDate(e.target.value)}
- min={fromDate || undefined}
- className="fb-date-input"
- data-error={rangeError ? "" : undefined}
- aria-invalid={rangeError}
- aria-describedby={rangeError ? "fb-date-range-error" : undefined}
- />
- {rangeError && (
-
- “To” must be after “From”
-
- )}
-
-
-
-
- {/* Focus sentinel: the last focusable element inside the picker.
- When Tab exits Apply (or To when Apply is disabled), the browser naturally
- focuses this sentinel. onFocus immediately redirects back into the cycle,
- keeping focus trapped inside the filter bar. It must live inside the picker
- (inside the container) so handleBlur sees relatedTarget as within-container. */}
+
+ {/* Focus sentinel for keyboard cycling */}
})
+>;
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps["children"];
+ }
+>(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+ {children}
+
+
+ );
+});
+ChartContainer.displayName = "Chart";
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ const cssString = Object.entries(THEMES)
+ .map(
+ ([theme, prefix]) => `
+${prefix} [data-chart=${id}] {
+${colorConfig
+ .map(([key, itemConfig]) => {
+ const color = itemConfig.theme?.[theme as keyof typeof THEMES] || itemConfig.color;
+ return color ? ` --color-${key}: ${color};` : null;
+ })
+ .filter(Boolean)
+ .join("\n")}
+}`,
+ )
+ .join("\n");
+
+ return ;
+};
+
+const ChartTooltip = RechartsPrimitive.Tooltip;
+
+const ChartTooltipContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps &
+ 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 {labelFormatter(value, payload)}
;
+ }
+
+ if (!value) {
+ return null;
+ }
+
+ return {value}
;
+ }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
+
+ if (!active || !payload?.length) {
+ return null;
+ }
+
+ const nestLabel = payload.length === 1 && indicator !== "dot";
+
+ return (
+
+ {!nestLabel ? tooltipLabel : null}
+
+ {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 (
+
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
+ indicator === "dot" && "items-center",
+ )}
+ >
+ {formatter && item?.value !== undefined && item.name ? (
+ formatter(item.value, item.name, item, index, item.payload)
+ ) : (
+ <>
+ {itemConfig?.icon ? (
+
+ ) : (
+ !hideIndicator && (
+
+ )
+ )}
+
+
+ {nestLabel ? tooltipLabel : null}
+ {itemConfig?.label || item.name}
+
+ {item.value !== undefined && (
+
+ {item.value.toLocaleString("en-US")}
+
+ )}
+
+ >
+ )}
+
+ );
+ })}
+
+
+ );
+ },
+);
+ChartTooltipContent.displayName = "ChartTooltip";
+
+const ChartLegend = RechartsPrimitive.Legend;
+
+const ChartLegendContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> &
+ Pick & {
+ hideIcon?: boolean;
+ nameKey?: string;
+ }
+>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
+ const { config } = useChart();
+
+ if (!payload?.length) {
+ return null;
+ }
+
+ return (
+
+ {payload.map((item) => {
+ const key = `${nameKey || item.dataKey || "value"}`;
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
+
+ return (
+
svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}
+ >
+ {itemConfig?.icon && !hideIcon ? (
+
+ ) : (
+
+ )}
+ {itemConfig?.label}
+
+ );
+ })}
+
+ );
+});
+ChartLegendContent.displayName = "ChartLegend";
+
+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 as Record)[key] === "string") {
+ configLabelKey = (payload as Record)[key] as string;
+ } else if (
+ payloadPayload &&
+ key in payloadPayload &&
+ typeof (payloadPayload as Record)[key] === "string"
+ ) {
+ configLabelKey = (payloadPayload as Record)[key] as string;
+ }
+
+ return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
+}
+
+export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle, useChart };
+export type { ChartConfig };
+
+export const Area = RechartsPrimitive.Area;
+export const AreaChart = RechartsPrimitive.AreaChart;
+export const CartesianGrid = RechartsPrimitive.CartesianGrid;
+export const XAxis = RechartsPrimitive.XAxis;
+export const YAxis = RechartsPrimitive.YAxis;
diff --git a/src/ui/src/components/utilization-chart/utilization-chart.tsx b/src/ui/src/components/utilization-chart/utilization-chart.tsx
new file mode 100644
index 000000000..f2c160117
--- /dev/null
+++ b/src/ui/src/components/utilization-chart/utilization-chart.tsx
@@ -0,0 +1,344 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+"use client";
+
+import { useState, useReducer, useMemo, useCallback } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn/card";
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ XAxis,
+ YAxis,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/components/shadcn/chart";
+import { Skeleton } from "@/components/shadcn/skeleton";
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/shadcn/popover";
+import { InlineErrorBoundary } from "@/components/error/inline-error-boundary";
+import { DateRangePicker, type DateRangePickerResult } from "@/components/date-range-picker/date-range-picker";
+import { useUtilizationData } from "@/hooks/use-utilization-data";
+import { type MetricKey, type RawUtilizationBucket, TIER_MS, ceilToHour } from "@/lib/api/adapter/utilization";
+import { parseDateRangeValue } from "@/lib/date-range-utils";
+import { formatCompact, formatBytes, cn } from "@/lib/utils";
+import { MONTHS_SHORT } from "@/lib/format-date";
+
+const chartConfig = {
+ gpu: { label: "GPUs", color: "var(--chart-gpu)" },
+ cpu: { label: "CPUs", color: "var(--chart-cpu)" },
+ memory: { label: "Memory", color: "var(--chart-memory)" },
+ storage: { label: "Storage", color: "var(--chart-storage)" },
+} satisfies ChartConfig;
+
+type PresetKey = "1d" | "3d" | "7d" | "14d" | "30d";
+
+const RANGE_PRESETS: { key: PresetKey; label: string; ms: number }[] = [
+ { key: "1d", label: "1d", ms: TIER_MS["1d"] },
+ { key: "3d", label: "3d", ms: TIER_MS["3d"] },
+ { key: "7d", label: "7d", ms: TIER_MS["7d"] },
+ { key: "14d", label: "14d", ms: TIER_MS["14d"] },
+ { key: "30d", label: "30d", ms: TIER_MS["30d"] },
+];
+
+const DEFAULT_PRESET: PresetKey = "7d";
+
+const METRICS: MetricKey[] = ["gpu", "cpu", "memory", "storage"];
+
+const METRIC_TOTAL_FORMAT: Record string> = {
+ gpu: (v) => `${formatCompact(v)}\u00B7h`,
+ cpu: (v) => `${formatCompact(v)}\u00B7h`,
+ memory: (v) => `${formatBytes(v).display}\u00B7h`,
+ storage: (v) => `${formatBytes(v).display}\u00B7h`,
+};
+
+const METRIC_FORMAT: Record string> = {
+ gpu: (v) => `${formatCompact(v)} GPUs`,
+ cpu: (v) => `${formatCompact(v)} CPUs`,
+ memory: (v) => formatBytes(v).display,
+ storage: (v) => formatBytes(v).display,
+};
+
+function formatXAxisTick(timestampMs: number, granularityMs: number): string {
+ const d = new Date(timestampMs);
+ const mon = MONTHS_SHORT[d.getMonth()];
+ const day = d.getDate();
+ const hours = d.getHours();
+ const ampm = hours >= 12 ? "PM" : "AM";
+ const h12 = hours % 12 || 12;
+ if (granularityMs <= 3_600_000) {
+ return `${mon} ${day}, ${h12} ${ampm}`;
+ }
+ return `${mon} ${day}`;
+}
+
+function formatTooltipTime(timestampMs: number, granularityMs: number): string {
+ const d = new Date(timestampMs);
+ const mon = MONTHS_SHORT[d.getMonth()];
+ const day = d.getDate();
+ const fmtTime = (date: Date) => {
+ const h = date.getHours();
+ const m = date.getMinutes().toString().padStart(2, "0");
+ const ap = h >= 12 ? "PM" : "AM";
+ return `${h % 12 || 12}:${m} ${ap}`;
+ };
+
+ if (granularityMs <= 3_600_000) {
+ return `${mon} ${day}, ${fmtTime(d)}`;
+ }
+ const endD = new Date(timestampMs + granularityMs);
+ return `${mon} ${day}, ${fmtTime(d)} – ${fmtTime(endD)}`;
+}
+
+function rangeFromPreset(key: PresetKey): { start: number; end: number } {
+ const end = ceilToHour(Date.now());
+ const ms = RANGE_PRESETS.find((p) => p.key === key)?.ms ?? TIER_MS["7d"];
+ return { start: end - ms, end };
+}
+
+type RangeState =
+ | { preset: PresetKey; range: { start: number; end: number }; from: ""; to: "" }
+ | { preset: null; range: { start: number; end: number }; from: string; to: string };
+
+type RangeAction =
+ | { type: "preset"; key: PresetKey }
+ | { type: "custom"; range: { start: number; end: number }; from: string; to: string };
+
+function rangeReducer(_: RangeState, action: RangeAction): RangeState {
+ switch (action.type) {
+ case "preset":
+ return { preset: action.key, range: rangeFromPreset(action.key), from: "", to: "" };
+ case "custom":
+ return { preset: null, range: action.range, from: action.from, to: action.to };
+ }
+}
+
+export function UtilizationChart() {
+ const [rangeState, dispatchRange] = useReducer(rangeReducer, null, () => ({
+ preset: DEFAULT_PRESET,
+ range: rangeFromPreset(DEFAULT_PRESET),
+ from: "" as const,
+ to: "" as const,
+ }));
+ const [activeMetric, setActiveMetric] = useState("gpu");
+ const [popoverOpen, setPopoverOpen] = useState(false);
+
+ const { range } = rangeState;
+ const displayStartMs = range.start;
+ const displayEndMs = range.end;
+
+ const { buckets, truncated, isLoading, error, refetch, granularityMs } = useUtilizationData({
+ displayStartMs,
+ displayEndMs,
+ });
+
+ const totals = useMemo(() => {
+ const hours = granularityMs / 3_600_000;
+ const result = { gpu: 0, cpu: 0, memory: 0, storage: 0 };
+ for (const b of buckets) {
+ result.gpu += b.gpu * hours;
+ result.cpu += b.cpu * hours;
+ result.memory += b.memory * hours;
+ result.storage += b.storage * hours;
+ }
+ return result;
+ }, [buckets, granularityMs]);
+
+ const isCustom = rangeState.preset === null;
+
+ const handlePresetClick = useCallback((key: PresetKey) => {
+ dispatchRange({ type: "preset", key });
+ }, []);
+
+ const handleCustomCommit = useCallback((result: DateRangePickerResult) => {
+ const parsed = parseDateRangeValue(result.value);
+ if (parsed && parsed.end > parsed.start) {
+ const [from = "", to = ""] = result.value.split("..");
+ dispatchRange({ type: "custom", range: { start: parsed.start.getTime(), end: parsed.end.getTime() }, from, to });
+ setPopoverOpen(false);
+ }
+ }, []);
+
+ return (
+
+
+
+ {/* Left: title + range controls */}
+
+
Resource Utilization
+
+
+ {RANGE_PRESETS.map((p) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Right: metric tabs */}
+
+ {METRICS.map((metric) => (
+
+ ))}
+
+
+
+
+ {isLoading ? (
+
+
+
+
+ ) : error ? (
+
+
Failed to load utilization data
+
+
+ ) : buckets.length === 0 ? (
+
+
No utilization data for this range
+
Try selecting a different time range
+
+ ) : (
+
+
+
+ formatXAxisTick(value, granularityMs)}
+ />
+ {
+ if (activeMetric === "memory" || activeMetric === "storage") {
+ return formatBytes(value).display;
+ }
+ return formatCompact(value);
+ }}
+ width={40}
+ />
+ {
+ const items = payload as Array<{ payload?: RawUtilizationBucket }> | undefined;
+ const ts = items?.[0]?.payload?.timestamp;
+ if (ts == null) return "";
+ return formatTooltipTime(ts, granularityMs);
+ }}
+ formatter={(value) => {
+ const numVal = typeof value === "number" ? value : Number(value);
+ return METRIC_FORMAT[activeMetric](numVal);
+ }}
+ />
+ }
+ />
+
+
+
+ )}
+ {truncated && (
+
+ Data may be incomplete — too many tasks in this range.
+
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/src/features/dashboard/dashboard-content.tsx b/src/ui/src/features/dashboard/dashboard-content.tsx
index 76c03ae2e..ed1747fb3 100644
--- a/src/ui/src/features/dashboard/dashboard-content.tsx
+++ b/src/ui/src/features/dashboard/dashboard-content.tsx
@@ -14,12 +14,6 @@
//
// SPDX-License-Identifier: Apache-2.0
-/**
- * Dashboard Content (Client Component)
- *
- * Interactive dashboard content with hydrated data.
- */
-
"use client";
import { useMemo, useEffect } from "react";
@@ -34,10 +28,12 @@ import { cn } from "@/lib/utils";
import { getStatusDisplay, STATUS_STYLES } from "@/lib/workflows/workflow-constants";
import { WORKFLOW_STATUS_ICONS } from "@/lib/workflows/workflow-status-icons";
import { STATUS_PRESETS } from "@/lib/workflows/workflow-status-presets";
+import dynamic from "next/dynamic";
-// =============================================================================
-// Dashboard Content
-// =============================================================================
+const UtilizationChart = dynamic(
+ () => import("@/components/utilization-chart/utilization-chart").then((m) => ({ default: m.UtilizationChart })),
+ { ssr: false },
+);
interface DashboardContentProps {
/** Server-computed 24h cutoff (ISO string) — ensures query key matches between SSR and client */
@@ -184,6 +180,9 @@ export function DashboardContent({ submittedAfter }: DashboardContentProps) {
+ {/* Utilization chart */}
+
+
{/* Version info — lazy: only fetches when this component renders */}
{
+ const allTasks: ListTaskEntry[] = [];
+ let offset = 0;
+ let truncated = false;
+
+ while (allTasks.length < MAX_TASK_ROWS) {
+ const response = await listTaskApiTaskGet({
+ started_before: tierEndISO,
+ ended_after: tierStartISO,
+ all_users: true,
+ all_pools: true,
+ limit: PAGE_LIMIT,
+ offset,
+ });
+
+ const responseData = response.data as unknown as ListTaskResponse;
+ const tasks = responseData?.tasks ?? [];
+ allTasks.push(...tasks);
+
+ if (tasks.length < PAGE_LIMIT) break;
+
+ offset += PAGE_LIMIT;
+
+ if (allTasks.length >= MAX_TASK_ROWS) {
+ truncated = true;
+ break;
+ }
+ }
+
+ return { tasks: allTasks.slice(0, MAX_TASK_ROWS), truncated };
+}
+
+interface UseUtilizationDataParams {
+ displayStartMs: number;
+ displayEndMs: number;
+}
+
+export function useUtilizationData({ displayStartMs, displayEndMs }: UseUtilizationDataParams): UtilizationResult & {
+ isLoading: boolean;
+ granularityMs: number;
+ error: Error | null;
+ refetch: () => void;
+} {
+ const rangeMs = displayEndMs - displayStartMs;
+ const tier: FetchTier = selectTier(rangeMs);
+ const tierMs = TIER_MS[tier];
+
+ const tierStartMs = floorToHour(displayEndMs - tierMs);
+ const tierEndMs = ceilToHour(displayEndMs);
+ const tierStartISO = new Date(tierStartMs).toISOString();
+ const tierEndISO = new Date(tierEndMs).toISOString();
+
+ const queryKey = UTILIZATION_QUERY_KEY(tierStartISO, tier);
+
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey,
+ queryFn: () => fetchAllTasks(tierStartISO, tierEndISO),
+ staleTime: QUERY_STALE_TIME.STANDARD,
+ });
+
+ const granularityMs = autoGranularityMs(rangeMs);
+
+ const tasks = data?.tasks;
+ const buckets = useMemo(() => {
+ if (!tasks?.length) return [];
+ return bucketTasks(tasks, displayStartMs, displayEndMs, granularityMs);
+ }, [tasks, displayStartMs, displayEndMs, granularityMs]);
+
+ return {
+ buckets,
+ truncated: data?.truncated ?? false,
+ isLoading,
+ error,
+ refetch,
+ granularityMs,
+ };
+}
diff --git a/src/ui/src/lib/api/adapter/utilization.ts b/src/ui/src/lib/api/adapter/utilization.ts
new file mode 100644
index 000000000..51381deec
--- /dev/null
+++ b/src/ui/src/lib/api/adapter/utilization.ts
@@ -0,0 +1,144 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ListTaskEntry } from "@/lib/api/generated";
+
+const MS_PER_HOUR = 3_600_000;
+
+/** Snap a timestamp up to the next full hour (no-op if already on the hour). */
+export function ceilToHour(ms: number): number {
+ return Math.ceil(ms / MS_PER_HOUR) * MS_PER_HOUR;
+}
+
+/** Snap a timestamp down to the previous full hour (no-op if already on the hour). */
+export function floorToHour(ms: number): number {
+ return Math.floor(ms / MS_PER_HOUR) * MS_PER_HOUR;
+}
+
+export type FetchTier = "1d" | "3d" | "7d" | "14d" | "30d";
+
+export const TIER_MS: Record = {
+ "1d": 86_400_000,
+ "3d": 259_200_000,
+ "7d": 604_800_000,
+ "14d": 1_209_600_000,
+ "30d": 2_592_000_000,
+};
+
+const TIER_ORDER: FetchTier[] = ["1d", "3d", "7d", "14d", "30d"];
+
+export function selectTier(rangeMs: number): FetchTier {
+ for (const tier of TIER_ORDER) {
+ if (rangeMs <= TIER_MS[tier]) return tier;
+ }
+ return "30d";
+}
+
+export function autoGranularityMs(rangeMs: number): number {
+ const days = rangeMs / 86_400_000;
+ if (days <= 3) return 3_600_000; // 1h -> 24-72 points
+ if (days <= 7) return 10_800_000; // 3h -> 56 points
+ if (days <= 14) return 21_600_000; // 6h -> 56 points
+ return 43_200_000; // 12h -> 60 points
+}
+
+export interface RawUtilizationBucket {
+ timestamp: number;
+ gpu: number;
+ cpu: number;
+ memory: number;
+ storage: number;
+}
+
+export interface UtilizationResult {
+ buckets: RawUtilizationBucket[];
+ truncated: boolean;
+}
+
+export type MetricKey = "gpu" | "cpu" | "memory" | "storage";
+
+export const MAX_TASK_ROWS = 5_000;
+
+export const UTILIZATION_QUERY_KEY = (tierStart: string, tier: FetchTier) =>
+ ["/api/task/utilization", { tierStart, tier }] as const;
+
+interface ParsedTask {
+ startMs: number;
+ endMs: number;
+ gpu: number;
+ cpu: number;
+ memory: number;
+ storage: number;
+}
+
+function parseTasks(tasks: ListTaskEntry[], fallbackEndMs: number): ParsedTask[] {
+ const result: ParsedTask[] = [];
+ for (const task of tasks) {
+ if (!task.start_time) continue;
+ const startMs = new Date(task.start_time).getTime();
+ if (Number.isNaN(startMs)) continue;
+ const endMs = task.end_time ? new Date(task.end_time).getTime() : fallbackEndMs;
+ result.push({
+ startMs,
+ endMs: Number.isNaN(endMs) ? fallbackEndMs : endMs,
+ gpu: task.gpu,
+ cpu: task.cpu,
+ memory: task.memory,
+ storage: task.storage,
+ });
+ }
+ return result;
+}
+
+export function bucketTasks(
+ tasks: ListTaskEntry[],
+ displayStartMs: number,
+ displayEndMs: number,
+ granularityMs: number,
+): RawUtilizationBucket[] {
+ const parsed = parseTasks(tasks, displayEndMs);
+ const bucketCount = Math.ceil((displayEndMs - displayStartMs) / granularityMs);
+ const buckets: RawUtilizationBucket[] = [];
+
+ for (let i = 0; i < bucketCount; i++) {
+ buckets.push({
+ timestamp: displayStartMs + i * granularityMs,
+ gpu: 0,
+ cpu: 0,
+ memory: 0,
+ storage: 0,
+ });
+ }
+
+ for (const task of parsed) {
+ if (task.endMs <= displayStartMs || task.startMs >= displayEndMs) continue;
+
+ const firstBucket = Math.max(0, Math.floor((task.startMs - displayStartMs) / granularityMs));
+ const lastBucket = Math.min(
+ bucketCount - 1,
+ Math.floor((Math.min(task.endMs, displayEndMs) - displayStartMs - 1) / granularityMs),
+ );
+
+ for (let i = firstBucket; i <= lastBucket; i++) {
+ buckets[i].gpu += task.gpu;
+ buckets[i].cpu += task.cpu;
+ buckets[i].memory += task.memory;
+ buckets[i].storage += task.storage;
+ }
+ }
+
+ return buckets;
+}
diff --git a/src/ui/src/lib/api/generated.ts b/src/ui/src/lib/api/generated.ts
index 2af5aa516..411985f1e 100644
--- a/src/ui/src/lib/api/generated.ts
+++ b/src/ui/src/lib/api/generated.ts
@@ -2118,6 +2118,8 @@ all_pools?: boolean;
nodes?: string[];
started_after?: string;
started_before?: string;
+ended_after?: string;
+ended_before?: string;
offset?: number;
limit?: number;
order?: ListOrder;
diff --git a/src/ui/src/lib/utils.ts b/src/ui/src/lib/utils.ts
index 821a6f882..da30f1f70 100644
--- a/src/ui/src/lib/utils.ts
+++ b/src/ui/src/lib/utils.ts
@@ -61,8 +61,8 @@ export function formatCompact(value: number): string {
return value.toString();
}
-type ByteUnit = "Ki" | "Mi" | "Gi" | "Ti";
-const UNIT_ORDER: ByteUnit[] = ["Ki", "Mi", "Gi", "Ti"];
+type ByteUnit = "Ki" | "Mi" | "Gi" | "Ti" | "Pi";
+const UNIT_ORDER: ByteUnit[] = ["Ki", "Mi", "Gi", "Ti", "Pi"];
interface FormattedBytes {
value: string;
@@ -79,12 +79,18 @@ function formatDecimal(n: number): string {
return fixed.replace(/\.0$/, "");
}
-// Format GiB to most readable binary unit (Ki, Mi, Gi, Ti)
+// Format GiB to most readable binary unit (Ki, Mi, Gi, Ti, Pi)
export function formatBytes(gib: number): FormattedBytes {
if (gib === 0) {
return { value: "0", unit: "Gi", display: "0 Gi", rawGib: 0 };
}
+ if (gib >= 1024 * 1024) {
+ const pi = gib / (1024 * 1024);
+ const formatted = formatDecimal(pi);
+ return { value: formatted, unit: "Pi", display: `${formatted} Pi`, rawGib: gib };
+ }
+
if (gib >= 1024) {
const ti = gib / 1024;
const formatted = formatDecimal(ti);
@@ -109,6 +115,8 @@ export function formatBytes(gib: number): FormattedBytes {
function gibToUnit(gib: number, unit: ByteUnit): number {
switch (unit) {
+ case "Pi":
+ return gib / (1024 * 1024);
case "Ti":
return gib / 1024;
case "Gi":
diff --git a/src/ui/src/mocks/generators/utilization-generator.ts b/src/ui/src/mocks/generators/utilization-generator.ts
new file mode 100644
index 000000000..2372f992a
--- /dev/null
+++ b/src/ui/src/mocks/generators/utilization-generator.ts
@@ -0,0 +1,368 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Generates deterministic ListTaskEntry[] for the utilization dashboard mock.
+ * Tasks span the last 35 days with varied workload profiles, temporal patterns,
+ * and edge cases (still-running, zero-GPU, pre-window spans).
+ */
+
+import { faker } from "@faker-js/faker";
+import type { ListTaskEntry } from "@/lib/api/generated";
+import { TaskGroupStatus } from "@/lib/api/generated";
+import { MOCK_CONFIG } from "@/mocks/seed/types";
+import { hashString } from "@/mocks/utils";
+
+const BASE_SEED = 0xcafe_babe;
+const MS_PER_HOUR = 3_600_000;
+const MS_PER_DAY = 86_400_000;
+const GENERATION_WINDOW_DAYS = 35;
+
+const USERS = MOCK_CONFIG.workflows.users;
+const POOLS = [
+ "dgx-cloud-us-west-2",
+ "dgx-cloud-us-east-1",
+ "gpu-cluster-prod",
+ "gpu-cluster-dev",
+ "shared-pool-alpha",
+ "dedicated-h100-80gb",
+ "training-pool",
+ "inference-pool",
+ "benchmark-pool",
+ "research-cluster",
+];
+
+interface WorkloadProfile {
+ name: string;
+ weight: number;
+ gpu: [number, number];
+ cpu: [number, number];
+ memory: [number, number];
+ storage: [number, number];
+ durationHours: [number, number];
+ statuses: { status: TaskGroupStatus; weight: number }[];
+ overnightBias: boolean;
+}
+
+const WORKLOADS: WorkloadProfile[] = [
+ {
+ name: "large-training",
+ weight: 0.08,
+ gpu: [16, 128],
+ cpu: [128, 1024],
+ memory: [1024, 8192],
+ storage: [1000, 10000],
+ durationHours: [8, 72],
+ statuses: [
+ { status: TaskGroupStatus.COMPLETED, weight: 0.6 },
+ { status: TaskGroupStatus.RUNNING, weight: 0.15 },
+ { status: TaskGroupStatus.FAILED, weight: 0.1 },
+ { status: TaskGroupStatus.FAILED_EXEC_TIMEOUT, weight: 0.1 },
+ { status: TaskGroupStatus.FAILED_EVICTED, weight: 0.05 },
+ ],
+ overnightBias: true,
+ },
+ {
+ name: "medium-training",
+ weight: 0.25,
+ gpu: [2, 8],
+ cpu: [16, 64],
+ memory: [128, 512],
+ storage: [100, 1000],
+ durationHours: [2, 12],
+ statuses: [
+ { status: TaskGroupStatus.COMPLETED, weight: 0.65 },
+ { status: TaskGroupStatus.RUNNING, weight: 0.1 },
+ { status: TaskGroupStatus.FAILED, weight: 0.15 },
+ { status: TaskGroupStatus.FAILED_IMAGE_PULL, weight: 0.05 },
+ { status: TaskGroupStatus.FAILED_PREEMPTED, weight: 0.05 },
+ ],
+ overnightBias: false,
+ },
+ {
+ name: "short-experiment",
+ weight: 0.25,
+ gpu: [1, 4],
+ cpu: [8, 32],
+ memory: [32, 128],
+ storage: [0, 100],
+ durationHours: [0.25, 2],
+ statuses: [
+ { status: TaskGroupStatus.COMPLETED, weight: 0.7 },
+ { status: TaskGroupStatus.FAILED, weight: 0.2 },
+ { status: TaskGroupStatus.FAILED_START_ERROR, weight: 0.05 },
+ { status: TaskGroupStatus.RUNNING, weight: 0.05 },
+ ],
+ overnightBias: false,
+ },
+ {
+ name: "inference",
+ weight: 0.12,
+ gpu: [1, 4],
+ cpu: [4, 16],
+ memory: [16, 64],
+ storage: [0, 50],
+ durationHours: [0.5, 48],
+ statuses: [
+ { status: TaskGroupStatus.RUNNING, weight: 0.35 },
+ { status: TaskGroupStatus.COMPLETED, weight: 0.55 },
+ { status: TaskGroupStatus.FAILED, weight: 0.1 },
+ ],
+ overnightBias: false,
+ },
+ {
+ name: "cpu-preprocessing",
+ weight: 0.15,
+ gpu: [0, 0],
+ cpu: [8, 64],
+ memory: [32, 256],
+ storage: [100, 2000],
+ durationHours: [0.5, 4],
+ statuses: [
+ { status: TaskGroupStatus.COMPLETED, weight: 0.8 },
+ { status: TaskGroupStatus.RUNNING, weight: 0.05 },
+ { status: TaskGroupStatus.FAILED, weight: 0.1 },
+ { status: TaskGroupStatus.FAILED_EXEC_TIMEOUT, weight: 0.05 },
+ ],
+ overnightBias: false,
+ },
+ {
+ name: "benchmark",
+ weight: 0.08,
+ gpu: [4, 16],
+ cpu: [32, 128],
+ memory: [256, 512],
+ storage: [0, 50],
+ durationHours: [0.08, 1],
+ statuses: [
+ { status: TaskGroupStatus.COMPLETED, weight: 0.85 },
+ { status: TaskGroupStatus.FAILED, weight: 0.1 },
+ { status: TaskGroupStatus.RUNNING, weight: 0.05 },
+ ],
+ overnightBias: false,
+ },
+ {
+ name: "long-running-service",
+ weight: 0.07,
+ gpu: [1, 8],
+ cpu: [8, 32],
+ memory: [64, 256],
+ storage: [0, 20],
+ durationHours: [24, 168],
+ statuses: [
+ { status: TaskGroupStatus.RUNNING, weight: 0.5 },
+ { status: TaskGroupStatus.COMPLETED, weight: 0.3 },
+ { status: TaskGroupStatus.FAILED_EVICTED, weight: 0.1 },
+ { status: TaskGroupStatus.FAILED_PREEMPTED, weight: 0.1 },
+ ],
+ overnightBias: false,
+ },
+];
+
+function seededInt(key: string, min: number, max: number): number {
+ faker.seed(BASE_SEED ^ hashString(key));
+ return faker.number.int({ min, max });
+}
+
+function seededFloat(key: string, min: number, max: number): number {
+ faker.seed(BASE_SEED ^ hashString(key));
+ return faker.number.float({ min, max, multipleOf: 0.01 });
+}
+
+function seededChoice(key: string, arr: readonly T[]): T {
+ faker.seed(BASE_SEED ^ hashString(key));
+ return faker.helpers.arrayElement(arr as T[]);
+}
+
+function seededWeighted(key: string, items: { value: T; weight: number }[]): T {
+ faker.seed(BASE_SEED ^ hashString(key));
+ return faker.helpers.weightedArrayElement(items.map((i) => ({ value: i.value, weight: i.weight })));
+}
+
+function pickWorkload(taskIndex: number): WorkloadProfile {
+ return seededWeighted(
+ `workload:${taskIndex}`,
+ WORKLOADS.map((w) => ({ value: w, weight: w.weight })),
+ );
+}
+
+function generateStartHour(taskIndex: number, workload: WorkloadProfile): number {
+ const key = `hour:${taskIndex}`;
+ if (workload.overnightBias) {
+ return seededFloat(key, 17, 23);
+ }
+ const roll = seededFloat(`${key}:roll`, 0, 1);
+ if (roll < 0.7) {
+ return seededFloat(key, 8, 20);
+ }
+ return seededFloat(key, 0, 24);
+}
+
+function generateTask(taskIndex: number, nowMs: number): ListTaskEntry {
+ const workload = pickWorkload(taskIndex);
+ const key = `task:${taskIndex}`;
+
+ const daysAgo = seededFloat(`${key}:day`, 0, GENERATION_WINDOW_DAYS);
+ const dayOfWeek = new Date(nowMs - daysAgo * MS_PER_DAY).getDay();
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
+
+ if (isWeekend && seededFloat(`${key}:weekend-skip`, 0, 1) > 0.3) {
+ return generateTask(taskIndex + 500, nowMs);
+ }
+
+ const startHour = generateStartHour(taskIndex, workload);
+ const startMs = nowMs - daysAgo * MS_PER_DAY + startHour * MS_PER_HOUR;
+ const durationMs = seededFloat(`${key}:dur`, workload.durationHours[0], workload.durationHours[1]) * MS_PER_HOUR;
+
+ const status = seededWeighted(
+ `${key}:status`,
+ workload.statuses.map((s) => ({ value: s.status, weight: s.weight })),
+ );
+
+ const isStillRunning = status === TaskGroupStatus.RUNNING;
+ const endMs = isStillRunning ? undefined : startMs + durationMs;
+ const endTime = endMs != null ? new Date(endMs).toISOString() : undefined;
+ const duration = endMs != null ? Math.round((endMs - startMs) / 1000) : undefined;
+
+ const gpu = seededInt(`${key}:gpu`, workload.gpu[0], workload.gpu[1]);
+ const cpu = seededInt(`${key}:cpu`, workload.cpu[0], workload.cpu[1]);
+ const memory = seededInt(`${key}:mem`, workload.memory[0], workload.memory[1]);
+ const storage = seededInt(`${key}:sto`, workload.storage[0], workload.storage[1]);
+
+ const user = seededChoice(`${key}:user`, USERS);
+ const pool = seededChoice(`${key}:pool`, POOLS);
+ const node = `node-${pool.slice(0, 3)}-${seededInt(`${key}:node`, 1, 50).toString().padStart(3, "0")}`;
+ const priority = seededChoice(`${key}:pri`, ["HIGH", "NORMAL", "LOW"] as const);
+
+ const prefix = seededChoice(`${key}:prefix`, MOCK_CONFIG.workflows.namePatterns.prefixes);
+ const suffix = seededChoice(`${key}:suffix`, MOCK_CONFIG.workflows.namePatterns.suffixes);
+ const workflowName = `${prefix}-${suffix}-${taskIndex.toString(16).padStart(4, "0")}`;
+ const taskName = `${workload.name}-${seededInt(`${key}:tidx`, 0, 7)}`;
+
+ return {
+ user,
+ workflow_id: workflowName,
+ workflow_uuid: `${workflowName}-uuid-${taskIndex}`,
+ task_name: taskName,
+ retry_id: 0,
+ pool,
+ node,
+ start_time: new Date(startMs).toISOString(),
+ end_time: endTime,
+ duration,
+ status,
+ overview: `${workload.name} task`,
+ logs: `/api/workflow/${workflowName}/task/${taskName}/logs`,
+ priority,
+ gpu,
+ cpu,
+ memory,
+ storage,
+ };
+}
+
+const NOW_MS = Date.now();
+const TASK_COUNT = 420;
+
+const ALL_TASKS: ListTaskEntry[] = [];
+for (let i = 0; i < TASK_COUNT; i++) {
+ ALL_TASKS.push(generateTask(i, NOW_MS));
+}
+
+ALL_TASKS.sort((a, b) => {
+ const aTime = a.start_time ? new Date(a.start_time).getTime() : 0;
+ const bTime = b.start_time ? new Date(b.start_time).getTime() : 0;
+ return bTime - aTime;
+});
+
+export interface UtilizationTaskFilters {
+ started_before?: string;
+ ended_after?: string;
+ limit?: number;
+ offset?: number;
+}
+
+export class UtilizationGenerator {
+ /**
+ * Return tasks that were active during the specified window.
+ *
+ * A task is "active" during [ended_after, started_before] if:
+ * task.start_time < started_before AND (task.end_time >= ended_after OR task.end_time IS NULL)
+ *
+ * Mirrors the SQL filter in helpers.py.
+ */
+ getTasks(filters: UtilizationTaskFilters = {}): { tasks: ListTaskEntry[]; total: number } {
+ let result = ALL_TASKS;
+
+ if (filters.started_before) {
+ const before = new Date(filters.started_before).getTime();
+ result = result.filter((t) => {
+ if (!t.start_time) return false;
+ return new Date(t.start_time).getTime() < before;
+ });
+ }
+
+ if (filters.ended_after) {
+ const after = new Date(filters.ended_after).getTime();
+ result = result.filter((t) => {
+ if (!t.end_time) return true;
+ return new Date(t.end_time).getTime() >= after;
+ });
+ }
+
+ const total = result.length;
+ const offset = filters.offset ?? 0;
+ const limit = filters.limit ?? 1000;
+
+ return {
+ tasks: result.slice(offset, offset + limit),
+ total,
+ };
+ }
+
+ /** Total number of generated tasks. */
+ get totalTasks(): number {
+ return ALL_TASKS.length;
+ }
+
+ /** Summary stats for debugging. */
+ getStats(): {
+ total: number;
+ running: number;
+ completed: number;
+ failed: number;
+ gpuZero: number;
+ maxGpu: number;
+ } {
+ let running = 0;
+ let completed = 0;
+ let failed = 0;
+ let gpuZero = 0;
+ let maxGpu = 0;
+
+ for (const t of ALL_TASKS) {
+ if (t.status === TaskGroupStatus.RUNNING) running++;
+ else if (t.status === TaskGroupStatus.COMPLETED) completed++;
+ else if (t.status.toString().startsWith("FAILED")) failed++;
+ if (t.gpu === 0) gpuZero++;
+ if (t.gpu > maxGpu) maxGpu = t.gpu;
+ }
+
+ return { total: ALL_TASKS.length, running, completed, failed, gpuZero, maxGpu };
+ }
+}
+
+export const utilizationGenerator = new UtilizationGenerator();
diff --git a/src/ui/src/mocks/handlers.ts b/src/ui/src/mocks/handlers.ts
index f8807d0a3..0adb0fcf7 100644
--- a/src/ui/src/mocks/handlers.ts
+++ b/src/ui/src/mocks/handlers.ts
@@ -37,6 +37,7 @@ import { profileGenerator } from "@/mocks/generators/profile-generator";
import { portForwardGenerator } from "@/mocks/generators/portforward-generator";
import { ptySimulator, type PTYScenario } from "@/mocks/generators/pty-simulator";
import { taskSummaryGenerator } from "@/mocks/generators/task-summary-generator";
+import { utilizationGenerator } from "@/mocks/generators/utilization-generator";
import { parsePagination, parseWorkflowFilters, hasActiveFilters, getMockDelay, hashString } from "@/mocks/utils";
import { getMockWorkflow, getWorkflowLogConfig } from "@/mocks/mock-workflows";
import { MOCK_CONFIG, SHARED_POOL_ALPHA, SHARED_POOL_BETA } from "@/mocks/seed/types";
@@ -1618,34 +1619,43 @@ export const handlers = [
}),
// ==========================================================================
- // Task Summary — GET /api/task?summary=true
+ // Task — GET /api/task
// ==========================================================================
- // Handles the occupancy page data source. When summary=true the endpoint
- // returns aggregated (user, pool, priority) resource-usage rows rather than
- // individual task records.
+ // summary=true → occupancy page (aggregated resource-usage rows)
+ // summary!=true → utilization dashboard (individual task records for bucketing)
http.get("*/api/task", async ({ request }) => {
await delay(MOCK_DELAY);
const url = new URL(request.url);
- // Only intercept summary requests; let other /api/task calls pass through.
- if (url.searchParams.get("summary") !== "true") {
- return passthrough();
+ if (url.searchParams.get("summary") === "true") {
+ const users = url.searchParams.getAll("users");
+ const pools = url.searchParams.getAll("pools");
+ const priorities = url.searchParams.getAll("priority");
+ const limit = parseInt(url.searchParams.get("limit") ?? "10000", 10);
+
+ const summaries = taskSummaryGenerator.getSummaries({
+ users: users.length > 0 ? users : undefined,
+ pools: pools.length > 0 ? pools : undefined,
+ priorities: priorities.length > 0 ? priorities : undefined,
+ limit: isNaN(limit) ? undefined : limit,
+ });
+
+ return HttpResponse.json({ summaries });
}
- const users = url.searchParams.getAll("users");
- const pools = url.searchParams.getAll("pools");
- const priorities = url.searchParams.getAll("priority");
- const limit = parseInt(url.searchParams.get("limit") ?? "10000", 10);
+ const startedBefore = url.searchParams.get("started_before") ?? undefined;
+ const endedAfter = url.searchParams.get("ended_after") ?? undefined;
+ const { offset, limit } = parsePagination(url, { limit: 1000 });
- const summaries = taskSummaryGenerator.getSummaries({
- users: users.length > 0 ? users : undefined,
- pools: pools.length > 0 ? pools : undefined,
- priorities: priorities.length > 0 ? priorities : undefined,
- limit: isNaN(limit) ? undefined : limit,
+ const { tasks } = utilizationGenerator.getTasks({
+ started_before: startedBefore,
+ ended_after: endedAfter,
+ limit,
+ offset,
});
- return HttpResponse.json({ summaries });
+ return HttpResponse.json({ tasks });
}),
// ==========================================================================
@@ -1708,4 +1718,5 @@ export {
portForwardGenerator,
ptySimulator,
taskSummaryGenerator,
+ utilizationGenerator,
};