diff --git a/bun.lock b/bun.lock
index 9d650c50d..54d9777ee 100644
--- a/bun.lock
+++ b/bun.lock
@@ -45,7 +45,7 @@
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-table": "^8.20.6",
- "@tanstack/react-virtual": "^3.11.3",
+ "@tanstack/react-virtual": "^3.13.12",
"@theguild/remark-mermaid": "^0.2.0",
"@trpc/client": "^11.7.1",
"@trpc/react-query": "^11.7.1",
@@ -66,7 +66,7 @@
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"deepmerge": "^4.3.1",
- "e2b": "1.10.0",
+ "e2b": "^2.7.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"fast-xml-parser": "^4.5.1",
@@ -104,7 +104,7 @@
"styled-components": "^6.1.19",
"superjson": "^2.2.5",
"swr": "^2.3.4",
- "tailwind-merge": "^3.3.1",
+ "tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.3.6",
"usehooks-ts": "^3.1.0",
"vaul": "^1.1.2",
@@ -424,8 +424,14 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig=="],
+ "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
+
+ "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
+
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
+ "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
+
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="],
"@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="],
@@ -1392,6 +1398,8 @@
"chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="],
+ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
+
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
"chrono-node": ["chrono-node@2.9.0", "", {}, "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ=="],
@@ -1580,6 +1588,8 @@
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
+ "dockerfile-ast": ["dockerfile-ast@0.7.1", "", { "dependencies": { "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3" } }, "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw=="],
+
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
@@ -1602,7 +1612,7 @@
"duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="],
- "e2b": ["e2b@1.10.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "compare-versions": "^6.1.0", "openapi-fetch": "^0.9.7", "platform": "^1.3.6" } }, "sha512-m0lt8hTQ84M7tUjF2Dw7oNwfMcc8EyCHJtA1vX6Sv3OO2OtjPdCky854XWY+UejDK+q3m5vuSpSgLgeE0rJ7LA=="],
+ "e2b": ["e2b@2.7.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.0.3", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.4.3" } }, "sha512-pbCbkkdkkY+yIhhtdSE7lM/vhIROtHNI0hNpj8lBphDILNH2qmmjhxU7/wam8/xWRbiWbfuQaOsv100lD32nag=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -1800,7 +1810,7 @@
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
- "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+ "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -1978,7 +1988,7 @@
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
- "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
+ "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="],
"jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="],
@@ -2190,6 +2200,8 @@
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
+ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
+
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
@@ -2298,7 +2310,7 @@
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
- "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+ "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -2644,7 +2656,7 @@
"synckit": ["synckit@0.11.11", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw=="],
- "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
+ "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="],
@@ -2652,6 +2664,8 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+ "tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="],
+
"teeny-request": ["teeny-request@9.0.0", "", { "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.9", "stream-events": "^1.0.5", "uuid": "^9.0.0" } }, "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g=="],
"terser": ["terser@5.44.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw=="],
@@ -2842,7 +2856,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
- "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="],
@@ -3212,7 +3226,7 @@
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
- "e2b/openapi-fetch": ["openapi-fetch@0.9.8", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.8" } }, "sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg=="],
+ "e2b/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -3234,7 +3248,7 @@
"gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
- "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
+ "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
@@ -3254,6 +3268,8 @@
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+ "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+
"mermaid/stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
"mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
@@ -3278,7 +3294,7 @@
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
- "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+ "path-scurry/lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
@@ -3344,6 +3360,8 @@
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
+ "test-exclude/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
@@ -3504,12 +3522,8 @@
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
- "e2b/openapi-fetch/openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.8", "", {}, "sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g=="],
-
"extract-zip/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
- "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
-
"jest-diff/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"jest-matcher-utils/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
@@ -3590,6 +3604,10 @@
"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=="],
+
+ "test-exclude/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+
"test-exclude/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
@@ -3700,6 +3718,8 @@
"remark-mermaid/unist-util-visit/unist-util-visit-parents/unist-util-is": ["unist-util-is@3.0.0", "", {}, "sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A=="],
+ "test-exclude/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
"mermaid.cli/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
}
}
diff --git a/next.config.mjs b/next.config.mjs
index 292b0fe31..a6b163d5a 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -14,6 +14,16 @@ const config = {
authInterrupts: true,
clientSegmentCache: true,
},
+ turbopack: {
+ resolveAlias: {
+ // Stub Node.js modules for browser builds
+ // e2b package bundles these packages. when dealing with browser chunks,
+ // we need to stub these packages for builds.
+ fs: { browser: './stubs/fs.ts' },
+ 'node:fs': { browser: './stubs/fs.ts' },
+ 'node:fs/promises': { browser: './stubs/fs-promises.ts' },
+ },
+ },
logging: {
fetches: {
fullUrl: true,
diff --git a/package.json b/package.json
index 885d14b8c..b7d534581 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,8 @@
"scripts:create-migration": "bun scripts/create-migration.ts",
"<<<<<<< Development": "",
"shad": "bunx shadcn@canary",
- "test:development:traffic": "vitest run src/__test__/development/traffic.test.ts",
+ "test:dev:traffic": "vitest run src/__test__/development/traffic.test.ts",
+ "test:dev:build": "vitest run src/__test__/development/template-build.test.ts",
"clean": "rm -rf .next node_modules && bun install",
"<<<<<<< Testing": "",
"test:run": "bun scripts:check-all-env && vitest run",
@@ -81,7 +82,7 @@
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-table": "^8.20.6",
- "@tanstack/react-virtual": "^3.11.3",
+ "@tanstack/react-virtual": "^3.13.12",
"@theguild/remark-mermaid": "^0.2.0",
"@trpc/client": "^11.7.1",
"@trpc/react-query": "^11.7.1",
@@ -102,7 +103,7 @@
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"deepmerge": "^4.3.1",
- "e2b": "1.10.0",
+ "e2b": "^2.7.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"fast-xml-parser": "^4.5.1",
@@ -140,7 +141,7 @@
"styled-components": "^6.1.19",
"superjson": "^2.2.5",
"swr": "^2.3.4",
- "tailwind-merge": "^3.3.1",
+ "tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.3.6",
"usehooks-ts": "^3.1.0",
"vaul": "^1.1.2",
diff --git a/src/__test__/development/template-build.test.ts b/src/__test__/development/template-build.test.ts
new file mode 100644
index 000000000..056edd99d
--- /dev/null
+++ b/src/__test__/development/template-build.test.ts
@@ -0,0 +1,63 @@
+/**
+ * This test builds a basic sandbox template using the E2B SDK,
+ * useful for development and testing of template building features in the dashboard.
+ */
+
+import { Template } from 'e2b'
+import { describe, expect, it } from 'vitest'
+
+const l = console
+
+const { TEST_E2B_DOMAIN, TEST_E2B_API_KEY } = import.meta.env
+
+if (!TEST_E2B_DOMAIN || !TEST_E2B_API_KEY) {
+ throw new Error(
+ 'Missing environment variables: TEST_E2B_DOMAIN and/or TEST_E2B_API_KEY'
+ )
+}
+
+const BUILD_TIMEOUT_MS = 5 * 60 * 1000
+
+describe('E2B Template build test', () => {
+ it(
+ 'builds a basic template with Node.js',
+ { timeout: BUILD_TIMEOUT_MS },
+ async () => {
+ const templateName = `test-template-${Date.now()}`
+
+ l.info('test:starting_template_build', {
+ templateName,
+ startTime: new Date().toISOString(),
+ })
+
+ const template = Template()
+ .skipCache()
+ .fromNodeImage('lts')
+ .setWorkdir('/app')
+ .runCmd('echo "Hello from template build"')
+ .setStartCmd('node --version', 'node --version')
+
+ const buildInfo = await Template.build(template, {
+ alias: templateName,
+ apiKey: TEST_E2B_API_KEY,
+ domain: TEST_E2B_DOMAIN,
+ onBuildLogs: (log) => {
+ l.info('test:build_log', {
+ level: log.level,
+ message: log.message,
+ })
+ },
+ })
+
+ l.info('test:template_build_completed', {
+ templateId: buildInfo.templateId,
+ buildId: buildInfo.buildId,
+ alias: buildInfo.alias,
+ endTime: new Date().toISOString(),
+ })
+
+ expect(buildInfo.templateId).toBeDefined()
+ expect(buildInfo.buildId).toBeDefined()
+ }
+ )
+})
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/default.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/default.tsx
new file mode 100644
index 000000000..86b9e9a38
--- /dev/null
+++ b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/default.tsx
@@ -0,0 +1,3 @@
+export default function Default() {
+ return null
+}
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/page.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/page.tsx
new file mode 100644
index 000000000..3e280c132
--- /dev/null
+++ b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@builds/page.tsx
@@ -0,0 +1,11 @@
+import BuildsHeader from '@/features/dashboard/templates/builds/header'
+import BuildsTable from '@/features/dashboard/templates/builds/table'
+
+export default function BuildsPage() {
+ return (
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/default.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/default.tsx
new file mode 100644
index 000000000..86b9e9a38
--- /dev/null
+++ b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/default.tsx
@@ -0,0 +1,3 @@
+export default function Default() {
+ return null
+}
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/page.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/page.tsx
new file mode 100644
index 000000000..5900c4cf3
--- /dev/null
+++ b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/@list/page.tsx
@@ -0,0 +1,11 @@
+import LoadingLayout from '@/features/dashboard/loading-layout'
+import TemplatesTable from '@/features/dashboard/templates/list/table'
+import { Suspense } from 'react'
+
+export default async function ListPage() {
+ return (
+ }>
+
+
+ )
+}
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/layout.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/layout.tsx
new file mode 100644
index 000000000..4e7debefb
--- /dev/null
+++ b/src/app/dashboard/[teamIdOrSlug]/templates/(tabs)/layout.tsx
@@ -0,0 +1,33 @@
+import { DashboardTab, DashboardTabs } from '@/ui/dashboard-tabs'
+import { BuildIcon, ListIcon } from '@/ui/primitives/icons'
+
+export default function TemplatesLayout({
+ list,
+ builds,
+}: LayoutProps<'/dashboard/[teamIdOrSlug]/templates'> & {
+ list: React.ReactNode
+ builds: React.ReactNode
+}) {
+ return (
+
+ }
+ >
+ {list}
+
+ }
+ >
+ {builds}
+
+
+ )
+}
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/page.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/page.tsx
deleted file mode 100644
index ea21637fc..000000000
--- a/src/app/dashboard/[teamIdOrSlug]/templates/page.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import LoadingLayout from '@/features/dashboard/loading-layout'
-import TemplatesTable from '@/features/dashboard/templates/table'
-import { HydrateClient, prefetch, trpc } from '@/trpc/server'
-import { Suspense } from 'react'
-
-export default async function Page({
- params,
-}: PageProps<'/dashboard/[teamIdOrSlug]/templates'>) {
- const { teamIdOrSlug } = await params
-
- prefetch(
- trpc.templates.getTemplates.queryOptions({
- teamIdOrSlug,
- })
- )
- prefetch(trpc.templates.getDefaultTemplatesCached.queryOptions())
-
- return (
-
- }>
-
-
-
- )
-}
diff --git a/src/configs/urls.ts b/src/configs/urls.ts
index 7966ea1e5..ed4af2c4a 100644
--- a/src/configs/urls.ts
+++ b/src/configs/urls.ts
@@ -35,6 +35,11 @@ export const PROTECTED_URLS = {
WEBHOOKS: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/webhooks`,
TEMPLATES: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/templates`,
+ TEMPLATES_LIST: (teamIdOrSlug: string) =>
+ `/dashboard/${teamIdOrSlug}/templates?tab=list`,
+ TEMPLATES_BUILDS: (teamIdOrSlug: string) =>
+ `/dashboard/${teamIdOrSlug}/templates?tab=builds`,
+
USAGE: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/usage`,
BILLING: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/billing`,
BUDGET: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/budget`,
diff --git a/src/features/dashboard/layout/wrapper.tsx b/src/features/dashboard/layout/wrapper.tsx
index ba0925305..6473d5317 100644
--- a/src/features/dashboard/layout/wrapper.tsx
+++ b/src/features/dashboard/layout/wrapper.tsx
@@ -4,31 +4,32 @@ import { getDashboardLayoutConfig } from '@/configs/layout'
import { CatchErrorBoundary } from '@/ui/error'
import { usePathname } from 'next/navigation'
-export default function DashboardLayoutWrapper({
+export function DefaultDashboardLayout({
children,
}: {
children: React.ReactNode
}) {
- const pathname = usePathname()
- const config = getDashboardLayoutConfig(pathname)
-
- if (config.type === 'default') {
- return (
-
-
-
- {children}
-
-
+ return (
+
+
+
+ {children}
+
- )
- }
+
+ )
+}
+export function CustomDashboardLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
return (
)
}
+
+export default function DashboardLayoutWrapper({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ const pathname = usePathname()
+ const config = getDashboardLayoutConfig(pathname)
+
+ if (config.type === 'default') {
+ return {children}
+ }
+
+ return {children}
+}
diff --git a/src/features/dashboard/loading-layout.tsx b/src/features/dashboard/loading-layout.tsx
index b916cd40c..a0d073ffc 100644
--- a/src/features/dashboard/loading-layout.tsx
+++ b/src/features/dashboard/loading-layout.tsx
@@ -1,9 +1,11 @@
-import { Loader } from '@/ui/primitives/loader_d'
+'use client'
+
+import { Loader } from '@/ui/primitives/loader'
export default function LoadingLayout() {
return (
-
+
)
}
diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx
index 1359ed0b6..b80283bf5 100644
--- a/src/features/dashboard/sandbox/inspect/context.tsx
+++ b/src/features/dashboard/sandbox/inspect/context.tsx
@@ -37,7 +37,7 @@ interface SandboxInspectProviderProps {
seedEntries?: EntryInfo[]
}
-export function SandboxInspectProvider({
+export default function SandboxInspectProvider({
children,
rootPath,
seedEntries,
@@ -232,8 +232,7 @@ export function SandboxInspectProvider({
sandboxManagerRef.current = new SandboxManager(
storeRef.current,
sandbox,
- rootPath,
- sandboxInfo.envdAccessToken !== undefined
+ rootPath
)
trackInteraction('started_watching', {
diff --git a/src/features/dashboard/sandbox/inspect/sandbox-manager.ts b/src/features/dashboard/sandbox/inspect/sandbox-manager.ts
index 1aa5364a0..974ad4941 100644
--- a/src/features/dashboard/sandbox/inspect/sandbox-manager.ts
+++ b/src/features/dashboard/sandbox/inspect/sandbox-manager.ts
@@ -25,7 +25,6 @@ export class SandboxManager {
private readonly rootPath: string
private store: FilesystemStore
private sandbox: Sandbox
- private readonly isSandboxSecure: boolean = false
private static readonly LOAD_DEBOUNCE_MS = 250
private static readonly READ_DEBOUNCE_MS = 250
@@ -50,16 +49,10 @@ export class SandboxManager {
}
> = new Map()
- constructor(
- store: FilesystemStore,
- sandbox: Sandbox,
- rootPath: string,
- isSandboxSecure: boolean
- ) {
+ constructor(store: FilesystemStore, sandbox: Sandbox, rootPath: string) {
this.store = store
this.sandbox = sandbox
this.rootPath = normalizePath(rootPath)
- this.isSandboxSecure = isSandboxSecure
// immediately start a single recursive watcher at the root
void this.startRootWatcher()
@@ -360,11 +353,8 @@ export class SandboxManager {
const downloadUrl = await this.sandbox.downloadUrl(normalizedPath, {
user: 'root',
- useSignature: this.isSandboxSecure || undefined,
})
- console.log('downloadUrl', downloadUrl)
-
return downloadUrl
}
diff --git a/src/features/dashboard/sandbox/inspect/view.tsx b/src/features/dashboard/sandbox/inspect/view.tsx
index 83de3254a..ccc3f9867 100644
--- a/src/features/dashboard/sandbox/inspect/view.tsx
+++ b/src/features/dashboard/sandbox/inspect/view.tsx
@@ -1,6 +1,6 @@
'use client'
-import { SandboxInspectProvider } from '@/features/dashboard/sandbox/inspect/context'
+import SandboxInspectProvider from '@/features/dashboard/sandbox/inspect/context'
import SandboxInspectFilesystem from '@/features/dashboard/sandbox/inspect/filesystem'
import SandboxInspectViewer from '@/features/dashboard/sandbox/inspect/viewer'
import { cn } from '@/lib/utils'
diff --git a/src/features/dashboard/sandboxes/list/table-cells.tsx b/src/features/dashboard/sandboxes/list/table-cells.tsx
index 36d66b520..5bae0c898 100644
--- a/src/features/dashboard/sandboxes/list/table-cells.tsx
+++ b/src/features/dashboard/sandboxes/list/table-cells.tsx
@@ -2,7 +2,7 @@
import { PROTECTED_URLS } from '@/configs/urls'
import ResourceUsage from '@/features/dashboard/common/resource-usage'
-import { useTemplateTableStore } from '@/features/dashboard/templates/stores/table-store'
+import { useTemplateTableStore } from '@/features/dashboard/templates/list/stores/table-store'
import {
defaultErrorToast,
defaultSuccessToast,
diff --git a/src/features/dashboard/templates/builds/constants.ts b/src/features/dashboard/templates/builds/constants.ts
new file mode 100644
index 000000000..c7bd04dca
--- /dev/null
+++ b/src/features/dashboard/templates/builds/constants.ts
@@ -0,0 +1,7 @@
+import { BuildStatusDTO } from '@/server/api/models/builds.models'
+
+export const INITIAL_BUILD_STATUSES: BuildStatusDTO[] = [
+ 'building',
+ 'failed',
+ 'success',
+]
diff --git a/src/features/dashboard/templates/builds/empty.tsx b/src/features/dashboard/templates/builds/empty.tsx
new file mode 100644
index 000000000..e4e643463
--- /dev/null
+++ b/src/features/dashboard/templates/builds/empty.tsx
@@ -0,0 +1,24 @@
+import { cn } from '@/lib/utils'
+import { BuildIcon } from '@/ui/primitives/icons'
+
+interface BuildsEmptyProps {
+ error?: string
+}
+
+export default function BuildsEmpty({ error }: BuildsEmptyProps) {
+ return (
+
+
+
+ {error ? error : 'No template builds found'}
+
+
+ )
+}
diff --git a/src/features/dashboard/templates/builds/filter-params.ts b/src/features/dashboard/templates/builds/filter-params.ts
new file mode 100644
index 000000000..05acc834d
--- /dev/null
+++ b/src/features/dashboard/templates/builds/filter-params.ts
@@ -0,0 +1,17 @@
+import {
+ createLoader,
+ parseAsArrayOf,
+ parseAsString,
+ parseAsStringEnum,
+} from 'nuqs/server'
+
+export const templateBuildsFilterParams = {
+ statuses: parseAsArrayOf(
+ parseAsStringEnum(['building', 'failed', 'success'])
+ ),
+ buildIdOrTemplate: parseAsString,
+}
+
+export const loadTemplateBuildsFilters = createLoader(
+ templateBuildsFilterParams
+)
diff --git a/src/features/dashboard/templates/builds/header.tsx b/src/features/dashboard/templates/builds/header.tsx
new file mode 100644
index 000000000..f827cddbf
--- /dev/null
+++ b/src/features/dashboard/templates/builds/header.tsx
@@ -0,0 +1,150 @@
+'use client'
+
+import { cn } from '@/lib/utils'
+import type { BuildStatusDTO } from '@/server/api/models/builds.models'
+import { Button } from '@/ui/primitives/button'
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/ui/primitives/dropdown-menu'
+import { Input } from '@/ui/primitives/input'
+import { useEffect, useState } from 'react'
+import { Status } from './table-cells'
+import useFilters from './use-filters'
+
+interface DashedStatusCircleIconProps {
+ status: BuildStatusDTO
+ index: number
+}
+
+const DashedStatusCircleIcon = ({
+ status,
+ index,
+}: DashedStatusCircleIconProps) => {
+ return (
+
+ )
+}
+
+const StatusIcons = ({
+ selectedStatuses,
+}: {
+ selectedStatuses: BuildStatusDTO[]
+}) => {
+ const statusOrder: BuildStatusDTO[] = ['building', 'failed', 'success']
+ const sortedStatuses = statusOrder.filter((s) => selectedStatuses.includes(s))
+
+ return (
+
+ {sortedStatuses.map((status, i) => (
+
+ ))}
+
+ )
+}
+
+const STATUS_OPTIONS: Array<{ value: BuildStatusDTO; label: string }> = [
+ { value: 'building', label: 'Building' },
+ { value: 'success', label: 'Success' },
+ { value: 'failed', label: 'Failed' },
+]
+
+export default function BuildsHeader() {
+ const { statuses, setStatuses, buildIdOrTemplate, setBuildIdOrTemplate } =
+ useFilters()
+
+ const [localBuildIdOrTemplate, setLocalBuildIdOrTemplate] = useState(
+ buildIdOrTemplate ?? ''
+ )
+
+ const [localStatuses, setLocalStatuses] = useState(statuses)
+
+ useEffect(() => {
+ setLocalBuildIdOrTemplate(buildIdOrTemplate ?? '')
+ }, [buildIdOrTemplate])
+
+ useEffect(() => {
+ setLocalStatuses(statuses)
+ }, [statuses])
+
+ const toggleStatus = (status: BuildStatusDTO) => {
+ const isSelected = localStatuses.includes(status)
+
+ if (isSelected && localStatuses.length === 1) {
+ return
+ }
+
+ const newStatuses = isSelected
+ ? localStatuses.filter((s) => s !== status)
+ : [...localStatuses, status]
+
+ setLocalStatuses(newStatuses)
+ setStatuses(newStatuses)
+ }
+
+ const selectAllStatuses = () => {
+ const allStatuses = STATUS_OPTIONS.map((s) => s.value)
+ setLocalStatuses(allStatuses)
+ setStatuses(allStatuses)
+ }
+
+ return (
+
+ {
+ setLocalBuildIdOrTemplate(e.target.value)
+ setBuildIdOrTemplate(e.target.value)
+ }}
+ />
+
+
+
+
+
+
+ e.preventDefault()}
+ >
+ All
+
+
+ {STATUS_OPTIONS.map((option) => (
+ toggleStatus(option.value)}
+ onSelect={(e) => e.preventDefault()}
+ >
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/features/dashboard/templates/builds/table-cells.tsx b/src/features/dashboard/templates/builds/table-cells.tsx
new file mode 100644
index 000000000..e6fa1a83a
--- /dev/null
+++ b/src/features/dashboard/templates/builds/table-cells.tsx
@@ -0,0 +1,242 @@
+'use client'
+
+import { PROTECTED_URLS } from '@/configs/urls'
+import { useTemplateTableStore } from '@/features/dashboard/templates/list/stores/table-store'
+import { useClipboard } from '@/lib/hooks/use-clipboard'
+import { cn } from '@/lib/utils'
+import {
+ formatDurationCompact,
+ formatTimeAgoCompact,
+} from '@/lib/utils/formatting'
+import type {
+ BuildStatusDTO,
+ ListedBuildDTO,
+} from '@/server/api/models/builds.models'
+import { Badge } from '@/ui/primitives/badge'
+import { Button } from '@/ui/primitives/button'
+import { CheckIcon, CloseIcon } from '@/ui/primitives/icons'
+import { Loader } from '@/ui/primitives/loader'
+import { ArrowUpRight } from 'lucide-react'
+import { useParams, useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+
+function CopyableCell({
+ value,
+ children,
+ className,
+}: {
+ value: string
+ children: React.ReactNode
+ className?: string
+}) {
+ const [wasCopied, copy] = useClipboard()
+
+ return (
+
+ )
+}
+
+export function BuildId({ id }: { id: string }) {
+ return (
+
+ {id}
+
+ )
+}
+
+export function Template({
+ name,
+ templateId,
+}: {
+ name: string
+ templateId: string
+}) {
+ const router = useRouter()
+ const { teamIdOrSlug } =
+ useParams<
+ Awaited['params']>
+ >()
+
+ return (
+
+ )
+}
+
+export function LoadMoreButton({
+ isLoading,
+ onLoadMore,
+}: {
+ isLoading: boolean
+ onLoadMore: () => void
+}) {
+ if (isLoading) {
+ return (
+
+
+ Loading...
+
+ )
+ }
+ return (
+
+ )
+}
+
+export function BackToTopButton({ onBackToTop }: { onBackToTop: () => void }) {
+ return (
+
+ )
+}
+
+export function Duration({
+ createdAt,
+ finishedAt,
+ isBuilding,
+}: {
+ createdAt: number
+ finishedAt: number | null
+ isBuilding: boolean
+}) {
+ const [now, setNow] = useState(() => Date.now())
+
+ useEffect(() => {
+ if (!isBuilding) return
+
+ const interval = setInterval(() => {
+ setNow(Date.now())
+ }, 1000)
+
+ return () => clearInterval(interval)
+ }, [isBuilding])
+
+ const duration = isBuilding
+ ? now - createdAt
+ : (finishedAt ?? now) - createdAt
+ const iso = finishedAt ? new Date(finishedAt).toISOString() : null
+
+ if (isBuilding || !iso) {
+ return (
+
+ {formatDurationCompact(duration)}
+
+ )
+ }
+
+ return (
+
+ {formatDurationCompact(duration)}
+
+ )
+}
+
+export function StartedAt({ timestamp }: { timestamp: number }) {
+ const iso = new Date(timestamp).toISOString()
+ const elapsed = Date.now() - timestamp
+
+ return (
+
+ {formatTimeAgoCompact(elapsed)}
+
+ )
+}
+
+interface StatusProps {
+ status: BuildStatusDTO
+}
+
+export function Status({ status }: StatusProps) {
+ const config: Record<
+ BuildStatusDTO,
+ {
+ label: string
+ variant: 'default' | 'positive' | 'error'
+ icon: React.ReactNode
+ }
+ > = {
+ building: {
+ label: 'Building',
+ variant: 'default',
+ icon: null,
+ },
+ success: {
+ label: 'Success',
+ variant: 'positive',
+ icon: ,
+ },
+ failed: {
+ label: 'Failed',
+ variant: 'error',
+ icon: ,
+ },
+ }
+
+ const { label, icon, variant } = config[status]
+
+ return (
+
+
+ {icon}
+ {label}
+
+
+ )
+}
+
+export function Reason({
+ statusMessage,
+}: {
+ statusMessage: ListedBuildDTO['statusMessage']
+}) {
+ if (!statusMessage) return null
+
+ return (
+
+ {statusMessage}
+
+ )
+}
diff --git a/src/features/dashboard/templates/builds/table.tsx b/src/features/dashboard/templates/builds/table.tsx
new file mode 100644
index 000000000..db1e9604d
--- /dev/null
+++ b/src/features/dashboard/templates/builds/table.tsx
@@ -0,0 +1,341 @@
+'use client'
+
+import type {
+ ListedBuildDTO,
+ RunningBuildStatusDTO,
+} from '@/server/api/models/builds.models'
+import { useTRPC } from '@/trpc/client'
+import { ArrowDownIcon } from '@/ui/primitives/icons'
+import { Loader } from '@/ui/primitives/loader'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/ui/primitives/table'
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query'
+import { useParams } from 'next/navigation'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+import BuildsEmpty from './empty'
+import {
+ BackToTopButton,
+ BuildId,
+ Duration,
+ LoadMoreButton,
+ Reason,
+ StartedAt,
+ Status,
+ Template,
+} from './table-cells'
+import useFilters from './use-filters'
+
+const BUILDS_REFETCH_INTERVAL_MS = 15_000
+const RUNNING_BUILD_POLL_INTERVAL_MS = 3_000
+const MAX_CACHED_PAGES = 3
+
+const COLUMN_WIDTHS = {
+ id: 132,
+ status: 96,
+ template: 192,
+ started: 156,
+ duration: 96,
+} as const
+
+const BuildsTable = () => {
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
+ const scrollContainerRef = useRef(null)
+
+ const { teamIdOrSlug } =
+ useParams<
+ Awaited['params']>
+ >()
+ const { statuses, buildIdOrTemplate } = useFilters()
+ const { isFilterRefetching, clearFilterRefetching } = useFilterChangeTracking(
+ statuses,
+ buildIdOrTemplate
+ )
+
+ // Builds list query
+ const {
+ data: paginatedBuilds,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isFetching: isFetchingBuilds,
+ isPending: isInitialLoad,
+ error: buildsError,
+ } = useInfiniteQuery(
+ trpc.builds.list.infiniteQueryOptions(
+ { teamIdOrSlug, statuses, buildIdOrTemplate },
+ {
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
+ placeholderData: keepPreviousData,
+ retry: 3,
+ refetchInterval: BUILDS_REFETCH_INTERVAL_MS,
+ refetchIntervalInBackground: false,
+ maxPages: MAX_CACHED_PAGES,
+ }
+ )
+ )
+
+ const builds = useMemo(
+ () => paginatedBuilds?.pages.flatMap((p) => p.data) ?? [],
+ [paginatedBuilds]
+ )
+
+ const hasScrolledPastInitialPages =
+ paginatedBuilds?.pageParams[0] !== undefined
+
+ useEffect(() => {
+ if (!isFetchingBuilds && isFilterRefetching) {
+ clearFilterRefetching()
+ }
+ }, [isFetchingBuilds, isFilterRefetching, clearFilterRefetching])
+
+ // Running builds status polling
+ const runningBuildIds = useMemo(
+ () => builds.filter((b) => b.status === 'building').map((b) => b.id),
+ [builds]
+ )
+
+ const { data: runningStatusesData } = useQuery(
+ trpc.builds.runningStatuses.queryOptions(
+ { teamIdOrSlug, buildIds: runningBuildIds },
+ {
+ enabled: runningBuildIds.length > 0,
+ refetchInterval: (query) => {
+ const hasRunningBuilds = query.state.data?.some(
+ (s) => s.status === 'building'
+ )
+ return hasRunningBuilds ? RUNNING_BUILD_POLL_INTERVAL_MS : false
+ },
+ refetchIntervalInBackground: false,
+ refetchOnWindowFocus: 'always',
+ retry: false,
+ }
+ )
+ )
+
+ const buildsWithLiveStatus = useMemo(
+ () => mergeBuildsWithLiveStatuses(builds, runningStatusesData),
+ [builds, runningStatusesData]
+ )
+
+ // Handlers
+ const buildsQueryKey = trpc.builds.list.infiniteQueryOptions({
+ teamIdOrSlug,
+ statuses,
+ buildIdOrTemplate,
+ }).queryKey
+
+ const handleLoadMore = useCallback(() => {
+ fetchNextPage()
+ }, [fetchNextPage])
+
+ const handleBackToTop = useCallback(() => {
+ queryClient.resetQueries({ queryKey: buildsQueryKey })
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTop = 0
+ }
+ }, [queryClient, buildsQueryKey])
+
+ // Derived UI state
+ const hasData = buildsWithLiveStatus.length > 0
+ const showLoader = isInitialLoad && !hasData
+ const showEmpty = !isInitialLoad && !isFetchingBuilds && !hasData
+ const showFilterRefetchingOverlay = isFilterRefetching && hasData
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Build ID
+ Status
+ Template
+
+
+ Started
+
+
+
+ Duration
+ |
+
+
+
+
+ {showLoader && (
+
+
+
+
+
+
+
+ )}
+
+ {showEmpty && (
+
+
+
+
+
+ )}
+
+ {hasData && (
+ <>
+ {hasScrolledPastInitialPages && (
+
+
+
+
+
+ )}
+
+ {buildsWithLiveStatus.map((build) => {
+ const isBuilding = build.status === 'building'
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ })}
+
+ {hasNextPage && (
+
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+ )
+}
+
+export default BuildsTable
+
+function colStyle(width: number) {
+ return { width, minWidth: width, maxWidth: width }
+}
+
+function useFilterChangeTracking(
+ statuses: string[],
+ buildIdOrTemplate: string | undefined
+) {
+ const [isFilterRefetching, setIsFilterRefetching] = useState(false)
+ const isFirstRender = useRef(true)
+
+ useEffect(() => {
+ if (isFirstRender.current) {
+ isFirstRender.current = false
+ return
+ }
+ setIsFilterRefetching(true)
+ }, [statuses, buildIdOrTemplate])
+
+ const clearFilterRefetching = useCallback(() => {
+ setIsFilterRefetching(false)
+ }, [])
+
+ return { isFilterRefetching, clearFilterRefetching }
+}
+
+function mergeBuildsWithLiveStatuses(
+ builds: ListedBuildDTO[],
+ runningStatusesData: RunningBuildStatusDTO[] | undefined
+): ListedBuildDTO[] {
+ if (!runningStatusesData || runningStatusesData.length === 0) return builds
+
+ const statusMap = new Map(runningStatusesData.map((s) => [s.id, s]))
+
+ return builds.map((build) => {
+ const updated = statusMap.get(build.id)
+ if (updated) {
+ return {
+ ...build,
+ status: updated.status,
+ finishedAt: updated.finishedAt,
+ statusMessage: updated.statusMessage,
+ }
+ }
+ return build
+ })
+}
diff --git a/src/features/dashboard/templates/builds/use-filters.tsx b/src/features/dashboard/templates/builds/use-filters.tsx
new file mode 100644
index 000000000..e7837a900
--- /dev/null
+++ b/src/features/dashboard/templates/builds/use-filters.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import { BuildStatusDTO } from '@/server/api/models/builds.models'
+import { useQueryStates } from 'nuqs'
+import { useMemo } from 'react'
+import { useDebounceCallback } from 'usehooks-ts'
+import { INITIAL_BUILD_STATUSES } from './constants'
+import { templateBuildsFilterParams } from './filter-params'
+
+export default function useFilters() {
+ const [filters, setFilters] = useQueryStates(templateBuildsFilterParams, {
+ shallow: true,
+ })
+
+ const statuses: BuildStatusDTO[] = useMemo(
+ () =>
+ (filters?.statuses as BuildStatusDTO[] | null) || INITIAL_BUILD_STATUSES,
+ [filters.statuses]
+ )
+
+ const buildIdOrTemplate = filters?.buildIdOrTemplate ?? undefined
+
+ const setStatuses = useDebounceCallback((statuses: BuildStatusDTO[]) => {
+ setFilters({ statuses: statuses })
+ }, 300)
+
+ const setBuildIdOrTemplate = useDebounceCallback(
+ (buildIdOrTemplate: string) => {
+ setFilters({ buildIdOrTemplate })
+ },
+ 300
+ )
+
+ return {
+ statuses,
+ buildIdOrTemplate,
+ setStatuses,
+ setBuildIdOrTemplate,
+ }
+}
diff --git a/src/features/dashboard/templates/header.tsx b/src/features/dashboard/templates/list/header.tsx
similarity index 100%
rename from src/features/dashboard/templates/header.tsx
rename to src/features/dashboard/templates/list/header.tsx
diff --git a/src/features/dashboard/templates/stores/table-store.ts b/src/features/dashboard/templates/list/stores/table-store.ts
similarity index 100%
rename from src/features/dashboard/templates/stores/table-store.ts
rename to src/features/dashboard/templates/list/stores/table-store.ts
diff --git a/src/features/dashboard/templates/table-body.tsx b/src/features/dashboard/templates/list/table-body.tsx
similarity index 100%
rename from src/features/dashboard/templates/table-body.tsx
rename to src/features/dashboard/templates/list/table-body.tsx
diff --git a/src/features/dashboard/templates/table-cells.tsx b/src/features/dashboard/templates/list/table-cells.tsx
similarity index 99%
rename from src/features/dashboard/templates/table-cells.tsx
rename to src/features/dashboard/templates/list/table-cells.tsx
index 31b3e0532..444922341 100644
--- a/src/features/dashboard/templates/table-cells.tsx
+++ b/src/features/dashboard/templates/list/table-cells.tsx
@@ -28,8 +28,8 @@ import { CellContext } from '@tanstack/react-table'
import { Lock, LockOpen, MoreVertical } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useMemo, useState } from 'react'
-import ResourceUsage from '../common/resource-usage'
-import { useDashboard } from '../context'
+import ResourceUsage from '../../common/resource-usage'
+import { useDashboard } from '../../context'
function E2BTemplateBadge() {
return (
diff --git a/src/features/dashboard/templates/table-config.tsx b/src/features/dashboard/templates/list/table-config.tsx
similarity index 100%
rename from src/features/dashboard/templates/table-config.tsx
rename to src/features/dashboard/templates/list/table-config.tsx
diff --git a/src/features/dashboard/templates/table-filters.tsx b/src/features/dashboard/templates/list/table-filters.tsx
similarity index 100%
rename from src/features/dashboard/templates/table-filters.tsx
rename to src/features/dashboard/templates/list/table-filters.tsx
diff --git a/src/features/dashboard/templates/table-search.tsx b/src/features/dashboard/templates/list/table-search.tsx
similarity index 100%
rename from src/features/dashboard/templates/table-search.tsx
rename to src/features/dashboard/templates/list/table-search.tsx
diff --git a/src/features/dashboard/templates/table.tsx b/src/features/dashboard/templates/list/table.tsx
similarity index 100%
rename from src/features/dashboard/templates/table.tsx
rename to src/features/dashboard/templates/list/table.tsx
diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts
index 1ed1dc918..8aec7b4bd 100644
--- a/src/lib/utils/formatting.ts
+++ b/src/lib/utils/formatting.ts
@@ -89,17 +89,21 @@ export function formatHour(timestamp: number): string {
const hour12 = hour % 12 || 12
if (isThisYear(timestamp)) {
- return new Intl.DateTimeFormat('en-US', {
+ return (
+ new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ }).format(timestamp) + `, ${hour12}${ampm}`
+ )
+ }
+
+ return (
+ new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
month: 'short',
day: 'numeric',
}).format(timestamp) + `, ${hour12}${ampm}`
- }
-
- return new Intl.DateTimeFormat('en-US', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- }).format(timestamp) + `, ${hour12}${ampm}`
+ )
}
/**
@@ -200,6 +204,47 @@ export function formatDuration(durationMs: number): string {
}
}
+export function formatDurationCompact(ms: number): string {
+ const seconds = Math.floor(ms / 1000)
+ const minutes = Math.floor(seconds / 60)
+ const hours = Math.floor(minutes / 60)
+
+ if (hours > 0) {
+ const remainingMinutes = minutes % 60
+ return `${hours}h ${remainingMinutes}m`
+ }
+ if (minutes > 0) {
+ const remainingSeconds = seconds % 60
+ return `${minutes}m ${remainingSeconds}s`
+ }
+ return `${seconds}s`
+}
+
+export function formatTimeAgoCompact(ms: number): string {
+ const minutes = Math.floor(ms / 1000 / 60)
+ const hours = Math.floor(minutes / 60)
+ const days = Math.floor(hours / 24)
+ const months = Math.floor(days / 30)
+
+ if (minutes < 1) {
+ return '< 1m ago'
+ }
+ if (hours < 1) {
+ return `${minutes}m ago`
+ }
+ if (days < 1) {
+ const remainingMinutes = minutes % 60
+ return `${hours}h ${remainingMinutes}m ago`
+ }
+ if (months < 1) {
+ const remainingHours = hours % 24
+ return `${days}d ${remainingHours}h ago`
+ }
+
+ const remainingDays = days % 30
+ return `${months}mo ${remainingDays}d ago`
+}
+
/**
* Format an averaging period text (e.g., "5 seconds average")
* @param stepMs - Step/period in milliseconds
diff --git a/src/lib/utils/ui.ts b/src/lib/utils/ui.ts
new file mode 100644
index 000000000..0150baaaf
--- /dev/null
+++ b/src/lib/utils/ui.ts
@@ -0,0 +1,13 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+/**
+ * Animation easing curves
+ * ⚠️ Must be kept in sync with theme.css utilities (anim-ease-appear, anim-ease-transform)
+ */
+export const EASE_APPEAR = [0.23, 1, 0.32, 1] as const; // ease-out-quint
+export const EASE_TRANSFORM = [0.79, 0.14, 0.15, 0.86] as const; // ease-in-out-circ
diff --git a/src/server/api/middlewares/telemetry.ts b/src/server/api/middlewares/telemetry.ts
index 99cfc58fc..572f2bebd 100644
--- a/src/server/api/middlewares/telemetry.ts
+++ b/src/server/api/middlewares/telemetry.ts
@@ -1,6 +1,7 @@
import { l } from '@/lib/clients/logger/logger'
import { getMeter } from '@/lib/clients/meter'
import { getTracer } from '@/lib/clients/tracer'
+import { flattenClientInputValue } from '@/lib/utils/action'
import type { Span } from '@opentelemetry/api'
import {
context,
@@ -167,14 +168,23 @@ export const endTelemetryMiddleware = t.middleware(
const duration = performance.now() - startTime
const durationMs = Math.round(duration * 1000) / 1000
- // collect context attributes that were added by downstream middlewares
- const contextAttrs: Record = {}
+ const contextAttrs: Record = {
+ template_id: flattenClientInputValue(rawInput, 'templateId'),
+ sandbox_id: flattenClientInputValue(rawInput, 'sandboxId'),
+ }
+
+ // set span attributes for input inferred parameters
+ for (const [k, v] of Object.entries(contextAttrs)) {
+ if (!v || typeof v !== 'string') continue
+ span.setAttribute(k, v)
+ }
+
+ // set span and context attributs for procedure ctx inferred parameters
if ('user' in ctx && ctx.user && (ctx.user as User).id) {
span.setAttribute('user_id', (ctx.user as User).id)
contextAttrs.user_id = (ctx.user as User).id
}
-
if ('teamId' in ctx && typeof ctx.teamId === 'string') {
span.setAttribute('team_id', ctx.teamId)
contextAttrs.team_id = ctx.teamId
diff --git a/src/server/api/models/builds.models.ts b/src/server/api/models/builds.models.ts
new file mode 100644
index 000000000..0cd3239c4
--- /dev/null
+++ b/src/server/api/models/builds.models.ts
@@ -0,0 +1,100 @@
+import z from 'zod'
+
+export const BuildStatusDTOSchema = z.enum(['building', 'failed', 'success'])
+
+export type BuildStatusDTO = z.infer
+export type BuildStatusDB = 'waiting' | 'building' | 'uploaded' | 'failed'
+
+export interface ListedBuildDTO {
+ id: string
+ template: string
+ status: BuildStatusDTO
+ statusMessage: string | null
+ createdAt: number
+ finishedAt: number | null
+}
+
+export interface RunningBuildStatusDTO {
+ id: string
+ status: BuildStatusDTO
+ finishedAt: number | null
+ statusMessage: string | null
+}
+
+// database queries
+
+type RawListedBuildWithEnvAndAliasesDB = {
+ id: string
+ env_id: string
+ status: string
+ reason: unknown
+ created_at: string
+ finished_at: string | null
+ envs: {
+ id: string
+ team_id: string
+ env_aliases: Array<{ alias: string }> | null
+ }
+}
+
+// mappings
+
+export function mapDatabaseBuildReasonToListedBuildDTOStatusMessage(
+ status: string,
+ reason: unknown
+): string | null {
+ if (status !== 'failed') return null
+ if (!reason || typeof reason !== 'object') return null
+ if (!('message' in reason)) return null
+ if (typeof reason.message !== 'string') return null
+ return reason.message
+}
+
+export function mapDatabaseBuildToListedBuildDTO(
+ build: RawListedBuildWithEnvAndAliasesDB
+): ListedBuildDTO {
+ const alias = build.envs.env_aliases?.[0]?.alias
+
+ return {
+ id: build.id,
+ template: alias ?? build.env_id,
+ status: mapDatabaseBuildStatusToBuildStatusDTO(
+ build.status as BuildStatusDB
+ ),
+ statusMessage: mapDatabaseBuildReasonToListedBuildDTOStatusMessage(
+ build.status,
+ build.reason
+ ),
+ createdAt: new Date(build.created_at).getTime(),
+ finishedAt: build.finished_at
+ ? new Date(build.finished_at).getTime()
+ : null,
+ }
+}
+
+export function mapBuildStatusDTOToDatabaseBuildStatus(
+ buildStatusDTO: BuildStatusDTO
+): BuildStatusDB[] {
+ switch (buildStatusDTO) {
+ case 'building':
+ return ['building', 'waiting']
+ case 'failed':
+ return ['failed']
+ case 'success':
+ return ['uploaded']
+ }
+}
+
+export function mapDatabaseBuildStatusToBuildStatusDTO(
+ dbStatus: BuildStatusDB
+): BuildStatusDTO {
+ switch (dbStatus) {
+ case 'waiting':
+ case 'building':
+ return 'building'
+ case 'uploaded':
+ return 'success'
+ case 'failed':
+ return 'failed'
+ }
+}
diff --git a/src/server/api/repositories/builds.repository.ts b/src/server/api/repositories/builds.repository.ts
new file mode 100644
index 000000000..cb0cb717f
--- /dev/null
+++ b/src/server/api/repositories/builds.repository.ts
@@ -0,0 +1,163 @@
+import { supabaseAdmin } from '@/lib/clients/supabase/admin'
+import z from 'zod'
+import {
+ ListedBuildDTO,
+ mapDatabaseBuildReasonToListedBuildDTOStatusMessage,
+ mapDatabaseBuildStatusToBuildStatusDTO,
+ mapDatabaseBuildToListedBuildDTO,
+ type BuildStatusDB,
+ type RunningBuildStatusDTO,
+} from '../models/builds.models'
+
+// helpers
+
+function isUUID(value: string): boolean {
+ return z.uuid().safeParse(value).success
+}
+
+export async function resolveTemplateId(
+ templateIdOrAlias: string,
+ teamId: string
+): Promise {
+ const { data: envById } = await supabaseAdmin
+ .from('envs')
+ .select('id')
+ .eq('id', templateIdOrAlias)
+ .eq('team_id', teamId)
+ .maybeSingle()
+
+ if (envById) return envById.id
+
+ const { data: envByAlias } = await supabaseAdmin
+ .from('env_aliases')
+ .select('env_id, envs!inner(team_id)')
+ .eq('alias', templateIdOrAlias)
+ .eq('envs.team_id', teamId)
+ .maybeSingle()
+
+ return envByAlias?.env_id ?? null
+}
+
+// list builds
+
+interface ListBuildsOptions {
+ limit?: number
+ cursor?: string
+}
+
+interface ListBuildsResult {
+ data: ListedBuildDTO[]
+ nextCursor: string | null
+}
+
+export async function listBuilds(
+ teamId: string,
+ buildIdOrTemplate?: string,
+ statuses: BuildStatusDB[] = ['waiting', 'building', 'uploaded', 'failed'],
+ options: ListBuildsOptions = {}
+): Promise {
+ const limit = options.limit ?? 50
+
+ let query = supabaseAdmin
+ .from('env_builds')
+ .select(
+ `
+ id,
+ env_id,
+ status,
+ reason,
+ created_at,
+ finished_at,
+ envs!inner(
+ id,
+ team_id,
+ env_aliases(alias)
+ )
+ `
+ )
+ .eq('envs.team_id', teamId)
+ .in('status', statuses)
+ .order('created_at', { ascending: false })
+
+ if (buildIdOrTemplate) {
+ const resolvedEnvId = await resolveTemplateId(buildIdOrTemplate, teamId)
+ const isBuildUUID = isUUID(buildIdOrTemplate)
+
+ if (!resolvedEnvId && !isBuildUUID) {
+ return {
+ data: [],
+ nextCursor: null,
+ }
+ }
+
+ if (resolvedEnvId && isBuildUUID) {
+ query = query.or(`env_id.eq.${resolvedEnvId},id.eq.${buildIdOrTemplate}`)
+ } else if (resolvedEnvId) {
+ query = query.eq('env_id', resolvedEnvId)
+ } else if (isBuildUUID) {
+ query = query.eq('id', buildIdOrTemplate)
+ }
+ }
+
+ if (options.cursor) {
+ query = query.lt('created_at', options.cursor)
+ }
+
+ query = query.limit(limit + 1)
+
+ const { data: rawBuilds, error } = await query
+
+ if (error) {
+ throw error
+ }
+
+ if (!rawBuilds || rawBuilds.length === 0) {
+ return {
+ data: [],
+ nextCursor: null,
+ }
+ }
+
+ const hasMore = rawBuilds.length > limit
+ const trimmedBuilds = hasMore ? rawBuilds.slice(0, limit) : rawBuilds
+
+ return {
+ data: trimmedBuilds.map(mapDatabaseBuildToListedBuildDTO),
+ nextCursor: hasMore
+ ? trimmedBuilds[trimmedBuilds.length - 1]!.created_at
+ : null,
+ }
+}
+
+// get running build statuses
+
+export async function getRunningStatuses(
+ teamId: string,
+ buildIds: string[]
+): Promise {
+ if (buildIds.length === 0) {
+ return []
+ }
+
+ const { data, error } = await supabaseAdmin
+ .from('env_builds')
+ .select('id, status, reason, finished_at, envs!inner(team_id)')
+ .eq('envs.team_id', teamId)
+ .in('id', buildIds)
+
+ if (error) throw error
+
+ return (data ?? []).map((build) => ({
+ id: build.id,
+ status: mapDatabaseBuildStatusToBuildStatusDTO(
+ build.status as BuildStatusDB
+ ),
+ finishedAt: build.finished_at
+ ? new Date(build.finished_at).getTime()
+ : null,
+ statusMessage: mapDatabaseBuildReasonToListedBuildDTOStatusMessage(
+ build.status,
+ build.reason
+ ),
+ }))
+}
diff --git a/src/server/api/routers/builds.ts b/src/server/api/routers/builds.ts
new file mode 100644
index 000000000..81831906c
--- /dev/null
+++ b/src/server/api/routers/builds.ts
@@ -0,0 +1,113 @@
+import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
+import { infra } from '@/lib/clients/api'
+import { l } from '@/lib/clients/logger/logger'
+import * as buildsRepo from '@/server/api/repositories/builds.repository'
+import { TRPCError } from '@trpc/server'
+import { z } from 'zod'
+import { apiError } from '../errors'
+import { createTRPCRouter } from '../init'
+import {
+ BuildStatusDTOSchema,
+ mapBuildStatusDTOToDatabaseBuildStatus,
+} from '../models/builds.models'
+import { protectedTeamProcedure } from '../procedures'
+
+export const buildsRouter = createTRPCRouter({
+ // QUERIES
+
+ list: protectedTeamProcedure
+ .input(
+ z.object({
+ buildIdOrTemplate: z.string().optional(),
+ statuses: z.array(BuildStatusDTOSchema),
+ limit: z.number().min(1).max(100).default(50),
+ cursor: z.string().optional(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { teamId } = ctx
+ const { buildIdOrTemplate, statuses, limit, cursor } = input
+
+ const dbStatuses = statuses.flatMap(
+ mapBuildStatusDTOToDatabaseBuildStatus
+ )
+
+ return await buildsRepo.listBuilds(
+ teamId,
+ buildIdOrTemplate,
+ dbStatuses,
+ { limit, cursor }
+ )
+ }),
+
+ runningStatuses: protectedTeamProcedure
+ .input(
+ z.object({
+ buildIds: z.array(z.string()).max(100),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { teamId } = ctx
+ const { buildIds } = input
+
+ return await buildsRepo.getRunningStatuses(teamId, buildIds)
+ }),
+
+ getBuildStatus: protectedTeamProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ buildId: z.string(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { session, teamId } = ctx
+ const { templateId, buildId } = input
+
+ const res = await infra.GET(
+ '/templates/{templateID}/builds/{buildID}/status',
+ {
+ params: {
+ path: {
+ templateID: templateId,
+ buildID: buildId,
+ },
+ },
+ headers: {
+ ...SUPABASE_AUTH_HEADERS(session.access_token),
+ },
+ }
+ )
+
+ if (!res.response.ok) {
+ const status = res.response.status
+
+ l.error(
+ {
+ key: 'trpc:builds:get_build_status:infra_error',
+ error: res.error,
+ user_id: session.user.id,
+ team_id: teamId,
+ template_id: templateId,
+ build_id: buildId,
+ context: {
+ status,
+ body: await res.response.text(),
+ },
+ },
+ `Failed to get build status: ${res.error?.message || 'Unknown error'}`
+ )
+
+ if (status === 404) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Build not found',
+ })
+ }
+
+ throw apiError(status)
+ }
+
+ return res.data
+ }),
+})
diff --git a/src/server/api/routers/index.ts b/src/server/api/routers/index.ts
index 3f5ea28e8..85602dca8 100644
--- a/src/server/api/routers/index.ts
+++ b/src/server/api/routers/index.ts
@@ -1,12 +1,12 @@
import { createCallerFactory, createTRPCRouter } from '../init'
+import { buildsRouter } from './builds'
import { sandboxesRouter } from './sandboxes'
-import { teamsRouter } from './teams'
import { templatesRouter } from './templates'
export const trpcAppRouter = createTRPCRouter({
sandboxes: sandboxesRouter,
- teams: teamsRouter,
templates: templatesRouter,
+ builds: buildsRouter,
})
export type TRPCAppRouter = typeof trpcAppRouter
diff --git a/src/server/api/routers/teams.ts b/src/server/api/routers/teams.ts
deleted file mode 100644
index 84b982cc3..000000000
--- a/src/server/api/routers/teams.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { l } from '@/lib/clients/logger/logger'
-import { supabaseAdmin } from '@/lib/clients/supabase/admin'
-import { createTRPCRouter } from '../init'
-import { protectedTeamProcedure } from '../procedures'
-
-export const teamsRouter = createTRPCRouter({
- getLimits: protectedTeamProcedure.query(async ({ ctx }) => {
- const { teamId, user } = ctx
-
- const { data: teamData, error: teamError } = await supabaseAdmin
- .from('team_limits')
- .select('*')
- .eq('id', teamId)
- .single()
-
- if (teamError) {
- throw teamError
- }
-
- if (!teamData) {
- l.error(
- {
- key: 'teams:get_limits:no_team_limits_found',
- team_id: teamId,
- user_id: user.id,
- },
- `no team_limits found for team: ${teamId}`
- )
-
- return null
- }
-
- return {
- concurrentInstances: teamData.concurrent_sandboxes || 0,
- diskMb: teamData.disk_mb || 0,
- maxLengthHours: teamData.max_length_hours || 0,
- maxRamMb: teamData.max_ram_mb || 0,
- maxVcpu: teamData.max_vcpu || 0,
- }
- }),
-})
diff --git a/src/server/auth/get-user-by-token.ts b/src/server/auth/get-user-by-token.ts
index a1cd91714..1fea14d58 100644
--- a/src/server/auth/get-user-by-token.ts
+++ b/src/server/auth/get-user-by-token.ts
@@ -16,6 +16,7 @@ import { cache } from 'react'
* @returns A promise that resolves to an object containing either the user data or an error
* @throws {AuthSessionMissingError} When no access token is provided
*/
+
function getUserByToken(accessToken: string | undefined) {
const trimmedAccessToken = accessToken?.trim()
diff --git a/src/ui/primitives/button.tsx b/src/ui/primitives/button.tsx
index 43c8009fd..72aec060b 100644
--- a/src/ui/primitives/button.tsx
+++ b/src/ui/primitives/button.tsx
@@ -59,7 +59,7 @@ const buttonVariants = cva(
},
size: {
default: 'h-8 px-3 gap-2',
- sm: 'h-7 px-2 gap-1',
+ sm: 'h-7 px-2 gap-1 text-xs',
lg: 'h-10 px-4 gap-2',
icon: 'h-8 w-8 gap-2',
iconSm: 'h-7 w-7 gap-1',
diff --git a/src/ui/primitives/checkbox.tsx b/src/ui/primitives/checkbox.tsx
index 0661ad7ca..f10c7b95c 100644
--- a/src/ui/primitives/checkbox.tsx
+++ b/src/ui/primitives/checkbox.tsx
@@ -1,10 +1,10 @@
'use client'
+import { CheckIcon } from '@/ui/primitives/icons'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
-import { CheckIcon } from 'lucide-react'
import * as React from 'react'
-import { cn } from '@/lib/utils'
+import { cn } from '@/lib/utils/ui'
function Checkbox({
className,
@@ -14,14 +14,37 @@ function Checkbox({
diff --git a/src/ui/primitives/dropdown-menu.tsx b/src/ui/primitives/dropdown-menu.tsx
index ffbef6e7e..7fe288e7c 100644
--- a/src/ui/primitives/dropdown-menu.tsx
+++ b/src/ui/primitives/dropdown-menu.tsx
@@ -5,7 +5,9 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
import { VariantProps } from 'class-variance-authority'
-import { Check, ChevronRight } from 'lucide-react'
+import { ChevronRight } from 'lucide-react'
+import { Checkbox } from './checkbox'
+import { CheckIcon } from './icons'
import {
menuContentStyles,
menuGroupStyles,
@@ -109,13 +111,15 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, checked, ...props }, ref) => (
+ React.ComponentPropsWithoutRef & {
+ checkIndicator?: ({ checked }: { checked: boolean }) => React.ReactNode
+ }
+>(({ className, children, checked, checkIndicator, ...props }, ref) => (
-
-
- [×]
-
-
+ {checkIndicator ? (
+ checkIndicator({
+ checked: checked === true,
+ })
+ ) : (
+
+ )}
{children}
))
@@ -154,7 +160,7 @@ const DropdownMenuRadioItem = React.forwardRef<
-
+
diff --git a/src/ui/primitives/label.tsx b/src/ui/primitives/label.tsx
index b914801d3..605918f80 100644
--- a/src/ui/primitives/label.tsx
+++ b/src/ui/primitives/label.tsx
@@ -11,7 +11,7 @@ const Label = React.forwardRef<
{
- variant?: 'slash' | 'square'
+ variant?: 'slash' | 'square' | 'dots'
size?: 'sm' | 'md' | 'lg' | 'xl'
}
@@ -46,6 +46,11 @@ const StyledLoader = styled.div`
content: '|';
animation: slashAnimation 0.4s linear infinite;
`
+ case 'dots':
+ return css`
+ content: '.';
+ animation: dotsAnimation 0.9s step-end infinite;
+ `
default:
return css`
content: '◰';
@@ -95,6 +100,17 @@ const Loader = React.forwardRef(
content: '◰';
}
}
+ @keyframes dotsAnimation {
+ 0% {
+ content: '.';
+ }
+ 33% {
+ content: '..';
+ }
+ 66% {
+ content: '...';
+ }
+ }
`}
{
+ throw new Error('fs/promises is not available in the browser')
+}
+
+export const lstat = notImplemented
+export const readdir = notImplemented
+export const readlink = notImplemented
+export const realpath = notImplemented
+export const readFile = notImplemented
+export const writeFile = notImplemented
+export const stat = notImplemented
+export const mkdir = notImplemented
+export const unlink = notImplemented
+export const rmdir = notImplemented
+export const access = notImplemented
+export const copyFile = notImplemented
+export const rename = notImplemented
+
+const fsPromises = {
+ lstat,
+ readdir,
+ readlink,
+ realpath,
+ readFile,
+ writeFile,
+ stat,
+ mkdir,
+ unlink,
+ rmdir,
+ access,
+ copyFile,
+ rename,
+}
+
+export default fsPromises
diff --git a/stubs/fs.ts b/stubs/fs.ts
new file mode 100644
index 000000000..1ef1dc515
--- /dev/null
+++ b/stubs/fs.ts
@@ -0,0 +1,54 @@
+const notImplemented = () => {
+ throw new Error('fs is not available in the browser')
+}
+
+export const lstatSync = notImplemented
+export const readdirSync = notImplemented
+export const readlinkSync = notImplemented
+export const realpathSync = Object.assign(notImplemented, {
+ native: notImplemented,
+})
+export const readdir = notImplemented
+export const writeFileSync = notImplemented
+export const readFileSync = notImplemented
+export const existsSync = notImplemented
+export const mkdirSync = notImplemented
+export const statSync = notImplemented
+export const unlinkSync = notImplemented
+export const rmdirSync = notImplemented
+export const createReadStream = notImplemented
+export const createWriteStream = notImplemented
+export const promises = {
+ lstat: notImplemented,
+ readdir: notImplemented,
+ readlink: notImplemented,
+ realpath: notImplemented,
+ readFile: notImplemented,
+ writeFile: notImplemented,
+ stat: notImplemented,
+ mkdir: notImplemented,
+ unlink: notImplemented,
+ rmdir: notImplemented,
+}
+export const writev = notImplemented
+
+const fs = {
+ lstatSync,
+ readdirSync,
+ readlinkSync,
+ realpathSync,
+ readdir,
+ writeFileSync,
+ readFileSync,
+ existsSync,
+ mkdirSync,
+ statSync,
+ unlinkSync,
+ rmdirSync,
+ createReadStream,
+ createWriteStream,
+ promises,
+ writev,
+}
+
+export default fs
diff --git a/tsconfig.json b/tsconfig.json
index b35e3228a..8175970da 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
- "lib": [
- "dom",
- "dom.iterable",
- "esnext"
- ],
+ "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -24,9 +20,7 @@
}
],
"paths": {
- "@/*": [
- "./src/*"
- ]
+ "@/*": ["./src/*"]
},
"isolatedModules": true
},
@@ -34,9 +28,8 @@
"next-env.d.ts",
"src",
".next/types/**/*.ts",
- ".next/dev/types/**/*.ts"
+ ".next/dev/types/**/*.ts",
+ "stubs"
],
- "exclude": [
- "node_modules"
- ]
+ "exclude": ["node_modules"]
}