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 */}