diff --git a/bun.lock b/bun.lock index 36d107374..6a494c80a 100644 --- a/bun.lock +++ b/bun.lock @@ -161,6 +161,8 @@ "whatwg-url": "^13", }, "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -171,6 +173,12 @@ "@antfu/utils": ["@antfu/utils@9.3.0", "", {}, "sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], @@ -229,6 +237,8 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + "@bufbuild/buf": ["@bufbuild/buf@1.59.0", "", { "optionalDependencies": { "@bufbuild/buf-darwin-arm64": "1.59.0", "@bufbuild/buf-darwin-x64": "1.59.0", "@bufbuild/buf-linux-aarch64": "1.59.0", "@bufbuild/buf-linux-armv7": "1.59.0", "@bufbuild/buf-linux-x64": "1.59.0", "@bufbuild/buf-win32-arm64": "1.59.0", "@bufbuild/buf-win32-x64": "1.59.0" }, "bin": { "buf": "bin/buf", "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" } }, "sha512-VdLuGnFp1OKJaiMevlLow6Jcvv9omOyM02Qa1zexl8dBB4Ac2ggz6bpT3Zb06tmCnqd8tFrI/Im1fbom3CznlQ=="], "@bufbuild/buf-darwin-arm64": ["@bufbuild/buf-darwin-arm64@1.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d3JTxBCibC+C94JU0jwLMgo/WBhaAHBIRzZXaZ3Y8KREjTj3jhzAlelGZmCtQJyyE0l6DFSm3lQgMblJ5qlq/w=="], @@ -271,6 +281,18 @@ "@connectrpc/protoc-gen-connect-es": ["@connectrpc/protoc-gen-connect-es@1.7.0", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0", "@bufbuild/protoplugin": "^1.10.0" }, "peerDependencies": { "@bufbuild/protoc-gen-es": "^1.10.0", "@connectrpc/connect": "1.7.0" }, "optionalPeers": ["@bufbuild/protoc-gen-es", "@connectrpc/connect"], "bin": { "protoc-gen-connect-es": "bin/protoc-gen-connect-es" } }, "sha512-g2rE799dxGgXtwSTBOJoSlzCy3HN0IX/Es8uKsCgXRmco8o277/bb5nz1X8TmvBooCBGNdtEdUDG50olcvS9jQ=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.2", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.29", "", {}, "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -339,6 +361,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], + "@exodus/bytes": ["@exodus/bytes@1.14.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], @@ -1231,6 +1255,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.8.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -1349,10 +1375,14 @@ "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssstyle": ["cssstyle@6.2.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.28", "css-tree": "^3.1.0", "lru-cache": "^11.2.6" } }, "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], @@ -1427,6 +1457,8 @@ "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -1439,6 +1471,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], @@ -1673,6 +1707,8 @@ "html-dom-parser": ["html-dom-parser@5.1.1", "", { "dependencies": { "domhandler": "5.0.3", "htmlparser2": "10.0.0" } }, "sha512-+o4Y4Z0CLuyemeccvGN4bAO20aauB2N9tFEAep5x4OW34kV4PTarBHm6RL02afYt2BMKcr0D2Agep8S3nJPIBg=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], @@ -1683,7 +1719,7 @@ "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], - "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1715,6 +1751,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], @@ -1755,6 +1793,8 @@ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], @@ -1859,6 +1899,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "mermaid": ["mermaid@11.12.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.3", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-UlIZrRariB11TY1RtTgUWp65tphtBv4CSq7vyS2ZZ2TgoMjs2nloq+wFqxiwcxlhHUvs7DPGgMjs2aeQxz5h9g=="], @@ -2187,6 +2229,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], @@ -2291,6 +2335,8 @@ "swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="], @@ -2325,12 +2371,18 @@ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "tldts": ["tldts@7.0.24", "", { "dependencies": { "tldts-core": "^7.0.24" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ=="], + + "tldts-core": ["tldts-core@7.0.24", "", {}, "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + "tr46": ["tr46@4.1.1", "", { "dependencies": { "punycode": "^2.3.0" } }, "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -2429,13 +2481,15 @@ "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="], - "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], "webpack": ["webpack@5.102.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ=="], @@ -2461,6 +2515,10 @@ "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -2491,6 +2549,10 @@ "@antfu/install-pkg/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + + "@asamuzakjp/dom-selector/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2807,6 +2869,8 @@ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "cssstyle/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], @@ -2815,6 +2879,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "extract-zip/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2827,8 +2893,6 @@ "htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-diff/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -2849,6 +2913,12 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + + "jsdom/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + + "jsdom/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -2933,6 +3003,8 @@ "styled-components/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], + "teeny-request/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "teeny-request/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], @@ -2953,6 +3025,8 @@ "vitest/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "whatwg-url/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -3109,6 +3183,8 @@ "jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "jsdom/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "mermaid.cli/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "mermaid.cli/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], @@ -3179,6 +3255,8 @@ "styled-components/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "teeny-request/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "test-exclude/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], diff --git a/src/__test__/integration/auth.test.ts b/src/__test__/integration/auth.test.ts index 81c4925ec..71c96142c 100644 --- a/src/__test__/integration/auth.test.ts +++ b/src/__test__/integration/auth.test.ts @@ -95,12 +95,14 @@ describe('Auth Actions - Integration Tests', () => { ok: true, json: () => Promise.resolve({ version: 'v2.60.7', name: 'GoTrue' }), }) + global.fetch = fetchMock as unknown as typeof fetch verifyTurnstileToken.mockResolvedValue(true) }) afterEach(() => { // Restore original console.error after each test console.error = originalConsoleError + global.fetch = originalFetch }) describe('Sign In Flow', () => { diff --git a/src/__test__/unit/sandbox-monitoring-chart-model.test.ts b/src/__test__/unit/sandbox-monitoring-chart-model.test.ts new file mode 100644 index 000000000..3955ce9df --- /dev/null +++ b/src/__test__/unit/sandbox-monitoring-chart-model.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { buildMonitoringChartModel } from '@/features/dashboard/sandbox/monitoring/utils/chart-model' +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' + +const baseMetric = { + timestamp: '1970-01-01T00:00:00.000Z', + cpuCount: 2, + memTotal: 1_000, + diskTotal: 2_000, +} satisfies Omit< + SandboxMetric, + 'timestampUnix' | 'cpuUsedPct' | 'memUsed' | 'diskUsed' +> + +describe('buildMonitoringChartModel', () => { + it('builds deterministic time-series data sorted by timestamp', () => { + const metrics: SandboxMetric[] = [ + { + ...baseMetric, + timestampUnix: 10, + cpuUsedPct: 30, + memUsed: 300, + diskUsed: 600, + }, + { + ...baseMetric, + timestampUnix: 0, + cpuUsedPct: 10, + memUsed: 100, + diskUsed: 200, + }, + { + ...baseMetric, + timestampUnix: 5, + cpuUsedPct: 20, + memUsed: 200, + diskUsed: 400, + }, + ] + + const result = buildMonitoringChartModel({ + metrics, + startMs: 0, + endMs: 10_000, + hoveredTimestampMs: 6_000, + }) + + expect(result.latestMetric?.timestampUnix).toBe(10) + expect(result.resourceSeries).toHaveLength(2) + expect(result.diskSeries).toHaveLength(1) + expect(result.resourceSeries[0]?.data).toEqual([ + [0, 10, null], + [5_000, 20, null], + [10_000, 30, null], + ]) + expect(result.resourceSeries[1]?.data).toEqual([ + [0, 10, 0], + [5_000, 20, 0], + [10_000, 30, 0], + ]) + expect(result.diskSeries[0]?.data).toEqual([ + [0, 10, 0], + [5_000, 20, 0], + [10_000, 30, 0], + ]) + expect(result.resourceHoveredContext).toEqual({ + cpuPercent: 20, + ramPercent: 20, + timestampMs: 5_000, + }) + expect(result.diskHoveredContext).toEqual({ + diskPercent: 20, + timestampMs: 5_000, + }) + }) + + it('returns null hovered contexts when no data is available', () => { + const result = buildMonitoringChartModel({ + metrics: [], + startMs: 0, + endMs: 10_000, + hoveredTimestampMs: 1_000, + }) + + expect(result.resourceHoveredContext).toBeNull() + expect(result.diskHoveredContext).toBeNull() + }) + + it('filters out metrics outside range and invalid timestamps', () => { + const metrics: SandboxMetric[] = [ + { + ...baseMetric, + timestampUnix: 1, + cpuUsedPct: 5, + memUsed: 50, + diskUsed: 100, + }, + { + ...baseMetric, + timestampUnix: Number.NaN, + cpuUsedPct: 55, + memUsed: 550, + diskUsed: 1_100, + }, + { + ...baseMetric, + timestampUnix: 20, + cpuUsedPct: 80, + memUsed: 800, + diskUsed: 1_600, + }, + ] + + const result = buildMonitoringChartModel({ + metrics, + startMs: 0, + endMs: 10_000, + hoveredTimestampMs: null, + }) + + expect(result.latestMetric?.timestampUnix).toBe(1) + expect(result.resourceSeries[0]?.data).toEqual([[1_000, 5, null]]) + expect(result.diskSeries[0]?.data).toEqual([[1_000, 5, 0]]) + }) +}) diff --git a/src/__test__/unit/sandbox-monitoring-timeframe.test.ts b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts new file mode 100644 index 000000000..2c1b9384e --- /dev/null +++ b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { + SANDBOX_MONITORING_DEFAULT_RANGE_MS, + SANDBOX_MONITORING_MAX_RANGE_MS, + SANDBOX_MONITORING_MIN_RANGE_MS, +} from '@/features/dashboard/sandbox/monitoring/utils/constants' +import { + getSandboxLifecycleBounds, + normalizeMonitoringTimeframe, + parseMonitoringQueryState, +} from '@/features/dashboard/sandbox/monitoring/utils/timeframe' +import type { SandboxDetailsDTO } from '@/server/api/models/sandboxes.models' + +describe('sandbox-monitoring-timeframe', () => { + describe('normalizeMonitoringTimeframe', () => { + it('should fallback to default range when inputs are invalid', () => { + const now = 1_700_000_000_000 + + const result = normalizeMonitoringTimeframe({ + start: Number.NaN, + end: Number.POSITIVE_INFINITY, + now, + }) + + expect(result.end).toBe(now) + expect(result.start).toBe(now - SANDBOX_MONITORING_DEFAULT_RANGE_MS) + }) + + it('should enforce minimum range', () => { + const now = 1_700_000_000_000 + + const result = normalizeMonitoringTimeframe({ + start: now - 1_000, + end: now, + now, + }) + + expect(result.end - result.start).toBe(SANDBOX_MONITORING_MIN_RANGE_MS) + }) + + it('should cap maximum range', () => { + const now = 1_700_000_000_000 + + const result = normalizeMonitoringTimeframe({ + start: now - 365 * 24 * 60 * 60 * 1_000, + end: now, + now, + }) + + expect(result.end - result.start).toBe(SANDBOX_MONITORING_MAX_RANGE_MS) + }) + + it('should clamp future timestamps to now', () => { + const now = 1_700_000_000_000 + + const result = normalizeMonitoringTimeframe({ + start: now + 5_000, + end: now + 10_000, + now, + }) + + expect(result.end).toBe(now) + expect(result.start).toBe(now - SANDBOX_MONITORING_MIN_RANGE_MS) + }) + }) + + describe('parseMonitoringQueryState', () => { + it('should parse canonical query params', () => { + const result = parseMonitoringQueryState({ + start: '1000', + end: '2000', + live: '1', + }) + + expect(result).toEqual({ + start: 1000, + end: 2000, + live: true, + }) + }) + + it('should reject non-canonical live values and invalid timestamps', () => { + const result = parseMonitoringQueryState({ + start: '123abc', + end: String(Number.MAX_SAFE_INTEGER), + live: 'true', + }) + + expect(result).toEqual({ + start: null, + end: null, + live: null, + }) + }) + }) + + describe('getSandboxLifecycleBounds', () => { + it('should clamp lifecycle anchor end to now for paused sandbox', () => { + const now = 1_700_000_000_000 + const sandboxInfo: SandboxDetailsDTO = { + templateID: 'template-id', + sandboxID: 'sandbox-id', + startedAt: new Date(now - 60_000).toISOString(), + endAt: new Date(now + 60_000).toISOString(), + envdVersion: '1.0.0', + cpuCount: 2, + memoryMB: 512, + diskSizeMB: 1_024, + state: 'paused', + } + + const bounds = getSandboxLifecycleBounds(sandboxInfo, now) + + expect(bounds?.startMs).toBe(now - 60_000) + expect(bounds?.anchorEndMs).toBe(now) + expect(bounds?.isRunning).toBe(false) + }) + + it('should fall back to now for running sandbox without endAt', () => { + const now = 1_700_000_000_000 + const sandboxInfo = { + startedAt: new Date(now - 60_000).toISOString(), + endAt: null, + state: 'running' as const, + } + + const bounds = getSandboxLifecycleBounds(sandboxInfo, now) + + expect(bounds?.startMs).toBe(now - 60_000) + expect(bounds?.anchorEndMs).toBe(now) + expect(bounds?.isRunning).toBe(true) + }) + + it('should use stoppedAt when endAt is null for killed sandbox', () => { + const now = 1_700_000_000_000 + const stoppedAt = now - 30_000 + const sandboxInfo: SandboxDetailsDTO = { + templateID: 'template-id', + sandboxID: 'sandbox-id', + startedAt: new Date(now - 60_000).toISOString(), + endAt: null, + stoppedAt: new Date(stoppedAt).toISOString(), + cpuCount: 2, + memoryMB: 512, + diskSizeMB: 1_024, + state: 'killed', + } + + const bounds = getSandboxLifecycleBounds(sandboxInfo, now) + + expect(bounds?.startMs).toBe(now - 60_000) + expect(bounds?.anchorEndMs).toBe(stoppedAt) + expect(bounds?.isRunning).toBe(false) + }) + + it('should fall back to now for killed sandbox without end timestamp', () => { + const now = 1_700_000_000_000 + const sandboxInfo: SandboxDetailsDTO = { + templateID: 'template-id', + sandboxID: 'sandbox-id', + startedAt: new Date(now - 60_000).toISOString(), + endAt: null, + stoppedAt: null, + cpuCount: 2, + memoryMB: 512, + diskSizeMB: 1_024, + state: 'killed', + } + + const bounds = getSandboxLifecycleBounds(sandboxInfo, now) + + expect(bounds?.startMs).toBe(now - 60_000) + expect(bounds?.anchorEndMs).toBe(now) + expect(bounds?.isRunning).toBe(false) + }) + }) +}) diff --git a/src/__test__/unit/time-range-picker-logic.test.ts b/src/__test__/unit/time-range-picker-logic.test.ts new file mode 100644 index 000000000..73f17cb6b --- /dev/null +++ b/src/__test__/unit/time-range-picker-logic.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import { + createTimeRangeSchema, + normalizeTimeRangeValues, + parsePickerDateTime, + parseTimeRangeValuesToTimestamps, + type TimeRangeValues, + validateTimeRangeValues, +} from '@/ui/time-range-picker.logic' + +const baseValues: TimeRangeValues = { + startDate: '2026/02/18', + startTime: '00:00:00', + endDate: '2026/02/24', + endTime: '23:59:59', +} + +describe('time-range-picker logic', () => { + describe('parsePickerDateTime', () => { + it('returns null when date is missing, even if time exists', () => { + const parsed = parsePickerDateTime('', '18:00:00', '23:59:59') + expect(parsed).toBeNull() + }) + + it('parses canonical and display date formats', () => { + const canonical = parsePickerDateTime( + '2026/02/24', + '18:17:41', + '23:59:59' + ) + const display = parsePickerDateTime( + '24 / 02 / 2026', + '18 : 17 : 41', + '23:59:59' + ) + + expect(canonical).not.toBeNull() + expect(display).not.toBeNull() + expect(canonical?.getTime()).toBe(display?.getTime()) + }) + }) + + describe('normalizeTimeRangeValues', () => { + it('normalizes date and time strings without changing semantic values', () => { + const normalized = normalizeTimeRangeValues({ + startDate: '18 / 02 / 2026', + startTime: '09 : 05', + endDate: '2026-02-24', + endTime: '23:59:59', + }) + + expect(normalized).toEqual({ + startDate: '2026/02/18', + startTime: '09:05:00', + endDate: '2026/02/24', + endTime: '23:59:59', + }) + }) + }) + + describe('validateTimeRangeValues', () => { + it('does not enforce an implicit max boundary', () => { + const validation = validateTimeRangeValues( + { + ...baseValues, + endDate: '2026/12/31', + endTime: '23:59:59', + }, + { + hideTime: false, + bounds: { + min: new Date(2023, 0, 1, 0, 0, 0), + }, + } + ) + + expect(validation.issues).toEqual([]) + }) + + it('validates against explicit max boundary', () => { + const validation = validateTimeRangeValues(baseValues, { + hideTime: false, + bounds: { + max: new Date(2026, 1, 24, 18, 17, 41), + }, + }) + + expect(validation.issues).toHaveLength(1) + expect(validation.issues[0]).toEqual( + expect.objectContaining({ + field: 'endDate', + }) + ) + expect(validation.issues[0]?.message).toContain( + 'End date cannot be after' + ) + }) + + it('validates end is not before start', () => { + const validation = validateTimeRangeValues( + { + ...baseValues, + startDate: '2026/02/24', + startTime: '20:00:00', + endDate: '2026/02/24', + endTime: '19:00:00', + }, + { + hideTime: false, + } + ) + + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'endDate', + message: 'End date cannot be before start date', + }), + ]) + ) + }) + }) + + describe('parseTimeRangeValuesToTimestamps', () => { + it('converts values to start and end timestamps with fallback times', () => { + const timestamps = parseTimeRangeValuesToTimestamps({ + startDate: '2026/02/18', + startTime: null, + endDate: '2026/02/24', + endTime: null, + }) + + expect(timestamps).not.toBeNull() + expect(timestamps?.start).toBe(new Date(2026, 1, 18, 0, 0, 0).getTime()) + expect(timestamps?.end).toBe(new Date(2026, 1, 24, 23, 59, 59).getTime()) + }) + }) + + describe('createTimeRangeSchema', () => { + it('applies the same boundary validation as logic helpers', () => { + const schema = createTimeRangeSchema({ + hideTime: false, + bounds: { + max: new Date(2026, 1, 24, 18, 17, 41), + }, + }) + + const parsed = schema.safeParse(baseValues) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues[0]?.message).toContain( + 'End date cannot be after' + ) + } + }) + }) +}) diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx new file mode 100644 index 000000000..249f11404 --- /dev/null +++ b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx @@ -0,0 +1 @@ +export { default } from '@/features/dashboard/loading-layout' diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx new file mode 100644 index 000000000..2b0dbc4b2 --- /dev/null +++ b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx @@ -0,0 +1,11 @@ +import SandboxMonitoringView from '@/features/dashboard/sandbox/monitoring/components/monitoring-view' + +export default async function SandboxMonitoringPage({ + params, +}: { + params: Promise<{ teamIdOrSlug: string; sandboxId: string }> +}) { + const { sandboxId } = await params + + return +} diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 972f17244..7dd981c13 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -32,6 +32,8 @@ export const PROTECTED_URLS = { `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/logs`, SANDBOX_FILESYSTEM: (teamIdOrSlug: string, sandboxId: string) => `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/filesystem`, + SANDBOX_MONITORING: (teamIdOrSlug: string, sandboxId: string) => + `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/monitoring`, SANDBOX_LOGS: (teamIdOrSlug: string, sandboxId: string) => `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/logs`, diff --git a/src/features/dashboard/sandbox/layout.tsx b/src/features/dashboard/sandbox/layout.tsx index a20876385..c8283c312 100644 --- a/src/features/dashboard/sandbox/layout.tsx +++ b/src/features/dashboard/sandbox/layout.tsx @@ -5,7 +5,7 @@ import { SANDBOX_INSPECT_MINIMUM_ENVD_VERSION } from '@/configs/versioning' import { useRouteParams } from '@/lib/hooks/use-route-params' import { isVersionCompatible } from '@/lib/utils/version' import { DashboardTab, DashboardTabs } from '@/ui/dashboard-tabs' -import { ListIcon, StorageIcon } from '@/ui/primitives/icons' +import { ListIcon, StorageIcon, TrendIcon } from '@/ui/primitives/icons' import { useSandboxContext } from './context' import SandboxInspectIncompatible from './inspect/incompatible' @@ -63,6 +63,14 @@ export default function SandboxLayout({ > {children} + } + > + {children} + + {header} +
{children}
+ + ) +} diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx new file mode 100644 index 000000000..d50995eaa --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx @@ -0,0 +1,258 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { cn } from '@/lib/utils' +import { CpuIcon, MemoryIcon, StorageIcon } from '@/ui/primitives/icons' +import { useSandboxMonitoringController } from '../state/use-sandbox-monitoring-controller' +import type { SandboxMetricsMarkerValueFormatterInput } from '../types/sandbox-metrics-chart' +import { buildMonitoringChartModel } from '../utils/chart-model' +import { + SANDBOX_MONITORING_CPU_SERIES_ID, + SANDBOX_MONITORING_DISK_SERIES_ID, + SANDBOX_MONITORING_PERCENT_MAX, + SANDBOX_MONITORING_RAM_SERIES_ID, +} from '../utils/constants' +import MonitoringChartSection from './monitoring-chart-section' +import DiskChartHeader from './monitoring-disk-chart-header' +import ResourceChartHeader from './monitoring-resource-chart-header' +import SandboxMetricsChart from './monitoring-sandbox-metrics-chart' +import SandboxMonitoringTimeRangeControls from './monitoring-time-range-controls' + +interface SandboxMetricsChartsProps { + sandboxId: string +} + +function formatMarkerPercent(value: number) { + return Math.round(value) +} + +function renderPercentMarker(value: number) { + return ( + <> + {formatMarkerPercent(value)} + % + + ) +} + +function renderUsageMarker(usedMb: number | null, value: number) { + const normalizedUsedMb = + usedMb === null || !Number.isFinite(usedMb) ? 0 : Math.round(usedMb) + + return ( + <> + {normalizedUsedMb.toLocaleString()} + MB + · + {formatMarkerPercent(value)} + % + + ) +} + +export default function SandboxMetricsCharts({ + sandboxId, +}: SandboxMetricsChartsProps) { + const { + metrics, + timeframe, + isLiveUpdating, + isRefetching, + setTimeframe, + setLiveUpdating, + lifecycleBounds, + } = useSandboxMonitoringController(sandboxId) + const [hoveredTimestampMs, setHoveredTimestampMs] = useState( + null + ) + const [renderedTimeframe, setRenderedTimeframe] = useState(() => ({ + start: timeframe.start, + end: timeframe.end, + })) + + const handleTimeRangeChange = useCallback( + ( + startTimestamp: number, + endTimestamp: number, + options?: { isLiveUpdating?: boolean } + ) => { + const nextLiveUpdating = options?.isLiveUpdating ?? false + + if ( + startTimestamp === timeframe.start && + endTimestamp === timeframe.end && + nextLiveUpdating === isLiveUpdating + ) { + return + } + + setHoveredTimestampMs(null) + setTimeframe(startTimestamp, endTimestamp, { + isLiveUpdating: nextLiveUpdating, + }) + }, + [isLiveUpdating, setTimeframe, timeframe.end, timeframe.start] + ) + + const chartModel = useMemo( + () => + buildMonitoringChartModel({ + metrics, + startMs: renderedTimeframe.start, + endMs: renderedTimeframe.end, + hoveredTimestampMs, + }), + [ + hoveredTimestampMs, + metrics, + renderedTimeframe.end, + renderedTimeframe.start, + ] + ) + const resourceSeriesWithMarkerFormatters = useMemo( + () => + chartModel.resourceSeries.map((line) => { + if (line.id === SANDBOX_MONITORING_CPU_SERIES_ID) { + return { + ...line, + markerValueFormatter: ({ + value, + }: SandboxMetricsMarkerValueFormatterInput) => ( +
+ {renderPercentMarker(value)} + +
+ ), + } + } + + if (line.id === SANDBOX_MONITORING_RAM_SERIES_ID) { + return { + ...line, + markerValueFormatter: ({ + markerValue, + value, + }: SandboxMetricsMarkerValueFormatterInput) => ( +
+ {renderUsageMarker(markerValue, value)} + +
+ ), + } + } + + return line + }), + [chartModel.resourceSeries] + ) + const diskSeriesWithMarkerFormatters = useMemo( + () => + chartModel.diskSeries.map((line) => { + if (line.id !== SANDBOX_MONITORING_DISK_SERIES_ID) { + return line + } + + return { + ...line, + markerValueFormatter: ({ + markerValue, + value, + }: SandboxMetricsMarkerValueFormatterInput) => ( +
+ {renderUsageMarker(markerValue, value)} + +
+ ), + } + }), + [chartModel.diskSeries] + ) + + useEffect(() => { + if (isRefetching) { + return + } + + setRenderedTimeframe((previous) => { + if ( + previous.start === timeframe.start && + previous.end === timeframe.end + ) { + return previous + } + + return { + start: timeframe.start, + end: timeframe.end, + } + }) + }, [isRefetching, timeframe.end, timeframe.start]) + + const handleHoverEnd = useCallback(() => { + setHoveredTimestampMs(null) + }, []) + + return ( +
+ {lifecycleBounds ? ( +
+ +
+ ) : null} + + + } + > + + + + + } + > + + +
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-disk-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-disk-chart-header.tsx new file mode 100644 index 000000000..4386bf44e --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-disk-chart-header.tsx @@ -0,0 +1,68 @@ +'use client' + +import { cn } from '@/lib/utils' +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { StorageIcon } from '@/ui/primitives/icons' +import { + SANDBOX_MONITORING_DISK_INDICATOR_CLASS, + SANDBOX_MONITORING_DISK_SERIES_LABEL, +} from '../utils/constants' +import { + calculateRatioPercent, + formatBytesToGb, + formatHoverTimestamp, + formatMetricValue, + formatPercent, +} from '../utils/formatters' + +interface DiskChartHeaderProps { + metric?: SandboxMetric + hovered?: { + diskPercent: number | null + timestampMs: number + } | null +} + +export default function DiskChartHeader({ + metric, + hovered, +}: DiskChartHeaderProps) { + const diskPercent = hovered + ? hovered.diskPercent + : metric + ? calculateRatioPercent(metric.diskUsed, metric.diskTotal) + : 0 + + const diskTotalGb = formatBytesToGb(metric?.diskTotal ?? 0) + const contextLabel = hovered + ? formatHoverTimestamp(hovered.timestampMs) + : null + + return ( +
+
+ + + + + {SANDBOX_MONITORING_DISK_SERIES_LABEL} + + + + {formatMetricValue(formatPercent(diskPercent), diskTotalGb)} + + +
+ {contextLabel ? ( + + {contextLabel} + + ) : null} +
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx new file mode 100644 index 000000000..de86d9576 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx @@ -0,0 +1,109 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { CpuIcon, MemoryIcon } from '@/ui/primitives/icons' +import { + SANDBOX_MONITORING_CPU_INDICATOR_CLASS, + SANDBOX_MONITORING_CPU_SERIES_LABEL, + SANDBOX_MONITORING_RAM_INDICATOR_CLASS, + SANDBOX_MONITORING_RAM_SERIES_LABEL, +} from '../utils/constants' +import { + calculateRatioPercent, + clampPercent, + formatBytesToGb, + formatCoreCount, + formatHoverTimestamp, + formatMetricValue, + formatPercent, +} from '../utils/formatters' + +interface ResourceChartHeaderProps { + metric?: SandboxMetric + hovered?: { + cpuPercent: number | null + ramPercent: number | null + timestampMs: number + } | null +} + +interface MetricItemProps { + label: string + value: string + indicatorClassName: string + icon: ReactNode +} + +function MetricItem({ + label, + value, + indicatorClassName, + icon, +}: MetricItemProps) { + return ( +
+ + {icon} + + {label} + + {value} + +
+ ) +} + +export default function ResourceChartHeader({ + metric, + hovered, +}: ResourceChartHeaderProps) { + const cpuPercent = hovered + ? hovered.cpuPercent + : clampPercent(metric?.cpuUsedPct ?? 0) + const cpuValue = formatMetricValue( + formatPercent(cpuPercent), + formatCoreCount(metric?.cpuCount ?? 0) + ) + + const ramPercent = hovered + ? hovered.ramPercent + : metric + ? calculateRatioPercent(metric.memUsed, metric.memTotal) + : 0 + const ramTotalGb = formatBytesToGb(metric?.memTotal ?? 0) + const ramValue = formatMetricValue(formatPercent(ramPercent), ramTotalGb) + const contextLabel = hovered + ? formatHoverTimestamp(hovered.timestampMs) + : null + + return ( +
+
+ } + /> + + } + /> +
+
+ {contextLabel ? ( + + {contextLabel} + + ) : null} +
+
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx new file mode 100644 index 000000000..dd62f71ca --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx @@ -0,0 +1,862 @@ +'use client' + +import type { + EChartsOption, + MarkPointComponentOption, + SeriesOption, +} from 'echarts' +import { LineChart } from 'echarts/charts' +import { + AxisPointerComponent, + BrushComponent, + GridComponent, + MarkPointComponent, +} from 'echarts/components' +import * as echarts from 'echarts/core' +import { SVGRenderer } from 'echarts/renderers' +import ReactEChartsCore from 'echarts-for-react/lib/core' +import { useTheme } from 'next-themes' +import { + memo, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + SANDBOX_MONITORING_CHART_AREA_OPACITY, + SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE, + SANDBOX_MONITORING_CHART_BRUSH_MODE, + SANDBOX_MONITORING_CHART_BRUSH_TYPE, + SANDBOX_MONITORING_CHART_FALLBACK_FG_TERTIARY, + SANDBOX_MONITORING_CHART_FALLBACK_FONT_MONO, + SANDBOX_MONITORING_CHART_FALLBACK_STROKE, + SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR, + SANDBOX_MONITORING_CHART_FONT_MONO_VAR, + SANDBOX_MONITORING_CHART_GRID_BOTTOM, + SANDBOX_MONITORING_CHART_GRID_BOTTOM_WITH_X_AXIS, + SANDBOX_MONITORING_CHART_GRID_RIGHT, + SANDBOX_MONITORING_CHART_GRID_TOP, + SANDBOX_MONITORING_CHART_GROUP, + SANDBOX_MONITORING_CHART_LINE_WIDTH, + SANDBOX_MONITORING_CHART_LIVE_INNER_DOT_SIZE, + SANDBOX_MONITORING_CHART_LIVE_MIDDLE_DOT_SIZE, + SANDBOX_MONITORING_CHART_LIVE_OUTER_DOT_SIZE, + SANDBOX_MONITORING_CHART_LIVE_WINDOW_MS, + SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA, + SANDBOX_MONITORING_CHART_STROKE_VAR, + SANDBOX_MONITORING_CHART_Y_AXIS_SCALE_FACTOR, +} from '@/features/dashboard/sandbox/monitoring/utils/constants' +import { useCssVars } from '@/lib/hooks/use-css-vars' +import { cn } from '@/lib/utils' +import { calculateAxisMax } from '@/lib/utils/chart' +import { formatAxisNumber } from '@/lib/utils/formatting' +import type { + SandboxMetricsChartProps, + SandboxMetricsDataPoint, +} from '../types/sandbox-metrics-chart' + +echarts.use([ + LineChart, + GridComponent, + BrushComponent, + MarkPointComponent, + SVGRenderer, + AxisPointerComponent, +]) + +interface AxisPointerInfo { + value?: unknown +} + +interface UpdateAxisPointerEventParams { + axesInfo?: AxisPointerInfo[] + xAxisInfo?: AxisPointerInfo[] + value?: unknown +} + +interface BrushArea { + coordRange?: [unknown, unknown] | unknown[] +} + +interface BrushEndEventParams { + areas?: BrushArea[] +} + +interface CrosshairMarker { + key: string + xPx: number + yPx: number + valueContent: ReactNode + dotColor: string + placeValueOnRight: boolean + labelOffsetYPx: number +} + +const SANDBOX_MONITORING_CHART_FG_VAR = '--fg' +const SANDBOX_MONITORING_CHART_MARKER_RIGHT_THRESHOLD_PX = 86 +const SANDBOX_MONITORING_CHART_MARKER_OVERLAP_THRESHOLD_PX = 24 +const SANDBOX_MONITORING_CHART_MARKER_LABEL_VERTICAL_GAP_PX = 20 + +function withOpacity(color: string, opacity: number): string { + const normalizedOpacity = Math.max(0, Math.min(1, opacity)) + const hex = color.trim() + + if (!hex.startsWith('#')) { + return color + } + + const value = hex.slice(1) + const expanded = + value.length === 3 + ? value + .split('') + .map((char) => `${char}${char}`) + .join('') + : value + + if (expanded.length !== 6 && expanded.length !== 8) { + return color + } + + const r = Number.parseInt(expanded.slice(0, 2), 16) + const g = Number.parseInt(expanded.slice(2, 4), 16) + const b = Number.parseInt(expanded.slice(4, 6), 16) + + if ([r, g, b].some((value) => Number.isNaN(value))) { + return color + } + + return `rgba(${r}, ${g}, ${b}, ${normalizedOpacity})` +} + +function normalizeOpacity( + opacity: number | undefined, + fallback: number +): number { + if (opacity === undefined || !Number.isFinite(opacity)) { + return fallback + } + + return Math.max(0, Math.min(1, opacity)) +} + +function toNumericValue(value: unknown): number { + if (typeof value === 'number') { + return value + } + + if (typeof value === 'string') { + return Number(value) + } + + return Number.NaN +} + +function formatXAxisLabel( + value: number | string, + includeSeconds: boolean = false +): string { + const timestamp = Number(value) + if (Number.isNaN(timestamp)) { + return '' + } + + const date = new Date(timestamp) + const hours = date.getHours().toString().padStart(2, '0') + const minutes = date.getMinutes().toString().padStart(2, '0') + const base = `${hours}:${minutes}` + + if (!includeSeconds) { + return base + } + + const seconds = date.getSeconds().toString().padStart(2, '0') + + return `${base}:${seconds}` +} + +function findLivePoint( + data: SandboxMetricsDataPoint[], + now: number = Date.now() +): { x: number; y: number } | null { + const liveBoundary = now - SANDBOX_MONITORING_CHART_LIVE_WINDOW_MS + + for (let index = data.length - 1; index >= 0; index -= 1) { + const point = data[index] + if (!point) { + continue + } + + const [timestamp, value] = point + if (typeof value !== 'number' || !Number.isFinite(timestamp)) { + continue + } + + if (timestamp > now) { + continue + } + + if (timestamp < liveBoundary) { + return null + } + + return { + x: timestamp, + y: value, + } + } + + return null +} + +function findClosestValidPoint( + points: SandboxMetricsDataPoint[], + targetTimestampMs: number +): { timestampMs: number; value: number; markerValue: number | null } | null { + let closestPoint: { + timestampMs: number + value: number + markerValue: number | null + } | null = null + let closestDistance = Number.POSITIVE_INFINITY + + for (const point of points) { + if (!point) { + continue + } + + const [timestampMs, value, markerValue] = point + if (value === null || !Number.isFinite(timestampMs)) { + continue + } + + const distance = Math.abs(timestampMs - targetTimestampMs) + if (distance >= closestDistance) { + continue + } + + closestDistance = distance + closestPoint = { + timestampMs, + value, + markerValue: markerValue ?? null, + } + } + + return closestPoint +} + +function findFirstValidPointTimestampMs( + points: SandboxMetricsDataPoint[] +): number | null { + for (const point of points) { + if (!point) { + continue + } + + const [timestampMs, value] = point + if (value === null || !Number.isFinite(timestampMs)) { + continue + } + + return timestampMs + } + + return null +} + +function applyMarkerLabelOffsets( + markers: CrosshairMarker[] +): CrosshairMarker[] { + if (markers.length < 2) { + return markers + } + + const sortedMarkers = [...markers].sort((a, b) => a.yPx - b.yPx) + const offsetsByMarkerKey = new Map() + let clusterStart = 0 + + for (let index = 1; index <= sortedMarkers.length; index += 1) { + const previousMarker = sortedMarkers[index - 1] + const currentMarker = sortedMarkers[index] + if (!previousMarker) { + continue + } + + const shouldSplitCluster = + !currentMarker || + Math.abs(currentMarker.yPx - previousMarker.yPx) > + SANDBOX_MONITORING_CHART_MARKER_OVERLAP_THRESHOLD_PX + + if (!shouldSplitCluster) { + continue + } + + const cluster = sortedMarkers.slice(clusterStart, index) + const halfIndex = (cluster.length - 1) / 2 + + cluster.forEach((marker, clusterIndex) => { + const offset = + (clusterIndex - halfIndex) * + SANDBOX_MONITORING_CHART_MARKER_LABEL_VERTICAL_GAP_PX + offsetsByMarkerKey.set(marker.key, offset) + }) + + clusterStart = index + } + + return markers.map((marker) => ({ + ...marker, + labelOffsetYPx: offsetsByMarkerKey.get(marker.key) ?? marker.labelOffsetYPx, + })) +} + +function createLiveIndicators( + point: { x: number; y: number }, + lineColor: string +) { + return { + silent: true, + animation: false, + data: [ + { + coord: [point.x, point.y], + symbol: 'circle', + symbolSize: SANDBOX_MONITORING_CHART_LIVE_OUTER_DOT_SIZE, + itemStyle: { + color: 'transparent', + borderColor: lineColor, + borderWidth: 1, + shadowBlur: 8, + shadowColor: lineColor, + opacity: 0.4, + }, + emphasis: { disabled: true }, + label: { show: false }, + }, + { + coord: [point.x, point.y], + symbol: 'circle', + symbolSize: SANDBOX_MONITORING_CHART_LIVE_MIDDLE_DOT_SIZE, + itemStyle: { + color: lineColor, + opacity: 0.3, + borderWidth: 0, + }, + emphasis: { disabled: true }, + label: { show: false }, + }, + { + coord: [point.x, point.y], + symbol: 'circle', + symbolSize: SANDBOX_MONITORING_CHART_LIVE_INNER_DOT_SIZE, + itemStyle: { + color: lineColor, + borderWidth: 0, + shadowBlur: 4, + shadowColor: lineColor, + }, + emphasis: { disabled: true }, + label: { show: false }, + }, + ], + } +} + +function SandboxMetricsChart({ + series, + hoveredTimestampMs = null, + className, + showXAxisLabels = true, + yAxisMax, + yAxisFormatter = formatAxisNumber, + onHover, + onHoverEnd, + onBrushEnd, +}: SandboxMetricsChartProps) { + const chartInstanceRef = useRef(null) + const [chartRevision, setChartRevision] = useState(0) + const { resolvedTheme } = useTheme() + + const cssVarNames = useMemo(() => { + const dynamicVarNames = series.flatMap((line) => + [line.lineColorVar, line.areaColorVar, line.areaToColorVar].filter( + (name): name is string => Boolean(name) + ) + ) + + return Array.from( + new Set([ + SANDBOX_MONITORING_CHART_STROKE_VAR, + SANDBOX_MONITORING_CHART_FG_VAR, + SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR, + SANDBOX_MONITORING_CHART_FONT_MONO_VAR, + ...dynamicVarNames, + ]) + ) + }, [series]) + + const cssVars = useCssVars(cssVarNames) + + const stroke = + cssVars[SANDBOX_MONITORING_CHART_STROKE_VAR] || + SANDBOX_MONITORING_CHART_FALLBACK_STROKE + const fgTertiary = + cssVars[SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR] || + SANDBOX_MONITORING_CHART_FALLBACK_FG_TERTIARY + const fg = cssVars[SANDBOX_MONITORING_CHART_FG_VAR] || stroke + const axisPointerColor = withOpacity(fg, 0.7) + const fontMono = + cssVars[SANDBOX_MONITORING_CHART_FONT_MONO_VAR] || + SANDBOX_MONITORING_CHART_FALLBACK_FONT_MONO + + const handleUpdateAxisPointer = useCallback( + (params: UpdateAxisPointerEventParams) => { + if (!onHover) { + return + } + + const pointerValue = + params.axesInfo?.[0]?.value ?? + params.xAxisInfo?.[0]?.value ?? + params.value + const timestampMs = toNumericValue(pointerValue) + if (Number.isNaN(timestampMs)) { + return + } + + const normalizedTimestampMs = Math.floor(timestampMs) + onHover(normalizedTimestampMs) + }, + [onHover] + ) + + const clearAxisPointer = useCallback(() => { + chartInstanceRef.current?.dispatchAction({ type: 'hideTip' }) + chartInstanceRef.current?.dispatchAction({ + type: 'updateAxisPointer', + currTrigger: 'leave', + }) + }, []) + + const handleHoverLeave = useCallback(() => { + clearAxisPointer() + onHoverEnd?.() + }, [clearAxisPointer, onHoverEnd]) + + useEffect(() => { + if (hoveredTimestampMs !== null) { + return + } + + clearAxisPointer() + }, [clearAxisPointer, hoveredTimestampMs]) + + const handleBrushEnd = useCallback( + (params: BrushEndEventParams) => { + const coordRange = params.areas?.[0]?.coordRange + if (!coordRange || coordRange.length !== 2 || !onBrushEnd) { + return + } + + const startTimestamp = toNumericValue(coordRange[0]) + const endTimestamp = toNumericValue(coordRange[1]) + if (Number.isNaN(startTimestamp) || Number.isNaN(endTimestamp)) { + return + } + + onBrushEnd( + Math.floor(Math.min(startTimestamp, endTimestamp)), + Math.floor(Math.max(startTimestamp, endTimestamp)) + ) + + chartInstanceRef.current?.dispatchAction({ + type: 'brush', + command: 'clear', + areas: [], + }) + }, + [onBrushEnd] + ) + + const handleChartReady = useCallback((chart: echarts.ECharts) => { + chartInstanceRef.current = chart + setChartRevision((v) => v + 1) + + chart.dispatchAction( + { + type: 'takeGlobalCursor', + key: 'brush', + brushOption: { + brushType: SANDBOX_MONITORING_CHART_BRUSH_TYPE, + brushMode: SANDBOX_MONITORING_CHART_BRUSH_MODE, + }, + }, + { flush: true } + ) + + chart.group = SANDBOX_MONITORING_CHART_GROUP + echarts.connect(SANDBOX_MONITORING_CHART_GROUP) + }, []) + + const option = useMemo(() => { + const values = series.flatMap((line) => + line.data + .map((point) => point[1]) + .filter((value): value is number => value !== null) + ) + const computedYAxisMax = + yAxisMax ?? + calculateAxisMax( + values.length > 0 ? values : [0], + SANDBOX_MONITORING_CHART_Y_AXIS_SCALE_FACTOR + ) + + const seriesItems: SeriesOption[] = series.map((line) => { + const lineColor = line.lineColorVar + ? cssVars[line.lineColorVar] + : undefined + const areaFromColor = line.areaColorVar + ? cssVars[line.areaColorVar] + : undefined + const areaToColor = line.areaToColorVar + ? cssVars[line.areaToColorVar] + : undefined + const resolvedLineColor = lineColor || stroke + const livePoint = findLivePoint(line.data) + const shouldShowArea = line.showArea ?? false + const areaFillColor = + areaFromColor && areaToColor + ? { + type: 'linear' as const, + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: areaFromColor }, + { offset: 1, color: areaToColor }, + ], + } + : areaFromColor || resolvedLineColor + const defaultAreaOpacity = + areaFromColor || areaToColor ? 1 : SANDBOX_MONITORING_CHART_AREA_OPACITY + const areaOpacity = normalizeOpacity(line.areaOpacity, defaultAreaOpacity) + + const seriesItem: SeriesOption = { + id: line.id, + name: line.name, + type: 'line', + z: line.zIndex, + symbol: 'none', + showSymbol: false, + smooth: false, + emphasis: { + disabled: true, + }, + areaStyle: shouldShowArea + ? { + opacity: areaOpacity, + color: areaFillColor, + } + : undefined, + lineStyle: { + width: SANDBOX_MONITORING_CHART_LINE_WIDTH, + color: resolvedLineColor, + }, + connectNulls: false, + data: line.data, + } + + if (livePoint) { + seriesItem.markPoint = createLiveIndicators( + livePoint, + resolvedLineColor + ) as MarkPointComponentOption + } + + return seriesItem + }) + + return { + backgroundColor: 'transparent', + animation: false, + brush: { + brushType: SANDBOX_MONITORING_CHART_BRUSH_TYPE, + brushMode: SANDBOX_MONITORING_CHART_BRUSH_MODE, + xAxisIndex: 0, + brushLink: 'all', + brushStyle: { borderWidth: SANDBOX_MONITORING_CHART_LINE_WIDTH }, + outOfBrush: { colorAlpha: SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA }, + }, + grid: { + top: SANDBOX_MONITORING_CHART_GRID_TOP, + bottom: showXAxisLabels + ? SANDBOX_MONITORING_CHART_GRID_BOTTOM_WITH_X_AXIS + : SANDBOX_MONITORING_CHART_GRID_BOTTOM, + left: 36, + right: SANDBOX_MONITORING_CHART_GRID_RIGHT, + }, + xAxis: { + type: 'time', + boundaryGap: [0, 0], + axisLine: { show: true, lineStyle: { color: stroke } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { + show: showXAxisLabels, + color: fgTertiary, + fontFamily: fontMono, + fontSize: SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE, + hideOverlap: true, + formatter: (value: number | string) => formatXAxisLabel(value), + }, + axisPointer: { + show: true, + type: 'line', + lineStyle: { + color: axisPointerColor, + type: 'solid', + width: SANDBOX_MONITORING_CHART_LINE_WIDTH, + }, + snap: true, + label: { + show: false, + }, + }, + }, + yAxis: { + type: 'value', + min: 0, + max: computedYAxisMax, + interval: computedYAxisMax / 2, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { color: stroke, type: 'dashed' }, + interval: 0, + }, + axisLabel: { + show: true, + color: fgTertiary, + fontFamily: fontMono, + fontSize: SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE, + interval: 0, + formatter: yAxisFormatter, + }, + axisPointer: { show: false }, + }, + series: seriesItems, + } + }, [ + cssVars, + axisPointerColor, + fgTertiary, + fontMono, + series, + showXAxisLabels, + stroke, + yAxisFormatter, + yAxisMax, + ]) + + const crosshairMarkers = useMemo(() => { + void chartRevision + + if (hoveredTimestampMs === null) { + return [] + } + + const chart = chartInstanceRef.current + if (!chart || chart.isDisposed()) { + return [] + } + + const firstTimestamps = series + .map((line) => findFirstValidPointTimestampMs(line.data)) + .filter((value): value is number => value !== null) + const firstTimestampMs = + firstTimestamps.length > 0 ? Math.min(...firstTimestamps) : null + const firstPointPixel = + firstTimestampMs !== null + ? chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ + firstTimestampMs, + 0, + ]) + : null + const firstPointPx = + Array.isArray(firstPointPixel) && + firstPointPixel.length > 0 && + typeof firstPointPixel[0] === 'number' && + Number.isFinite(firstPointPixel[0]) + ? firstPointPixel[0] + : null + + const markers = series.flatMap((line) => { + const closestPoint = findClosestValidPoint(line.data, hoveredTimestampMs) + if (!closestPoint) { + return [] + } + + const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ + closestPoint.timestampMs, + closestPoint.value, + ]) + if (!Array.isArray(pixel) || pixel.length < 2) { + return [] + } + + const xPx = pixel[0] + const yPx = pixel[1] + if ( + typeof xPx !== 'number' || + typeof yPx !== 'number' || + !Number.isFinite(xPx) || + !Number.isFinite(yPx) + ) { + return [] + } + + return [ + { + key: `${line.id}-${closestPoint.timestampMs}`, + xPx, + yPx, + valueContent: line.markerValueFormatter + ? line.markerValueFormatter({ + value: closestPoint.value, + markerValue: closestPoint.markerValue, + point: [ + closestPoint.timestampMs, + closestPoint.value, + closestPoint.markerValue, + ], + }) + : yAxisFormatter(closestPoint.value), + dotColor: line.lineColorVar + ? (cssVars[line.lineColorVar] ?? stroke) + : stroke, + placeValueOnRight: + firstPointPx !== null && + xPx - firstPointPx <= + SANDBOX_MONITORING_CHART_MARKER_RIGHT_THRESHOLD_PX, + labelOffsetYPx: 0, + }, + ] + }) + + return applyMarkerLabelOffsets(markers) + }, [ + chartRevision, + cssVars, + hoveredTimestampMs, + series, + stroke, + yAxisFormatter, + ]) + + const xAxisHoverBadge = useMemo(() => { + void chartRevision + + if (!showXAxisLabels || hoveredTimestampMs === null) { + return null + } + + const chart = chartInstanceRef.current + if (!chart || chart.isDisposed()) { + return null + } + + const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ + hoveredTimestampMs, + 0, + ]) + if (!Array.isArray(pixel) || pixel.length < 1) { + return null + } + + const xPx = pixel[0] + if (typeof xPx !== 'number' || !Number.isFinite(xPx)) { + return null + } + + return { + xPx, + label: formatXAxisLabel(hoveredTimestampMs, true), + } + }, [chartRevision, hoveredTimestampMs, showXAxisLabels]) + + const showOverlay = crosshairMarkers.length > 0 || xAxisHoverBadge !== null + + return ( +
+ + {showOverlay ? ( +
+ {crosshairMarkers.map((marker) => ( +
+ +
+ {marker.valueContent} +
+
+ ))} + + {xAxisHoverBadge ? ( +
+ {xAxisHoverBadge.label} +
+ ) : null} +
+ ) : null} +
+ ) +} + +const MemoizedSandboxMetricsChart = memo(SandboxMetricsChart) + +MemoizedSandboxMetricsChart.displayName = 'SandboxMetricsChart' + +export default MemoizedSandboxMetricsChart diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx new file mode 100644 index 000000000..92c1b9340 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx @@ -0,0 +1,522 @@ +'use client' + +import { millisecondsInHour, millisecondsInMinute } from 'date-fns/constants' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cn } from '@/lib/utils' +import { findMatchingPreset } from '@/lib/utils/time-range' +import { LiveDot } from '@/ui/live' +import { Button } from '@/ui/primitives/button' +import { TimeIcon } from '@/ui/primitives/icons' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/ui/primitives/popover' +import { Separator } from '@/ui/primitives/separator' +import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker' +import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' +import { type TimeRangePreset, TimeRangePresets } from '@/ui/time-range-presets' +import { + SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_ID, + SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_SHORTCUT, + SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID, + SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_SHORTCUT, + SANDBOX_MONITORING_FIRST_HOUR_PRESET_ID, + SANDBOX_MONITORING_FIRST_HOUR_PRESET_SHORTCUT, + SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID, + SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_SHORTCUT, + SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_ID, + SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_SHORTCUT, + SANDBOX_MONITORING_LAST_6_HOURS_PRESET_ID, + SANDBOX_MONITORING_LAST_6_HOURS_PRESET_SHORTCUT, + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID, + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_SHORTCUT, + SANDBOX_MONITORING_LAST_HOUR_PRESET_ID, + SANDBOX_MONITORING_LAST_HOUR_PRESET_SHORTCUT, + SANDBOX_MONITORING_MAX_HISTORY_ENTRIES, + SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS, + SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, +} from '../utils/constants' +import { + clampTimeframeToBounds, + type SandboxLifecycleBounds, +} from '../utils/timeframe' + +function isValidDate(date: Date): boolean { + return Number.isFinite(date.getTime()) +} + +function toSafeIsoDateTime( + timestampMs: number, + fallbackTimestampMs: number = Date.now() +): string { + const candidate = new Date(timestampMs) + if (isValidDate(candidate)) { + return candidate.toISOString() + } + + return new Date(fallbackTimestampMs).toISOString() +} + +const rangeLabelFormatter = new Intl.DateTimeFormat( + undefined, + SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS +) + +interface SandboxMonitoringTimeRangeControlsProps { + timeframe: { + start: number + end: number + } + lifecycle: SandboxLifecycleBounds + isLiveUpdating: boolean + onLiveChange: (isLiveUpdating: boolean) => void + onTimeRangeChange: ( + start: number, + end: number, + options?: { isLiveUpdating?: boolean } + ) => void + className?: string +} + +interface TimeRangeHistoryEntry { + start: number + end: number + isLiveUpdating: boolean +} + +interface TimeRangeHistoryState { + entries: TimeRangeHistoryEntry[] + index: number +} + +function isSameHistoryEntry( + a: TimeRangeHistoryEntry | undefined, + b: TimeRangeHistoryEntry +): boolean { + if (!a) { + return false + } + + return ( + a.start === b.start && + a.end === b.end && + a.isLiveUpdating === b.isLiveUpdating + ) +} + +export default function SandboxMonitoringTimeRangeControls({ + timeframe, + lifecycle, + isLiveUpdating, + onLiveChange, + onTimeRangeChange, + className, +}: SandboxMonitoringTimeRangeControlsProps) { + const [isOpen, setIsOpen] = useState(false) + const [pickerMaxDateMs, setPickerMaxDateMs] = useState(() => Date.now()) + const [historyState, setHistoryState] = useState( + () => ({ + entries: [ + { + start: timeframe.start, + end: timeframe.end, + isLiveUpdating, + }, + ], + index: 0, + }) + ) + const isHistoryNavigationRef = useRef(false) + + const clampToLifecycle = useCallback( + (start: number, end: number) => { + const maxBoundMs = lifecycle.isRunning + ? Date.now() + : lifecycle.anchorEndMs + + return clampTimeframeToBounds(start, end, lifecycle.startMs, maxBoundMs) + }, + [lifecycle.anchorEndMs, lifecycle.isRunning, lifecycle.startMs] + ) + + const presets = useMemo(() => { + const makeTrailing = ( + id: string, + label: string, + shortcut: string, + rangeMs: number + ): TimeRangePreset => ({ + id, + label, + shortcut, + isLiveUpdating: lifecycle.isRunning, + getValue: () => { + const anchorEndMs = lifecycle.isRunning + ? Date.now() + : lifecycle.anchorEndMs + const lifecycleDuration = anchorEndMs - lifecycle.startMs + + return clampToLifecycle( + anchorEndMs - Math.min(rangeMs, lifecycleDuration), + anchorEndMs + ) + }, + }) + + const makeLeading = ( + id: string, + label: string, + shortcut: string, + rangeMs: number + ): TimeRangePreset => ({ + id, + label, + shortcut, + isLiveUpdating: false, + getValue: () => { + const anchorEndMs = lifecycle.isRunning + ? Date.now() + : lifecycle.anchorEndMs + const lifecycleDuration = anchorEndMs - lifecycle.startMs + + return clampToLifecycle( + lifecycle.startMs, + lifecycle.startMs + Math.min(rangeMs, lifecycleDuration) + ) + }, + }) + + return [ + { + id: SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID, + label: lifecycle.isRunning ? 'From start to now' : 'Full lifecycle', + shortcut: SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_SHORTCUT, + isLiveUpdating: lifecycle.isRunning, + getValue: () => { + const anchorEndMs = lifecycle.isRunning + ? Date.now() + : lifecycle.anchorEndMs + return clampToLifecycle(lifecycle.startMs, anchorEndMs) + }, + }, + makeLeading( + SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_ID, + 'First 5 min', + SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_SHORTCUT, + 5 * millisecondsInMinute + ), + makeLeading( + SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID, + 'First 15 min', + SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_SHORTCUT, + 15 * millisecondsInMinute + ), + makeLeading( + SANDBOX_MONITORING_FIRST_HOUR_PRESET_ID, + 'First 1 hour', + SANDBOX_MONITORING_FIRST_HOUR_PRESET_SHORTCUT, + millisecondsInHour + ), + makeTrailing( + SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_ID, + 'Last 5 min', + SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_SHORTCUT, + 5 * millisecondsInMinute + ), + makeTrailing( + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID, + 'Last 15 min', + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_SHORTCUT, + 15 * millisecondsInMinute + ), + makeTrailing( + SANDBOX_MONITORING_LAST_HOUR_PRESET_ID, + 'Last 1 hour', + SANDBOX_MONITORING_LAST_HOUR_PRESET_SHORTCUT, + millisecondsInHour + ), + makeTrailing( + SANDBOX_MONITORING_LAST_6_HOURS_PRESET_ID, + 'Last 6 hours', + SANDBOX_MONITORING_LAST_6_HOURS_PRESET_SHORTCUT, + 6 * millisecondsInHour + ), + ] + }, [ + clampToLifecycle, + lifecycle.anchorEndMs, + lifecycle.isRunning, + lifecycle.startMs, + ]) + + const selectedPresetId = useMemo( + () => + findMatchingPreset( + presets, + timeframe.start, + timeframe.end, + SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS + ), + [presets, timeframe.end, timeframe.start] + ) + + const rangeLabel = useMemo(() => { + const startDate = new Date(timeframe.start) + const endDate = new Date(timeframe.end) + if (!isValidDate(startDate) || !isValidDate(endDate)) { + return '--' + } + + return `${rangeLabelFormatter.format(startDate)} - ${rangeLabelFormatter.format( + endDate + )}` + }, [timeframe.end, timeframe.start]) + + useEffect(() => { + if (isOpen && lifecycle.isRunning) { + setPickerMaxDateMs(Date.now()) + } + }, [isOpen, lifecycle.isRunning]) + + useEffect(() => { + const snapshot: TimeRangeHistoryEntry = { + start: timeframe.start, + end: timeframe.end, + isLiveUpdating, + } + + setHistoryState((previous) => { + const currentEntry = previous.entries[previous.index] + + if (isSameHistoryEntry(currentEntry, snapshot)) { + isHistoryNavigationRef.current = false + return previous + } + + if (isHistoryNavigationRef.current) { + isHistoryNavigationRef.current = false + const nextEntries = [...previous.entries] + nextEntries[previous.index] = snapshot + return { + entries: nextEntries, + index: previous.index, + } + } + + if (currentEntry?.isLiveUpdating && snapshot.isLiveUpdating) { + return previous + } + + const trimmedEntries = previous.entries.slice(0, previous.index + 1) + const lastEntry = trimmedEntries[trimmedEntries.length - 1] + if (isSameHistoryEntry(lastEntry, snapshot)) { + return { + entries: trimmedEntries, + index: trimmedEntries.length - 1, + } + } + + const nextEntries = [...trimmedEntries, snapshot] + const overflow = + nextEntries.length - SANDBOX_MONITORING_MAX_HISTORY_ENTRIES + if (overflow > 0) { + return { + entries: nextEntries.slice(overflow), + index: trimmedEntries.length - overflow, + } + } + + return { + entries: nextEntries, + index: trimmedEntries.length, + } + }) + }, [isLiveUpdating, timeframe.end, timeframe.start]) + + const pickerMaxDate = useMemo( + () => + lifecycle.isRunning + ? new Date(pickerMaxDateMs) + : new Date(lifecycle.anchorEndMs), + [lifecycle.anchorEndMs, lifecycle.isRunning, pickerMaxDateMs] + ) + + const pickerBounds = useMemo( + () => ({ + min: new Date(lifecycle.startMs), + max: pickerMaxDate, + }), + [lifecycle.startMs, pickerMaxDate] + ) + + const handlePresetSelect = useCallback( + (preset: TimeRangePreset) => { + const { start, end } = preset.getValue() + onTimeRangeChange(start, end, { + isLiveUpdating: preset.isLiveUpdating, + }) + setIsOpen(false) + }, + [onTimeRangeChange] + ) + + const handleApply = useCallback( + (values: TimeRangeValues) => { + const timestamps = parseTimeRangeValuesToTimestamps(values) + if (!timestamps) { + return + } + + const next = clampToLifecycle(timestamps.start, timestamps.end) + + onTimeRangeChange(next.start, next.end, { + isLiveUpdating: false, + }) + setIsOpen(false) + }, + [clampToLifecycle, onTimeRangeChange] + ) + + const handleLiveToggle = useCallback(() => { + if (!lifecycle.isRunning) { + onLiveChange(false) + return + } + + onLiveChange(!isLiveUpdating) + }, [isLiveUpdating, lifecycle.isRunning, onLiveChange]) + + const canGoBackward = historyState.index > 0 + const canGoForward = historyState.index < historyState.entries.length - 1 + + const handleHistoryNavigation = useCallback( + (targetIndex: number) => { + const target = historyState.entries[targetIndex] + if (!target) { + return + } + + isHistoryNavigationRef.current = true + setHistoryState((previous) => ({ + entries: previous.entries, + index: targetIndex, + })) + onTimeRangeChange(target.start, target.end, { + isLiveUpdating: target.isLiveUpdating, + }) + }, + [historyState.entries, onTimeRangeChange] + ) + + const handleGoBackward = useCallback(() => { + if (!canGoBackward) { + return + } + + handleHistoryNavigation(historyState.index - 1) + }, [canGoBackward, handleHistoryNavigation, historyState.index]) + + const handleGoForward = useCallback(() => { + if (!canGoForward) { + return + } + + handleHistoryNavigation(historyState.index + 1) + }, [canGoForward, handleHistoryNavigation, historyState.index]) + + return ( +
+
+ + + + + +
+ + + + +
+
+
+ + +
+ +
+ + +
+
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx new file mode 100644 index 000000000..05696d5a7 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx @@ -0,0 +1,25 @@ +'use client' + +import LoadingLayout from '@/features/dashboard/loading-layout' +import { useSandboxContext } from '@/features/dashboard/sandbox/context' +import SandboxMetricsCharts from './monitoring-charts' + +interface SandboxMonitoringViewProps { + sandboxId: string +} + +export default function SandboxMonitoringView({ + sandboxId, +}: SandboxMonitoringViewProps) { + const { isSandboxInfoLoading, sandboxInfo } = useSandboxContext() + + if (isSandboxInfoLoading && !sandboxInfo) { + return + } + + return ( +
+ +
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts new file mode 100644 index 000000000..2978f55da --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts @@ -0,0 +1,492 @@ +'use client' + +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' +import { useDashboard } from '@/features/dashboard/context' +import { useSandboxContext } from '@/features/dashboard/sandbox/context' +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { useTRPCClient } from '@/trpc/client' +import { + SANDBOX_MONITORING_DEFAULT_RANGE_MS, + SANDBOX_MONITORING_LIVE_POLLING_MS, + SANDBOX_MONITORING_MAX_RANGE_MS, + SANDBOX_MONITORING_MIN_RANGE_MS, + SANDBOX_MONITORING_QUERY_END_PARAM, + SANDBOX_MONITORING_QUERY_LIVE_FALSE, + SANDBOX_MONITORING_QUERY_LIVE_PARAM, + SANDBOX_MONITORING_QUERY_LIVE_TRUE, + SANDBOX_MONITORING_QUERY_START_PARAM, +} from '../utils/constants' +import { + clampTimeframeToBounds, + getSandboxLifecycleBounds, + normalizeMonitoringTimeframe, + parseMonitoringQueryState, + type SandboxLifecycleBounds, +} from '../utils/timeframe' + +interface SandboxMonitoringTimeframe { + start: number + end: number + duration: number +} + +interface SandboxMonitoringControllerState { + sandboxId: string | null + timeframe: SandboxMonitoringTimeframe + isLiveUpdating: boolean + isInitialized: boolean +} + +interface ApplyTimeframeOptions { + isLiveUpdating?: boolean +} + +type SandboxMonitoringControllerAction = + | { + type: 'initialize' + payload: { + sandboxId: string + timeframe: SandboxMonitoringTimeframe + isLiveUpdating: boolean + } + } + | { + type: 'setTimeframe' + payload: { + timeframe: SandboxMonitoringTimeframe + isLiveUpdating: boolean + } + } + | { + type: 'setLiveUpdating' + payload: { + isLiveUpdating: boolean + } + } + +function toTimeframe(start: number, end: number): SandboxMonitoringTimeframe { + return { + start, + end, + duration: end - start, + } +} + +function getDefaultTimeframe( + now: number = Date.now() +): SandboxMonitoringTimeframe { + const normalized = normalizeMonitoringTimeframe({ + start: now - SANDBOX_MONITORING_DEFAULT_RANGE_MS, + end: now, + now, + minRangeMs: SANDBOX_MONITORING_MIN_RANGE_MS, + maxRangeMs: SANDBOX_MONITORING_MAX_RANGE_MS, + }) + + return toTimeframe(normalized.start, normalized.end) +} + +function resolveTimeframe( + start: number, + end: number, + now: number, + lifecycleBounds: SandboxLifecycleBounds | null +): SandboxMonitoringTimeframe { + const normalized = normalizeMonitoringTimeframe({ + start, + end, + now, + minRangeMs: SANDBOX_MONITORING_MIN_RANGE_MS, + maxRangeMs: SANDBOX_MONITORING_MAX_RANGE_MS, + }) + + if (!lifecycleBounds) { + return toTimeframe(normalized.start, normalized.end) + } + + const maxBoundMs = lifecycleBounds.isRunning + ? now + : lifecycleBounds.anchorEndMs + const clamped = clampTimeframeToBounds( + normalized.start, + normalized.end, + lifecycleBounds.startMs, + maxBoundMs + ) + + return toTimeframe(clamped.start, clamped.end) +} + +function sandboxMonitoringControllerReducer( + state: SandboxMonitoringControllerState, + action: SandboxMonitoringControllerAction +): SandboxMonitoringControllerState { + switch (action.type) { + case 'initialize': { + const { sandboxId, timeframe, isLiveUpdating } = action.payload + + if ( + state.isInitialized && + state.sandboxId === sandboxId && + state.isLiveUpdating === isLiveUpdating && + state.timeframe.start === timeframe.start && + state.timeframe.end === timeframe.end + ) { + return state + } + + return { + sandboxId, + timeframe, + isLiveUpdating, + isInitialized: true, + } + } + + case 'setTimeframe': { + const { timeframe, isLiveUpdating } = action.payload + if ( + state.timeframe.start === timeframe.start && + state.timeframe.end === timeframe.end && + state.isLiveUpdating === isLiveUpdating + ) { + return state + } + + return { + ...state, + timeframe, + isLiveUpdating, + } + } + + case 'setLiveUpdating': { + if (state.isLiveUpdating === action.payload.isLiveUpdating) { + return state + } + + return { + ...state, + isLiveUpdating: action.payload.isLiveUpdating, + } + } + + default: + return state + } +} + +function createInitialState(): SandboxMonitoringControllerState { + return { + sandboxId: null, + timeframe: getDefaultTimeframe(), + isLiveUpdating: true, + isInitialized: false, + } +} + +export function useSandboxMonitoringController(sandboxId: string) { + const trpcClient = useTRPCClient() + const { team } = useDashboard() + const { sandboxInfo } = useSandboxContext() + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [state, dispatch] = useReducer( + sandboxMonitoringControllerReducer, + undefined, + createInitialState + ) + const stateRef = useRef(state) + const durationRef = useRef(state.timeframe.duration) + + const queryStart = searchParams.get(SANDBOX_MONITORING_QUERY_START_PARAM) + const queryEnd = searchParams.get(SANDBOX_MONITORING_QUERY_END_PARAM) + const queryLive = searchParams.get(SANDBOX_MONITORING_QUERY_LIVE_PARAM) + const searchParamsString = searchParams.toString() + + const queryState = useMemo( + () => + parseMonitoringQueryState({ + start: queryStart, + end: queryEnd, + live: queryLive, + }), + [queryEnd, queryLive, queryStart] + ) + + const lifecycleStartedAt = sandboxInfo?.startedAt + const lifecycleEndAt = sandboxInfo?.endAt + const lifecycleStoppedAt = + sandboxInfo && 'stoppedAt' in sandboxInfo ? sandboxInfo.stoppedAt : null + const lifecycleState = sandboxInfo?.state + const lifecycleBounds = useMemo(() => { + if (!lifecycleStartedAt || !lifecycleState) { + return null + } + + return getSandboxLifecycleBounds({ + startedAt: lifecycleStartedAt, + endAt: lifecycleEndAt ?? null, + stoppedAt: lifecycleStoppedAt ?? null, + state: lifecycleState, + }) + }, [lifecycleEndAt, lifecycleStartedAt, lifecycleStoppedAt, lifecycleState]) + + useEffect(() => { + stateRef.current = state + }, [state]) + + const applyTimeframe = useCallback( + (start: number, end: number, options?: ApplyTimeframeOptions) => { + const currentState = stateRef.current + const now = Date.now() + const timeframe = resolveTimeframe(start, end, now, lifecycleBounds) + const requestedLiveUpdating = + options?.isLiveUpdating ?? currentState.isLiveUpdating + const nextLiveUpdating = lifecycleBounds?.isRunning + ? requestedLiveUpdating + : lifecycleBounds + ? false + : requestedLiveUpdating + + if ( + currentState.timeframe.start === timeframe.start && + currentState.timeframe.end === timeframe.end && + currentState.isLiveUpdating === nextLiveUpdating + ) { + return + } + + dispatch({ + type: 'setTimeframe', + payload: { + timeframe, + isLiveUpdating: nextLiveUpdating, + }, + }) + }, + [lifecycleBounds] + ) + + const setLiveUpdating = useCallback( + (isLiveUpdating: boolean) => { + const currentState = stateRef.current + + if (!isLiveUpdating) { + if (!currentState.isLiveUpdating) { + return + } + + dispatch({ + type: 'setLiveUpdating', + payload: { isLiveUpdating: false }, + }) + + return + } + + if (lifecycleBounds && !lifecycleBounds.isRunning) { + if (!currentState.isLiveUpdating) { + return + } + + dispatch({ + type: 'setLiveUpdating', + payload: { isLiveUpdating: false }, + }) + + return + } + + const now = Date.now() + const anchorEndMs = lifecycleBounds?.isRunning + ? now + : (lifecycleBounds?.anchorEndMs ?? now) + + applyTimeframe(anchorEndMs - durationRef.current, anchorEndMs, { + isLiveUpdating: true, + }) + }, + [applyTimeframe, lifecycleBounds] + ) + + useEffect(() => { + durationRef.current = state.timeframe.duration + }, [state.timeframe.duration]) + + useEffect(() => { + const now = Date.now() + const currentState = stateRef.current + const hasExplicitRange = + queryState.start !== null && queryState.end !== null + const requestedLiveUpdating = queryState.live ?? true + const start = hasExplicitRange + ? queryState.start + : currentState.isInitialized && currentState.sandboxId === sandboxId + ? requestedLiveUpdating + ? now - durationRef.current + : currentState.timeframe.start + : now - SANDBOX_MONITORING_DEFAULT_RANGE_MS + const end = hasExplicitRange + ? queryState.end + : currentState.isInitialized && currentState.sandboxId === sandboxId + ? requestedLiveUpdating + ? now + : currentState.timeframe.end + : now + + if (start === null || end === null) { + return + } + + const timeframe = resolveTimeframe(start, end, now, lifecycleBounds) + + dispatch({ + type: 'initialize', + payload: { + sandboxId, + timeframe, + isLiveUpdating: + lifecycleBounds && !lifecycleBounds.isRunning + ? false + : requestedLiveUpdating, + }, + }) + }, [ + lifecycleBounds, + queryState.end, + queryState.live, + queryState.start, + sandboxId, + ]) + + useEffect(() => { + if (!state.isInitialized || !state.isLiveUpdating) { + return + } + + if (lifecycleBounds && !lifecycleBounds.isRunning) { + return + } + + const tick = () => { + const now = Date.now() + const anchorEndMs = lifecycleBounds?.isRunning + ? now + : (lifecycleBounds?.anchorEndMs ?? now) + + applyTimeframe(anchorEndMs - durationRef.current, anchorEndMs, { + isLiveUpdating: true, + }) + } + + const intervalId = window.setInterval( + tick, + SANDBOX_MONITORING_LIVE_POLLING_MS + ) + + return () => { + window.clearInterval(intervalId) + } + }, [ + applyTimeframe, + lifecycleBounds, + state.isInitialized, + state.isLiveUpdating, + ]) + + useEffect(() => { + if (!state.isInitialized) { + return + } + + const nextLive = state.isLiveUpdating + ? SANDBOX_MONITORING_QUERY_LIVE_TRUE + : SANDBOX_MONITORING_QUERY_LIVE_FALSE + const nextStart = String(state.timeframe.start) + const nextEnd = String(state.timeframe.end) + const shouldPersistExplicitRange = !state.isLiveUpdating + + if ( + queryLive === nextLive && + (shouldPersistExplicitRange + ? queryStart === nextStart && queryEnd === nextEnd + : queryStart === null && queryEnd === null) + ) { + return + } + + const nextParams = new URLSearchParams(searchParamsString) + nextParams.set(SANDBOX_MONITORING_QUERY_LIVE_PARAM, nextLive) + + if (shouldPersistExplicitRange) { + nextParams.set(SANDBOX_MONITORING_QUERY_START_PARAM, nextStart) + nextParams.set(SANDBOX_MONITORING_QUERY_END_PARAM, nextEnd) + } else { + nextParams.delete(SANDBOX_MONITORING_QUERY_START_PARAM) + nextParams.delete(SANDBOX_MONITORING_QUERY_END_PARAM) + } + + router.replace(`${pathname}?${nextParams.toString()}`, { + scroll: false, + }) + }, [ + pathname, + queryEnd, + queryLive, + queryStart, + router, + searchParamsString, + state.isInitialized, + state.isLiveUpdating, + state.timeframe.end, + state.timeframe.start, + ]) + + const queryKey = useMemo( + () => + [ + 'sandboxMonitoringMetrics', + team?.id ?? '', + sandboxId, + state.timeframe.start, + state.timeframe.end, + ] as const, + [sandboxId, state.timeframe.end, state.timeframe.start, team?.id] + ) + + const metricsQuery = useQuery({ + queryKey, + enabled: state.isInitialized && Boolean(team?.id), + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: SANDBOX_MONITORING_LIVE_POLLING_MS, + queryFn: async () => { + if (!team?.id) { + return [] + } + + return trpcClient.sandbox.resourceMetrics.query({ + teamIdOrSlug: team.id, + sandboxId, + startMs: state.timeframe.start, + endMs: state.timeframe.end, + }) + }, + }) + + return { + lifecycleBounds, + timeframe: state.timeframe, + metrics: metricsQuery.data ?? [], + isLiveUpdating: state.isLiveUpdating, + isRefetching: metricsQuery.isFetching, + setTimeframe: applyTimeframe, + setLiveUpdating, + } +} diff --git a/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts new file mode 100644 index 000000000..b6d661953 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' + +export type SandboxMetricsDataPoint = [ + timestampMs: number, + value: number | null, + markerValue?: number | null, +] + +export interface SandboxMetricsMarkerValueFormatterInput { + value: number + markerValue: number | null + point: SandboxMetricsDataPoint +} + +export interface SandboxMetricsSeries { + id: string + name: string + data: SandboxMetricsDataPoint[] + markerValueFormatter?: ( + input: SandboxMetricsMarkerValueFormatterInput + ) => ReactNode + lineColorVar?: string + areaColorVar?: string + areaToColorVar?: string + showArea?: boolean + areaOpacity?: number + zIndex?: number +} + +export interface SandboxMetricsChartProps { + series: SandboxMetricsSeries[] + hoveredTimestampMs?: number | null + className?: string + showXAxisLabels?: boolean + yAxisMax?: number + yAxisFormatter?: (value: number) => string + onHover?: (timestampMs: number) => void + onHoverEnd?: () => void + onBrushEnd?: (startTimestamp: number, endTimestamp: number) => void +} + +export interface MonitoringResourceHoveredContext { + cpuPercent: number | null + ramPercent: number | null + timestampMs: number +} + +export interface MonitoringDiskHoveredContext { + diskPercent: number | null + timestampMs: number +} + +export interface MonitoringChartModel { + latestMetric: SandboxMetric | undefined + resourceSeries: SandboxMetricsSeries[] + diskSeries: SandboxMetricsSeries[] + resourceHoveredContext: MonitoringResourceHoveredContext | null + diskHoveredContext: MonitoringDiskHoveredContext | null +} diff --git a/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts new file mode 100644 index 000000000..7f552d3bc --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts @@ -0,0 +1,269 @@ +import { millisecondsInSecond } from 'date-fns/constants' +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import type { + MonitoringChartModel, + SandboxMetricsDataPoint, + SandboxMetricsSeries, +} from '../types/sandbox-metrics-chart' +import { + SANDBOX_MONITORING_CPU_AREA_COLOR_VAR, + SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR, + SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, + SANDBOX_MONITORING_CPU_SERIES_ID, + SANDBOX_MONITORING_CPU_SERIES_LABEL, + SANDBOX_MONITORING_DISK_AREA_COLOR_VAR, + SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR, + SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, + SANDBOX_MONITORING_DISK_SERIES_ID, + SANDBOX_MONITORING_DISK_SERIES_LABEL, + SANDBOX_MONITORING_RAM_AREA_COLOR_VAR, + SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, + SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, + SANDBOX_MONITORING_RAM_SERIES_ID, + SANDBOX_MONITORING_RAM_SERIES_LABEL, +} from './constants' +import { clampPercent } from './formatters' + +interface NormalizedSandboxMetric { + metric: SandboxMetric + timestampMs: number + cpuPercent: number + ramPercent: number + diskPercent: number + ramUsedMb: number + diskUsedMb: number +} + +interface BuildMonitoringChartModelOptions { + metrics: SandboxMetric[] + startMs: number + endMs: number + hoveredTimestampMs: number | null +} + +function toPercent(used: number, total: number): number { + if (!total || total <= 0) { + return 0 + } + + return clampPercent((used / total) * 100) +} + +function toMegabytes(value: number): number { + if (!Number.isFinite(value) || value < 0) { + return 0 + } + + return Math.round(value / (1024 * 1024)) +} + +function getMetricTimestampMs(metric: SandboxMetric): number | null { + const timestampMs = Math.floor(metric.timestampUnix * millisecondsInSecond) + + if (!Number.isFinite(timestampMs)) { + return null + } + + return timestampMs +} + +function normalizeMetric( + metric: SandboxMetric +): NormalizedSandboxMetric | null { + const timestampMs = getMetricTimestampMs(metric) + if (timestampMs === null) { + return null + } + + return { + metric, + timestampMs, + cpuPercent: clampPercent(metric.cpuUsedPct), + ramPercent: toPercent(metric.memUsed, metric.memTotal), + diskPercent: toPercent(metric.diskUsed, metric.diskTotal), + ramUsedMb: toMegabytes(metric.memUsed), + diskUsedMb: toMegabytes(metric.diskUsed), + } +} + +function sortMetricsByTimestamp( + metrics: NormalizedSandboxMetric[] +): NormalizedSandboxMetric[] { + return [...metrics].sort((a, b) => a.timestampMs - b.timestampMs) +} + +function buildSeriesData( + metrics: NormalizedSandboxMetric[], + getValue: (metric: NormalizedSandboxMetric) => number, + getMarkerValue?: (metric: NormalizedSandboxMetric) => number | null +): SandboxMetricsDataPoint[] { + return metrics.map((metric) => [ + metric.timestampMs, + getValue(metric), + getMarkerValue ? getMarkerValue(metric) : null, + ]) +} + +function findClosestMetric( + metrics: NormalizedSandboxMetric[], + timestampMs: number +): NormalizedSandboxMetric | null { + if (metrics.length === 0 || !Number.isFinite(timestampMs)) { + return null + } + + let low = 0 + let high = metrics.length - 1 + + while (low <= high) { + const middle = Math.floor((low + high) / 2) + const middleTimestamp = metrics[middle]?.timestampMs + if (middleTimestamp === undefined) { + break + } + + if (middleTimestamp === timestampMs) { + return metrics[middle] ?? null + } + + if (middleTimestamp < timestampMs) { + low = middle + 1 + } else { + high = middle - 1 + } + } + + const nextMetric = metrics[low] + const previousMetric = metrics[low - 1] + + if (!nextMetric) { + return previousMetric ?? null + } + + if (!previousMetric) { + return nextMetric + } + + const nextDistance = Math.abs(nextMetric.timestampMs - timestampMs) + const previousDistance = Math.abs(previousMetric.timestampMs - timestampMs) + + return previousDistance <= nextDistance ? previousMetric : nextMetric +} + +function buildResourceSeries( + metrics: NormalizedSandboxMetric[] +): SandboxMetricsSeries[] { + return [ + { + id: SANDBOX_MONITORING_CPU_SERIES_ID, + name: SANDBOX_MONITORING_CPU_SERIES_LABEL, + lineColorVar: SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, + areaColorVar: SANDBOX_MONITORING_CPU_AREA_COLOR_VAR, + areaToColorVar: SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR, + showArea: true, + areaOpacity: 0.3, + zIndex: 2, + data: buildSeriesData(metrics, (metric) => metric.cpuPercent), + }, + { + id: SANDBOX_MONITORING_RAM_SERIES_ID, + name: SANDBOX_MONITORING_RAM_SERIES_LABEL, + lineColorVar: SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, + areaColorVar: SANDBOX_MONITORING_RAM_AREA_COLOR_VAR, + areaToColorVar: SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, + showArea: true, + areaOpacity: 0.3, + zIndex: 1, + data: buildSeriesData( + metrics, + (metric) => metric.ramPercent, + (metric) => metric.ramUsedMb + ), + }, + ] +} + +function buildDiskSeries( + metrics: NormalizedSandboxMetric[] +): SandboxMetricsSeries[] { + return [ + { + id: SANDBOX_MONITORING_DISK_SERIES_ID, + name: SANDBOX_MONITORING_DISK_SERIES_LABEL, + lineColorVar: SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, + areaColorVar: SANDBOX_MONITORING_DISK_AREA_COLOR_VAR, + areaToColorVar: SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR, + showArea: true, + areaOpacity: 0.5, + data: buildSeriesData( + metrics, + (metric) => metric.diskPercent, + (metric) => metric.diskUsedMb + ), + }, + ] +} + +export function buildMonitoringChartModel({ + metrics, + startMs, + endMs, + hoveredTimestampMs, +}: BuildMonitoringChartModelOptions): MonitoringChartModel { + const rangeStart = Math.min(startMs, endMs) + const rangeEnd = Math.max(startMs, endMs) + + const normalizedMetrics = sortMetricsByTimestamp( + metrics + .map(normalizeMetric) + .filter((metric): metric is NormalizedSandboxMetric => { + if (!metric) { + return false + } + + return ( + metric.timestampMs >= rangeStart && metric.timestampMs <= rangeEnd + ) + }) + ) + + const resourceSeries = buildResourceSeries(normalizedMetrics) + const diskSeries = buildDiskSeries(normalizedMetrics) + const latestMetric = normalizedMetrics[normalizedMetrics.length - 1]?.metric + + if (hoveredTimestampMs === null) { + return { + latestMetric, + resourceSeries, + diskSeries, + resourceHoveredContext: null, + diskHoveredContext: null, + } + } + + const hoveredMetric = findClosestMetric(normalizedMetrics, hoveredTimestampMs) + if (!hoveredMetric) { + return { + latestMetric, + resourceSeries, + diskSeries, + resourceHoveredContext: null, + diskHoveredContext: null, + } + } + + return { + latestMetric, + resourceSeries, + diskSeries, + resourceHoveredContext: { + cpuPercent: hoveredMetric.cpuPercent, + ramPercent: hoveredMetric.ramPercent, + timestampMs: hoveredMetric.timestampMs, + }, + diskHoveredContext: { + diskPercent: hoveredMetric.diskPercent, + timestampMs: hoveredMetric.timestampMs, + }, + } +} diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts new file mode 100644 index 000000000..81fbbc0b8 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -0,0 +1,98 @@ +import { + millisecondsInDay, + millisecondsInHour, + millisecondsInMinute, + millisecondsInSecond, +} from 'date-fns/constants' + +export const SANDBOX_MONITORING_METRICS_RETENTION_MS = 7 * millisecondsInDay +export const SANDBOX_MONITORING_DEFAULT_RANGE_MS = millisecondsInHour +export const SANDBOX_MONITORING_MIN_RANGE_MS = 30 * millisecondsInSecond +export const SANDBOX_MONITORING_MAX_RANGE_MS = 31 * millisecondsInDay +export const SANDBOX_MONITORING_LIVE_POLLING_MS = 10_000 +export const SANDBOX_MONITORING_MIN_TIMESTAMP_MS = -8_640_000_000_000_000 +export const SANDBOX_MONITORING_MAX_TIMESTAMP_MS = 8_640_000_000_000_000 + +export const SANDBOX_MONITORING_QUERY_START_PARAM = 'start' +export const SANDBOX_MONITORING_QUERY_END_PARAM = 'end' +export const SANDBOX_MONITORING_QUERY_LIVE_PARAM = 'live' +export const SANDBOX_MONITORING_QUERY_LIVE_TRUE = '1' +export const SANDBOX_MONITORING_QUERY_LIVE_FALSE = '0' + +export const SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS = millisecondsInMinute +export const SANDBOX_MONITORING_MAX_HISTORY_ENTRIES = 50 + +export const SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID = 'full-lifecycle' +export const SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_SHORTCUT = 'FULL' +export const SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_ID = 'first-5m' +export const SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_SHORTCUT = 'F5' +export const SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID = 'first-15m' +export const SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_SHORTCUT = 'F15' +export const SANDBOX_MONITORING_FIRST_HOUR_PRESET_ID = 'first-1h' +export const SANDBOX_MONITORING_FIRST_HOUR_PRESET_SHORTCUT = 'F1H' +export const SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_ID = 'last-5m' +export const SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_SHORTCUT = 'L5' +export const SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID = 'last-15m' +export const SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_SHORTCUT = 'L15' +export const SANDBOX_MONITORING_LAST_HOUR_PRESET_ID = 'last-1h' +export const SANDBOX_MONITORING_LAST_HOUR_PRESET_SHORTCUT = 'L1H' +export const SANDBOX_MONITORING_LAST_6_HOURS_PRESET_ID = 'last-6h' +export const SANDBOX_MONITORING_LAST_6_HOURS_PRESET_SHORTCUT = 'L6H' + +export const SANDBOX_MONITORING_CPU_SERIES_ID = 'cpu' +export const SANDBOX_MONITORING_RAM_SERIES_ID = 'ram' +export const SANDBOX_MONITORING_DISK_SERIES_ID = 'disk' +export const SANDBOX_MONITORING_CPU_SERIES_LABEL = 'CPU' +export const SANDBOX_MONITORING_RAM_SERIES_LABEL = 'RAM' +export const SANDBOX_MONITORING_DISK_SERIES_LABEL = 'DISK' +export const SANDBOX_MONITORING_CORE_LABEL_SINGULAR = 'CORE' +export const SANDBOX_MONITORING_CORE_LABEL_PLURAL = 'CORES' +export const SANDBOX_MONITORING_VALUE_UNAVAILABLE = '--' +export const SANDBOX_MONITORING_GIGABYTE_UNIT = 'GB' +export const SANDBOX_MONITORING_METRIC_VALUE_SEPARATOR = ' · ' + +export const SANDBOX_MONITORING_CPU_INDICATOR_CLASS = 'bg-graph-3' +export const SANDBOX_MONITORING_RAM_INDICATOR_CLASS = 'bg-graph-1' +export const SANDBOX_MONITORING_DISK_INDICATOR_CLASS = 'bg-graph-2' +export const SANDBOX_MONITORING_CPU_LINE_COLOR_VAR = '--graph-3' +export const SANDBOX_MONITORING_CPU_AREA_COLOR_VAR = '--graph-area-3-from' +export const SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR = '--graph-area-3-to' +export const SANDBOX_MONITORING_RAM_LINE_COLOR_VAR = '--graph-1' +export const SANDBOX_MONITORING_RAM_AREA_COLOR_VAR = '--graph-area-1-from' +export const SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR = '--graph-area-1-to' +export const SANDBOX_MONITORING_DISK_LINE_COLOR_VAR = '--graph-2' +export const SANDBOX_MONITORING_DISK_AREA_COLOR_VAR = '--graph-area-2-from' +export const SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR = '--graph-area-2-to' + +export const SANDBOX_MONITORING_CHART_STROKE_VAR = '--stroke' +export const SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR = '--fg-tertiary' +export const SANDBOX_MONITORING_CHART_FONT_MONO_VAR = '--font-mono' +export const SANDBOX_MONITORING_CHART_FALLBACK_STROKE = '#000' +export const SANDBOX_MONITORING_CHART_FALLBACK_FG_TERTIARY = '#666' +export const SANDBOX_MONITORING_CHART_FALLBACK_FONT_MONO = 'monospace' +export const SANDBOX_MONITORING_CHART_GROUP = 'sandbox-monitoring' +export const SANDBOX_MONITORING_CHART_BRUSH_TYPE = 'lineX' +export const SANDBOX_MONITORING_CHART_BRUSH_MODE = 'single' +export const SANDBOX_MONITORING_CHART_LINE_WIDTH = 1 +export const SANDBOX_MONITORING_CHART_GRID_TOP = 28 +export const SANDBOX_MONITORING_CHART_GRID_RIGHT = 28 +export const SANDBOX_MONITORING_CHART_GRID_BOTTOM = 28 +export const SANDBOX_MONITORING_CHART_GRID_BOTTOM_WITH_X_AXIS = 28 +export const SANDBOX_MONITORING_CHART_AREA_OPACITY = 0.18 +export const SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA = 0.25 +export const SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE = 12 +export const SANDBOX_MONITORING_CHART_Y_AXIS_SCALE_FACTOR = 1.5 +export const SANDBOX_MONITORING_CHART_LIVE_WINDOW_MS = 2 * millisecondsInMinute +export const SANDBOX_MONITORING_CHART_LIVE_OUTER_DOT_SIZE = 16 +export const SANDBOX_MONITORING_CHART_LIVE_MIDDLE_DOT_SIZE = 10 +export const SANDBOX_MONITORING_CHART_LIVE_INNER_DOT_SIZE = 6 +export const SANDBOX_MONITORING_PERCENT_MAX = 100 +export const SANDBOX_MONITORING_BYTES_IN_GIGABYTE = 1024 * 1024 * 1024 + +export const SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = + { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + } diff --git a/src/features/dashboard/sandbox/monitoring/utils/formatters.ts b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts new file mode 100644 index 000000000..3c77b67bb --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts @@ -0,0 +1,65 @@ +import { + SANDBOX_MONITORING_BYTES_IN_GIGABYTE, + SANDBOX_MONITORING_CORE_LABEL_PLURAL, + SANDBOX_MONITORING_CORE_LABEL_SINGULAR, + SANDBOX_MONITORING_GIGABYTE_UNIT, + SANDBOX_MONITORING_METRIC_VALUE_SEPARATOR, + SANDBOX_MONITORING_PERCENT_MAX, + SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, + SANDBOX_MONITORING_VALUE_UNAVAILABLE, +} from './constants' + +const hoverTimestampFormatter = new Intl.DateTimeFormat( + undefined, + SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS +) + +export function formatPercent(value: number | null): string { + if (value === null || Number.isNaN(value)) { + return SANDBOX_MONITORING_VALUE_UNAVAILABLE + } + + return `${Math.round(value)}%` +} + +export function formatCoreCount(value: number): string { + const normalized = Math.max(0, Math.round(value)) + const label = + normalized === 1 + ? SANDBOX_MONITORING_CORE_LABEL_SINGULAR + : SANDBOX_MONITORING_CORE_LABEL_PLURAL + + return `${normalized} ${label}` +} + +export function formatBytesToGb(bytes: number): string { + const gigabytes = bytes / SANDBOX_MONITORING_BYTES_IN_GIGABYTE + const rounded = gigabytes >= 10 ? gigabytes.toFixed(0) : gigabytes.toFixed(1) + const normalized = rounded.replace(/\.0$/, '') + + return `${normalized} ${SANDBOX_MONITORING_GIGABYTE_UNIT}` +} + +export function formatHoverTimestamp(timestampMs: number): string { + return hoverTimestampFormatter.format(new Date(timestampMs)) +} + +export function formatMetricValue(primary: string, secondary: string): string { + return `${primary}${SANDBOX_MONITORING_METRIC_VALUE_SEPARATOR}${secondary}` +} + +export function clampPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, Math.min(SANDBOX_MONITORING_PERCENT_MAX, value)) +} + +export function calculateRatioPercent(used: number, total: number): number { + if (total <= 0) { + return 0 + } + + return clampPercent((used / total) * 100) +} diff --git a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts new file mode 100644 index 000000000..8e0189199 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts @@ -0,0 +1,262 @@ +import type { SandboxDetailsDTO } from '@/server/api/models/sandboxes.models' +import { + SANDBOX_MONITORING_DEFAULT_RANGE_MS, + SANDBOX_MONITORING_MAX_RANGE_MS, + SANDBOX_MONITORING_MAX_TIMESTAMP_MS, + SANDBOX_MONITORING_MIN_RANGE_MS, + SANDBOX_MONITORING_MIN_TIMESTAMP_MS, + SANDBOX_MONITORING_QUERY_LIVE_FALSE, + SANDBOX_MONITORING_QUERY_LIVE_TRUE, +} from './constants' + +export interface NormalizedMonitoringTimeframe { + start: number + end: number +} + +export interface MonitoringQueryState { + start: number | null + end: number | null + live: boolean | null +} + +interface NormalizeMonitoringTimeframeInput { + start: number + end: number + now?: number + minRangeMs?: number + maxRangeMs?: number +} + +interface ParseMonitoringQueryStateInput { + start: string | null + end: string | null + live: string | null +} + +function clampToBounds(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return max + } + + return Math.max(min, Math.min(max, Math.floor(value))) +} + +function parseTimestampParam(value: string | null): number | null { + if (!value) { + return null + } + + const normalizedValue = value.trim() + if (!/^-?\d+$/.test(normalizedValue)) { + return null + } + + const parsed = Number(normalizedValue) + if (!Number.isFinite(parsed)) { + return null + } + + if ( + parsed < SANDBOX_MONITORING_MIN_TIMESTAMP_MS || + parsed > SANDBOX_MONITORING_MAX_TIMESTAMP_MS + ) { + return null + } + + return parsed +} + +function parseLiveParam(value: string | null): boolean | null { + if (value === SANDBOX_MONITORING_QUERY_LIVE_TRUE) { + return true + } + + if (value === SANDBOX_MONITORING_QUERY_LIVE_FALSE) { + return false + } + + return null +} + +function parseDateTimestampMs(value: string | null | undefined): number | null { + if (!value) { + return null + } + + const parsed = new Date(value).getTime() + if (!Number.isFinite(parsed)) { + return null + } + + return parsed +} + +export function parseMonitoringQueryState({ + start, + end, + live, +}: ParseMonitoringQueryStateInput): MonitoringQueryState { + return { + start: parseTimestampParam(start), + end: parseTimestampParam(end), + live: parseLiveParam(live), + } +} + +export function normalizeMonitoringTimeframe({ + start, + end, + now = Date.now(), + minRangeMs = SANDBOX_MONITORING_MIN_RANGE_MS, + maxRangeMs = SANDBOX_MONITORING_MAX_RANGE_MS, +}: NormalizeMonitoringTimeframeInput): NormalizedMonitoringTimeframe { + const safeNow = clampToBounds( + now, + SANDBOX_MONITORING_MIN_TIMESTAMP_MS, + SANDBOX_MONITORING_MAX_TIMESTAMP_MS + ) + const safeMinBound = SANDBOX_MONITORING_MIN_TIMESTAMP_MS + const safeMaxBound = safeNow + const fallbackEnd = safeNow + const fallbackStart = fallbackEnd - SANDBOX_MONITORING_DEFAULT_RANGE_MS + + let safeStart = Number.isFinite(start) ? start : fallbackStart + let safeEnd = Number.isFinite(end) ? end : fallbackEnd + + safeStart = clampToBounds(safeStart, safeMinBound, safeMaxBound) + safeEnd = clampToBounds(safeEnd, safeMinBound, safeMaxBound) + + if (safeEnd < safeStart) { + ;[safeStart, safeEnd] = [safeEnd, safeStart] + } + + if (safeEnd - safeStart > maxRangeMs) { + safeStart = safeEnd - maxRangeMs + } + + if (safeEnd - safeStart < minRangeMs) { + safeStart = safeEnd - minRangeMs + } + + safeStart = clampToBounds(safeStart, safeMinBound, safeMaxBound) + safeEnd = clampToBounds(safeEnd, safeMinBound, safeMaxBound) + + if (safeEnd - safeStart < minRangeMs) { + safeEnd = clampToBounds(safeStart + minRangeMs, safeMinBound, safeMaxBound) + safeStart = clampToBounds(safeEnd - minRangeMs, safeMinBound, safeMaxBound) + } + + if (safeEnd - safeStart > maxRangeMs) { + safeStart = safeEnd - maxRangeMs + } + + return { + start: safeStart, + end: safeEnd, + } +} + +export interface SandboxLifecycleBounds { + startMs: number + anchorEndMs: number + isRunning: boolean +} + +export function getSandboxLifecycleBounds( + sandboxInfo: Pick & { + stoppedAt?: string | null + }, + now: number = Date.now() +): SandboxLifecycleBounds | null { + const startMs = parseDateTimestampMs(sandboxInfo.startedAt) + const isRunning = sandboxInfo.state === 'running' + + if (startMs === null) { + return null + } + + const safeNow = clampToBounds( + now, + SANDBOX_MONITORING_MIN_TIMESTAMP_MS, + SANDBOX_MONITORING_MAX_TIMESTAMP_MS + ) + const endMs = + parseDateTimestampMs(sandboxInfo.endAt) ?? + parseDateTimestampMs(sandboxInfo.stoppedAt) ?? + safeNow + const anchorEndMs = Math.min(safeNow, endMs) + + const normalizedStart = Math.floor(Math.min(startMs, anchorEndMs)) + const normalizedEnd = Math.floor(Math.max(startMs, anchorEndMs)) + + return { + startMs: normalizedStart, + anchorEndMs: normalizedEnd, + isRunning, + } +} + +export function clampTimeframeToBounds( + start: number, + end: number, + minBoundMs: number, + maxBoundMs: number, + minRangeMs: number = SANDBOX_MONITORING_MIN_RANGE_MS +) { + const safeMin = Math.floor(Math.min(minBoundMs, maxBoundMs)) + const safeMax = Math.floor(Math.max(minBoundMs, maxBoundMs)) + const boundsDuration = safeMax - safeMin + + if (boundsDuration <= minRangeMs) { + return { start: safeMin, end: safeMax } + } + + let safeStart = Math.floor(start) + let safeEnd = Math.floor(end) + + if (!Number.isFinite(safeStart)) { + safeStart = safeMin + } + + if (!Number.isFinite(safeEnd)) { + safeEnd = safeMax + } + + if (safeEnd <= safeStart) { + safeEnd = safeStart + minRangeMs + } + + const requestedDuration = safeEnd - safeStart + if (requestedDuration >= boundsDuration) { + return { start: safeMin, end: safeMax } + } + + if (safeEnd > safeMax) { + const shift = safeEnd - safeMax + safeStart -= shift + safeEnd -= shift + } + + if (safeStart < safeMin) { + const shift = safeMin - safeStart + safeStart += shift + safeEnd += shift + } + + safeStart = Math.max(safeMin, safeStart) + safeEnd = Math.min(safeMax, safeEnd) + + if (safeEnd - safeStart < minRangeMs) { + if (safeStart + minRangeMs <= safeMax) { + safeEnd = safeStart + minRangeMs + } else { + safeStart = safeEnd - minRangeMs + } + } + + safeStart = Math.max(safeMin, safeStart) + safeEnd = Math.min(safeMax, safeEnd) + + return { start: safeStart, end: safeEnd } +} diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx index 10e5bf012..84bcaddd8 100644 --- a/src/features/dashboard/usage/usage-time-range-controls.tsx +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -14,6 +14,7 @@ import { } from '@/ui/primitives/popover' import { Separator } from '@/ui/primitives/separator' import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker' +import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' import { type TimeRangePreset, TimeRangePresets } from '@/ui/time-range-presets' import { TIME_RANGE_PRESETS } from './constants' import { @@ -22,6 +23,10 @@ import { normalizeToStartOfSamplingPeriod, } from './sampling-utils' +const USAGE_TIME_RANGE_BOUNDS = { + min: new Date('2023-01-01'), +} + interface UsageTimeRangeControlsProps { timeframe: { start: number @@ -106,15 +111,12 @@ export function UsageTimeRangeControls({ const handleTimeRangeApply = useCallback( (values: TimeRangeValues) => { - const startTime = values.startTime || '00:00:00' - const endTime = values.endTime || '23:59:59' - - const startTimestamp = new Date( - `${values.startDate} ${startTime}` - ).getTime() - const endTimestamp = new Date(`${values.endDate} ${endTime}`).getTime() + const timestamps = parseTimeRangeValuesToTimestamps(values) + if (!timestamps) { + return + } - onTimeRangeChange(startTimestamp, endTimestamp) + onTimeRangeChange(timestamps.start, timestamps.end) setIsTimePickerOpen(false) }, [onTimeRangeChange] @@ -166,7 +168,7 @@ export function UsageTimeRangeControls({ diff --git a/src/server/api/models/sandboxes.models.ts b/src/server/api/models/sandboxes.models.ts index 35826dd6b..69d128e4e 100644 --- a/src/server/api/models/sandboxes.models.ts +++ b/src/server/api/models/sandboxes.models.ts @@ -43,6 +43,8 @@ export interface SandboxLogsDTO { nextCursor: number | null } +export type SandboxMetric = InfraComponents['schemas']['SandboxMetric'] + // mappings export function mapInfraSandboxLogToDTO( diff --git a/src/server/api/repositories/sandboxes.repository.ts b/src/server/api/repositories/sandboxes.repository.ts index be16f717c..28cd8d847 100644 --- a/src/server/api/repositories/sandboxes.repository.ts +++ b/src/server/api/repositories/sandboxes.repository.ts @@ -152,7 +152,70 @@ export async function getSandboxDetails( }) } +// get sandbox metrics + +export interface GetSandboxMetricsOptions { + startUnixMs: number + endUnixMs: number +} + +export async function getSandboxMetrics( + accessToken: string, + teamId: string, + sandboxId: string, + options: GetSandboxMetricsOptions +) { + // convert milliseconds to seconds for the API + const startUnixSeconds = Math.floor(options.startUnixMs / 1000) + const endUnixSeconds = Math.floor(options.endUnixMs / 1000) + + const result = await infra.GET('/sandboxes/{sandboxID}/metrics', { + params: { + path: { + sandboxID: sandboxId, + }, + query: { + start: startUnixSeconds, + end: endUnixSeconds, + }, + }, + headers: { + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + }, + }) + + if (!result.response.ok || result.error) { + const status = result.response.status + + l.error( + { + key: 'repositories:sandboxes:get_sandbox_metrics:infra_error', + error: result.error, + team_id: teamId, + context: { + status, + path: '/sandboxes/{sandboxID}/metrics', + sandbox_id: sandboxId, + }, + }, + `failed to fetch /sandboxes/{sandboxID}/metrics: ${result.error?.message || 'Unknown error'}` + ) + + if (status === 404) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: "Sandbox not found or you don't have access to it", + }) + } + + throw apiError(status) + } + + return result.data +} + export const sandboxesRepo = { getSandboxLogs, getSandboxDetails, + getSandboxMetrics, } diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index b6ed87c0a..92b2d540a 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -1,4 +1,7 @@ +import { millisecondsInDay } from 'date-fns/constants' import { z } from 'zod' +import { SANDBOX_MONITORING_METRICS_RETENTION_MS } from '@/features/dashboard/sandbox/monitoring/utils/constants' +import { SandboxIdSchema } from '@/lib/schemas/api' import { createTRPCRouter } from '../init' import { mapApiSandboxRecordToDTO, @@ -17,7 +20,7 @@ export const sandboxRouter = createTRPCRouter({ details: protectedTeamProcedure .input( z.object({ - sandboxId: z.string(), + sandboxId: SandboxIdSchema, }) ) .query(async ({ ctx, input }) => { @@ -41,7 +44,7 @@ export const sandboxRouter = createTRPCRouter({ logsBackwardsReversed: protectedTeamProcedure .input( z.object({ - sandboxId: z.string(), + sandboxId: SandboxIdSchema, cursor: z.number().optional(), level: z.enum(['debug', 'info', 'warn', 'error']).optional(), search: z.string().max(256).optional(), @@ -83,7 +86,7 @@ export const sandboxRouter = createTRPCRouter({ logsForward: protectedTeamProcedure .input( z.object({ - sandboxId: z.string(), + sandboxId: SandboxIdSchema, cursor: z.number().optional(), level: z.enum(['debug', 'info', 'warn', 'error']).optional(), search: z.string().max(256).optional(), @@ -121,5 +124,48 @@ export const sandboxRouter = createTRPCRouter({ return result }), + resourceMetrics: protectedTeamProcedure + .input( + z + .object({ + sandboxId: SandboxIdSchema, + startMs: z.number().int().positive(), + endMs: z.number().int().positive(), + }) + .refine(({ startMs, endMs }) => startMs < endMs, { + message: 'startMs must be before endMs', + }) + .refine( + ({ startMs, endMs }) => { + const now = Date.now() + return ( + startMs >= now - SANDBOX_MONITORING_METRICS_RETENTION_MS && + endMs <= now + millisecondsInDay + ) + }, + { + message: + 'Time range must be within metrics retention window (7 days) and 1 day from now', + } + ) + ) + .query(async ({ ctx, input }) => { + const { teamId, session } = ctx + const { sandboxId } = input + const { startMs, endMs } = input + + const metrics = await sandboxesRepo.getSandboxMetrics( + session.access_token, + teamId, + sandboxId, + { + startUnixMs: startMs, + endUnixMs: endMs, + } + ) + + return metrics + }), + // MUTATIONS }) diff --git a/src/styles/theme.css b/src/styles/theme.css index c58a72729..2058328ae 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -92,9 +92,9 @@ --accent-secondary-error-highlight: #ff8763; --accent-secondary-error-bg: rgb(255, 135, 99, 0.16); - --graph-1: #fce8d8; - --graph-2: #f7b076; - --graph-3: #e27c1d; + --graph-1: #3b1d05; + --graph-2: #eea064; + --graph-3: #d5751c; --graph-4: #c09bb8; /* Pastel purple-peach */ --graph-5: #a67fa9; /* Pastel purple leaning to graph-6 */ --graph-6: #8c5ca5; /* Purple with orange contrast */ @@ -116,14 +116,14 @@ --graph-area-fg-from: rgba(108, 108, 108, 0.2); --graph-area-fg-to: rgba(250, 250, 250, 0.2); - --graph-area-1-from: rgba(252, 232, 216, 0.25); /* Peachy area gradient */ - --graph-area-1-to: rgba(250, 250, 250, 0.2); + --graph-area-1-from: rgba(59, 29, 5, 0.26); /* Dark orange area gradient */ + --graph-area-1-to: rgba(250, 250, 250, 0.16); - --graph-area-2-from: rgba(247, 176, 118, 0.2); /* Orange area gradient */ - --graph-area-2-to: rgba(250, 250, 250, 0.2); + --graph-area-2-from: rgba(238, 160, 100, 0.26); /* Orange area gradient */ + --graph-area-2-to: rgba(250, 250, 250, 0.16); - --graph-area-3-from: rgba(226, 124, 29, 0.18); /* Dark orange area gradient */ - --graph-area-3-to: rgba(250, 250, 250, 0.2); + --graph-area-3-from: rgba(213, 117, 28, 0.22); /* Dark orange area gradient */ + --graph-area-3-to: rgba(250, 250, 250, 0.16); --graph-area-4-from: rgba( 192, diff --git a/src/ui/time-range-picker.logic.ts b/src/ui/time-range-picker.logic.ts new file mode 100644 index 000000000..36f571791 --- /dev/null +++ b/src/ui/time-range-picker.logic.ts @@ -0,0 +1,358 @@ +import { z } from 'zod' + +export interface TimeRangeValues { + startDate: string + startTime: string | null + endDate: string + endTime: string | null +} + +export interface TimeRangePickerBounds { + min?: Date + max?: Date +} + +type TimeRangeField = 'startDate' | 'endDate' + +export interface TimeRangeIssue { + field: TimeRangeField + message: string +} + +export interface TimeRangeValidationResult { + startDateTime: Date | null + endDateTime: Date | null + issues: TimeRangeIssue[] +} + +interface TimeRangeValidationOptions { + hideTime: boolean + bounds?: TimeRangePickerBounds +} + +function normalizeDateInput(value: string): string { + return value.trim().replaceAll(' ', '').replaceAll('-', '/') +} + +function parseDateInput(value: string): Date | null { + const normalized = normalizeDateInput(value) + if (!normalized) { + return null + } + + const parts = normalized.split('/') + if (parts.length !== 3) { + return null + } + + const [first, second, third] = parts + if (!first || !second || !third) { + return null + } + + const firstValue = Number.parseInt(first, 10) + const secondValue = Number.parseInt(second, 10) + const thirdValue = Number.parseInt(third, 10) + + if ( + Number.isNaN(firstValue) || + Number.isNaN(secondValue) || + Number.isNaN(thirdValue) + ) { + return null + } + + let year: number + let month: number + let day: number + + if (first.length === 4) { + year = firstValue + month = secondValue + day = thirdValue + } else if (third.length === 4) { + day = firstValue + month = secondValue + year = thirdValue + } else { + return null + } + + if (month < 1 || month > 12 || day < 1 || day > 31) { + return null + } + + const parsed = new Date(year, month - 1, day) + + if ( + parsed.getFullYear() !== year || + parsed.getMonth() !== month - 1 || + parsed.getDate() !== day + ) { + return null + } + + return parsed +} + +function parseTimeInput(value: string): { + hours: number + minutes: number + seconds: number +} | null { + const normalized = value.trim().replaceAll(' ', '') + if (!normalized) { + return null + } + + const parts = normalized.split(':') + if (parts.length === 2) { + parts.push('0') + } + + if (parts.length !== 3) { + return null + } + + const [hourPart, minutePart, secondPart] = parts + if (!hourPart || !minutePart || !secondPart) { + return null + } + + const hours = Number.parseInt(hourPart, 10) + const minutes = Number.parseInt(minutePart, 10) + const seconds = Number.parseInt(secondPart, 10) + + if (Number.isNaN(hours) || Number.isNaN(minutes) || Number.isNaN(seconds)) { + return null + } + + if (hours < 0 || hours > 23) { + return null + } + + if (minutes < 0 || minutes > 59) { + return null + } + + if (seconds < 0 || seconds > 59) { + return null + } + + return { hours, minutes, seconds } +} + +export function toSecondPrecision(date: Date): Date { + return new Date(Math.floor(date.getTime() / 1000) * 1000) +} + +function formatDateValue(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}/${month}/${day}` +} + +function formatTimeValue( + hours: number, + minutes: number, + seconds: number +): string { + const hh = String(hours).padStart(2, '0') + const mm = String(minutes).padStart(2, '0') + const ss = String(seconds).padStart(2, '0') + return `${hh}:${mm}:${ss}` +} + +export function parsePickerDateTime( + dateInput: string, + timeInput: string | null | undefined, + fallbackTime: string +): Date | null { + const parsedDate = parseDateInput(dateInput) + if (!parsedDate) { + return null + } + + const effectiveTime = + timeInput && timeInput.trim().length > 0 ? timeInput : fallbackTime + const parsedTime = parseTimeInput(effectiveTime) + if (!parsedTime) { + return null + } + + return new Date( + parsedDate.getFullYear(), + parsedDate.getMonth(), + parsedDate.getDate(), + parsedTime.hours, + parsedTime.minutes, + parsedTime.seconds, + 0 + ) +} + +function formatBoundaryDateTime(date: Date, hideTime: boolean): string { + if (hideTime) { + return date.toLocaleDateString() + } + + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function normalizeTimeValue(time: string | null): string | null { + if (!time) { + return null + } + + const parsedTime = parseTimeInput(time) + if (!parsedTime) { + return time.trim() + } + + return formatTimeValue( + parsedTime.hours, + parsedTime.minutes, + parsedTime.seconds + ) +} + +export function normalizeTimeRangeValues( + values: TimeRangeValues +): TimeRangeValues { + const parsedStartDate = parseDateInput(values.startDate) + const parsedEndDate = parseDateInput(values.endDate) + + return { + startDate: parsedStartDate + ? formatDateValue(parsedStartDate) + : values.startDate.trim(), + startTime: normalizeTimeValue(values.startTime), + endDate: parsedEndDate + ? formatDateValue(parsedEndDate) + : values.endDate.trim(), + endTime: normalizeTimeValue(values.endTime), + } +} + +export function parseTimeRangeValuesToTimestamps( + values: TimeRangeValues +): { start: number; end: number } | null { + const startDateTime = parsePickerDateTime( + values.startDate, + values.startTime, + '00:00:00' + ) + const endDateTime = parsePickerDateTime( + values.endDate, + values.endTime, + '23:59:59' + ) + + if (!startDateTime || !endDateTime) { + return null + } + + return { + start: startDateTime.getTime(), + end: endDateTime.getTime(), + } +} + +export function validateTimeRangeValues( + values: TimeRangeValues, + { bounds, hideTime }: TimeRangeValidationOptions +): TimeRangeValidationResult { + const issues: TimeRangeIssue[] = [] + + const startDateTime = parsePickerDateTime( + values.startDate, + hideTime ? null : values.startTime, + '00:00:00' + ) + const endDateTime = parsePickerDateTime( + values.endDate, + hideTime ? null : values.endTime, + '23:59:59' + ) + + if (!startDateTime) { + issues.push({ + field: 'startDate', + message: 'Invalid start date format', + }) + } + + if (!endDateTime) { + issues.push({ + field: 'endDate', + message: 'Invalid end date format', + }) + } + + if (!startDateTime || !endDateTime) { + return { + startDateTime, + endDateTime, + issues, + } + } + + const minBoundary = bounds?.min ? toSecondPrecision(bounds.min) : undefined + const maxBoundary = bounds?.max ? toSecondPrecision(bounds.max) : undefined + + if (minBoundary && startDateTime.getTime() < minBoundary.getTime()) { + issues.push({ + field: 'startDate', + message: `Start date cannot be before ${formatBoundaryDateTime(minBoundary, hideTime)}`, + }) + } + + if (maxBoundary && endDateTime.getTime() > maxBoundary.getTime()) { + issues.push({ + field: 'endDate', + message: `End date cannot be after ${formatBoundaryDateTime(maxBoundary, hideTime)}`, + }) + } + + if (endDateTime.getTime() < startDateTime.getTime()) { + issues.push({ + field: 'endDate', + message: 'End date cannot be before start date', + }) + } + + return { + startDateTime, + endDateTime, + issues, + } +} + +export function createTimeRangeSchema(options: TimeRangeValidationOptions) { + return z + .object({ + startDate: z.string().min(1, 'Start date is required'), + startTime: z.string().nullable(), + endDate: z.string().min(1, 'End date is required'), + endTime: z.string().nullable(), + }) + .superRefine((data, ctx) => { + const validation = validateTimeRangeValues(data, options) + + for (const issue of validation.issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: [issue.field], + }) + } + }) +} diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx index 36dec25cb..e57b0a9b4 100644 --- a/src/ui/time-range-picker.tsx +++ b/src/ui/time-range-picker.tsx @@ -1,20 +1,12 @@ -/** - * General-purpose time range selection component - * A simplified abstraction for picking start and end date/time ranges - */ - 'use client' import { zodResolver } from '@hookform/resolvers/zod' +import { endOfDay, startOfDay } from 'date-fns' import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' -import { z } from 'zod' import { cn } from '@/lib/utils' -import { - parseDateTimeComponents, - tryParseDatetime, -} from '@/lib/utils/formatting' +import { parseDateTimeComponents } from '@/lib/utils/formatting' import { Button } from './primitives/button' import { @@ -26,38 +18,29 @@ import { FormMessage, } from './primitives/form' import { TimeInput } from './time-input' +import { + createTimeRangeSchema, + normalizeTimeRangeValues, + type TimeRangePickerBounds, + type TimeRangeValues, +} from './time-range-picker.logic' -export interface TimeRangeValues { - startDate: string - startTime: string | null - endDate: string - endTime: string | null -} +export type { TimeRangeValues } from './time-range-picker.logic' interface TimeRangePickerProps { - /** Initial start datetime in any parseable format */ startDateTime: string - /** Initial end datetime in any parseable format */ endDateTime: string - /** Optional minimum selectable date */ - minDate?: Date - /** Optional maximum selectable date */ - maxDate?: Date - /** Called when Apply button is clicked */ + bounds?: TimeRangePickerBounds onApply?: (values: TimeRangeValues) => void - /** Called whenever values change (real-time) */ onChange?: (values: TimeRangeValues) => void - /** Custom className for the container */ className?: string - /** Hide time inputs and only show date pickers (default: false) */ hideTime?: boolean } export function TimeRangePicker({ startDateTime, endDateTime, - minDate, - maxDate, + bounds, onApply, onChange, className, @@ -65,6 +48,9 @@ export function TimeRangePicker({ }: TimeRangePickerProps) { 'use no memo' + const minBoundMs = bounds?.min?.getTime() + const maxBoundMs = bounds?.max?.getTime() + const startParts = useMemo( () => parseDateTimeComponents(startDateTime), [startDateTime] @@ -74,134 +60,63 @@ export function TimeRangePicker({ [endDateTime] ) - // Create dynamic zod schema based on min/max dates - const schema = useMemo(() => { - // When hideTime is true, allow dates up to end of today - // Otherwise, allow up to now + 10 seconds (for time drift) - const defaultMaxDate = hideTime - ? (() => { - const endOfToday = new Date() - endOfToday.setDate(endOfToday.getDate() + 1) - endOfToday.setHours(0, 0, 0, 0) - return endOfToday - })() - : new Date(Date.now() + 10000) - - const maxDateValue = maxDate || defaultMaxDate - const minDateValue = minDate - - return z - .object({ - startDate: z.string().min(1, 'Start date is required'), - startTime: z.string().nullable(), - endDate: z.string().min(1, 'End date is required'), - endTime: z.string().nullable(), - }) - .superRefine((data, ctx) => { - const startTimeStr = data.startTime || '00:00:00' - const endTimeStr = data.endTime || '23:59:59' - const startTimestamp = tryParseDatetime( - `${data.startDate} ${startTimeStr}` - )?.getTime() - const endTimestamp = tryParseDatetime( - `${data.endDate} ${endTimeStr}` - )?.getTime() - - if (!startTimestamp) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid start date format', - path: ['startDate'], - }) - return - } - - if (!endTimestamp) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid end date format', - path: ['endDate'], - }) - return - } + const calendarMinDate = useMemo( + () => + minBoundMs !== undefined ? startOfDay(new Date(minBoundMs)) : undefined, + [minBoundMs] + ) - // validate against min date - if (minDateValue && startTimestamp < minDateValue.getTime()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Start date cannot be before ${minDateValue.toLocaleDateString()}`, - path: ['startDate'], - }) - } + const calendarMaxDate = useMemo( + () => + maxBoundMs !== undefined ? endOfDay(new Date(maxBoundMs)) : undefined, + [maxBoundMs] + ) - // validate end date is not before start date - if (endTimestamp < startTimestamp) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'End date cannot be before start date', - path: ['endDate'], - }) - } - }) - }, [minDate, maxDate, hideTime]) + const schema = useMemo(() => { + return createTimeRangeSchema({ + hideTime, + bounds: { + min: minBoundMs !== undefined ? new Date(minBoundMs) : undefined, + max: maxBoundMs !== undefined ? new Date(maxBoundMs) : undefined, + }, + }) + }, [hideTime, maxBoundMs, minBoundMs]) - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: { + const defaultValues = useMemo( + () => ({ startDate: startParts.date || '', startTime: startParts.time || null, endDate: endParts.date || '', endTime: endParts.time || null, - }, - mode: 'onChange', + }), + [endParts.date, endParts.time, startParts.date, startParts.time] + ) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues, + mode: 'onSubmit', + reValidateMode: 'onChange', }) - // sync with external props when they change useEffect(() => { - const currentStartTime = form.getValues('startDate') - ? tryParseDatetime( - `${form.getValues('startDate')} ${form.getValues('startTime')}` - )?.getTime() - : undefined - const currentEndTime = form.getValues('endDate') - ? tryParseDatetime( - `${form.getValues('endDate')} ${form.getValues('endTime')}` - )?.getTime() - : undefined - - const propStartTime = startDateTime - ? tryParseDatetime(startDateTime)?.getTime() - : undefined - const propEndTime = endDateTime - ? tryParseDatetime(endDateTime)?.getTime() - : undefined - - // detect meaningful external changes (>1s difference) - const startChanged = - propStartTime && - currentStartTime && - Math.abs(propStartTime - currentStartTime) > 1000 - const endChanged = - propEndTime && - currentEndTime && - Math.abs(propEndTime - currentEndTime) > 1000 - - const isExternalChange = startChanged || endChanged - - if (isExternalChange && !form.formState.isDirty) { - const newStartParts = parseDateTimeComponents(startDateTime) - const newEndParts = parseDateTimeComponents(endDateTime) + if (form.formState.isDirty) { + return + } - form.reset({ - startDate: newStartParts.date || '', - startTime: newStartParts.time || null, - endDate: newEndParts.date || '', - endTime: newEndParts.time || null, - }) + const currentValues = form.getValues() + if ( + currentValues.startDate === defaultValues.startDate && + currentValues.startTime === defaultValues.startTime && + currentValues.endDate === defaultValues.endDate && + currentValues.endTime === defaultValues.endTime + ) { + return } - }, [startDateTime, endDateTime, form]) - // Notify on changes + form.reset(defaultValues) + }, [defaultValues, form, form.formState.isDirty]) + useEffect(() => { const subscription = form.watch((values) => { onChange?.(values as TimeRangeValues) @@ -211,11 +126,15 @@ export function TimeRangePicker({ const handleSubmit = useCallback( (values: TimeRangeValues) => { - onApply?.(values) + const normalizedValues = normalizeTimeRangeValues(values) + onApply?.(normalizedValues) + form.reset(normalizedValues) }, - [onApply] + [form, onApply] ) + const shouldValidateOnChange = form.formState.submitCount > 0 + return (
{ - dateField.onChange(value) - form.trigger(['startDate', 'endDate']) + form.setValue('startDate', value, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: shouldValidateOnChange, + }) }} onTimeChange={(value) => { form.setValue('startTime', value || null, { - shouldValidate: true, shouldDirty: true, + shouldTouch: true, + shouldValidate: shouldValidateOnChange, }) - form.trigger(['startDate', 'endDate']) }} disabled={false} hideTime={hideTime} @@ -268,18 +190,21 @@ export function TimeRangePicker({ { - dateField.onChange(value) - form.trigger(['startDate', 'endDate']) + form.setValue('endDate', value, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: shouldValidateOnChange, + }) }} onTimeChange={(value) => { form.setValue('endTime', value || null, { - shouldValidate: true, shouldDirty: true, + shouldTouch: true, + shouldValidate: shouldValidateOnChange, }) - form.trigger(['startDate', 'endDate']) }} disabled={false} hideTime={hideTime} @@ -292,7 +217,7 @@ export function TimeRangePicker({