feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.

This commit is contained in:
2025-12-11 20:04:52 -05:00
parent 5be4ff0372
commit d83c02759a
45 changed files with 4123 additions and 1455 deletions

View File

@@ -43,7 +43,8 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"lucide-react": "^0.536.0", "lucide-react": "^0.536.0",
"next": "^16.0.3", "minio": "^8.0.6",
"next": "^16.0.10",
"next-auth": "^5.0.0-beta.29", "next-auth": "^5.0.0-beta.29",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"react": "^19.0.0", "react": "^19.0.0",
@@ -339,25 +340,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/env@16.0.3", "", {}, "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ=="], "@next/env": ["@next/env@16.0.10", "", {}, "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.4.5", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-YhbrlbEt0m4jJnXHMY/cCUDBAWgd5SaTa5mJjzOt82QwflAFfW/h3+COp2TfVSzhmscIZ5sg2WXt3MLziqCSCw=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.4.5", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-YhbrlbEt0m4jJnXHMY/cCUDBAWgd5SaTa5mJjzOt82QwflAFfW/h3+COp2TfVSzhmscIZ5sg2WXt3MLziqCSCw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg=="], "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A=="], "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg=="], "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ=="], "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA=="], "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g=="], "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg=="], "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.10", "", { "os": "win32", "cpu": "x64" }, "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -697,6 +698,8 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -731,6 +734,8 @@
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@@ -747,14 +752,20 @@
"bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="], "bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="],
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
"bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="], "bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
@@ -813,6 +824,8 @@
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
@@ -895,6 +908,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"execa": ["execa@7.2.0", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.1", "human-signals": "^4.3.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^3.0.7", "strip-final-newline": "^3.0.0" } }, "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA=="], "execa": ["execa@7.2.0", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.1", "human-signals": "^4.3.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^3.0.7", "strip-final-newline": "^3.0.0" } }, "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -905,7 +920,7 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
@@ -917,6 +932,8 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
@@ -989,6 +1006,10 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
@@ -1109,6 +1130,8 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"log-symbols": ["log-symbols@5.1.0", "", { "dependencies": { "chalk": "^5.0.0", "is-unicode-supported": "^1.1.0" } }, "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA=="], "log-symbols": ["log-symbols@5.1.0", "", { "dependencies": { "chalk": "^5.0.0", "is-unicode-supported": "^1.1.0" } }, "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA=="],
@@ -1127,12 +1150,18 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
@@ -1147,7 +1176,7 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@16.0.3", "", { "dependencies": { "@next/env": "16.0.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.3", "@next/swc-darwin-x64": "16.0.3", "@next/swc-linux-arm64-gnu": "16.0.3", "@next/swc-linux-arm64-musl": "16.0.3", "@next/swc-linux-x64-gnu": "16.0.3", "@next/swc-linux-x64-musl": "16.0.3", "@next/swc-win32-arm64-msvc": "16.0.3", "@next/swc-win32-x64-msvc": "16.0.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w=="], "next": ["next@16.0.10", "", { "dependencies": { "@next/env": "16.0.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.10", "@next/swc-darwin-x64": "16.0.10", "@next/swc-linux-arm64-gnu": "16.0.10", "@next/swc-linux-arm64-musl": "16.0.10", "@next/swc-linux-x64-gnu": "16.0.10", "@next/swc-linux-x64-musl": "16.0.10", "@next/swc-win32-arm64-msvc": "16.0.10", "@next/swc-win32-x64-msvc": "16.0.10", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA=="],
"next-auth": ["next-auth@5.0.0-beta.29", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A=="], "next-auth": ["next-auth@5.0.0-beta.29", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A=="],
@@ -1221,6 +1250,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
@@ -1265,6 +1296,8 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@@ -1305,12 +1338,20 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"stdin-discarder": ["stdin-discarder@0.1.0", "", { "dependencies": { "bl": "^5.0.0" } }, "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ=="], "stdin-discarder": ["stdin-discarder@0.1.0", "", { "dependencies": { "bl": "^5.0.0" } }, "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
"stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="],
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
@@ -1333,7 +1374,7 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
@@ -1351,6 +1392,8 @@
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@@ -1395,12 +1438,16 @@
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
@@ -1417,6 +1464,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=="], "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=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1431,6 +1482,8 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-sdk/core/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1507,6 +1560,8 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-sdk/core/fast-xml-parser/strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],

View File

@@ -17,18 +17,18 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# minio: minio:
# image: minio/minio image: minio/minio
# ports: ports:
# - "9000:9000" # API - "9000:9000" # API
# - "9001:9001" # Console - "9001:9001" # Console
# environment: environment:
# MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: minioadmin
# MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: minioadmin
# volumes: volumes:
# - minio_data:/data - minio_data:/data
# command: server --console-address ":9001" /data command: server --console-address ":9001" /data
volumes: volumes:
postgres_data: postgres_data:
# minio_data: minio_data:

View File

@@ -62,7 +62,8 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"lucide-react": "^0.536.0", "lucide-react": "^0.536.0",
"next": "^16.0.3", "minio": "^8.0.6",
"next": "^16.0.10",
"next-auth": "^5.0.0-beta.29", "next-auth": "^5.0.0-beta.29",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"react": "^19.0.0", "react": "^19.0.0",

927
plugin_dump.json Normal file
View File

@@ -0,0 +1,927 @@
jsonb_pretty
---------------------------------------------------------------------------------------
[ +
{ +
"id": "walk_velocity", +
"icon": "navigation", +
"name": "Walk with Velocity", +
"ros2": { +
"qos": { +
"depth": 1, +
"history": "keep_last", +
"durability": "volatile", +
"reliability": "reliable" +
}, +
"topic": "/cmd_vel", +
"messageType": "geometry_msgs/msg/Twist", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "transformToTwist" +
} +
}, +
"timeout": 5000, +
"category": "movement", +
"retryable": true, +
"description": "Control robot walking with linear and angular velocities", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"linear", +
"angular" +
], +
"properties": { +
"linear": { +
"type": "number", +
"default": 0, +
"maximum": 0.55, +
"minimum": -0.55, +
"description": "Forward velocity in m/s" +
}, +
"angular": { +
"type": "number", +
"default": 0, +
"maximum": 2, +
"minimum": -2, +
"description": "Angular velocity in rad/s" +
} +
} +
} +
}, +
{ +
"id": "walk_forward", +
"icon": "arrow-up", +
"name": "Walk Forward", +
"ros2": { +
"topic": "/cmd_vel", +
"messageType": "geometry_msgs/msg/Twist", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"linear": { +
"x": "{{speed}}", +
"y": 0, +
"z": 0 +
}, +
"angular": { +
"x": 0, +
"y": 0, +
"z": 0 +
} +
} +
} +
}, +
"timeout": 30000, +
"category": "movement", +
"retryable": true, +
"description": "Make the robot walk forward at specified speed", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"speed" +
], +
"properties": { +
"speed": { +
"type": "number", +
"default": 0.1, +
"maximum": 0.3, +
"minimum": 0.01, +
"description": "Walking speed in m/s" +
}, +
"duration": { +
"type": "number", +
"default": 0, +
"maximum": 30, +
"minimum": 0, +
"description": "Duration to walk in seconds (0 = indefinite)" +
} +
} +
} +
}, +
{ +
"id": "walk_backward", +
"icon": "arrow-down", +
"name": "Walk Backward", +
"ros2": { +
"topic": "/cmd_vel", +
"messageType": "geometry_msgs/msg/Twist", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"linear": { +
"x": "-{{speed}}", +
"y": 0, +
"z": 0 +
}, +
"angular": { +
"x": 0, +
"y": 0, +
"z": 0 +
} +
} +
} +
}, +
"timeout": 30000, +
"category": "movement", +
"retryable": true, +
"description": "Make the robot walk backward at specified speed", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"speed" +
], +
"properties": { +
"speed": { +
"type": "number", +
"default": 0.1, +
"maximum": 0.3, +
"minimum": 0.01, +
"description": "Walking speed in m/s" +
}, +
"duration": { +
"type": "number", +
"default": 0, +
"maximum": 30, +
"minimum": 0, +
"description": "Duration to walk in seconds (0 = indefinite)" +
} +
} +
} +
}, +
{ +
"id": "turn_left", +
"icon": "rotate-ccw", +
"name": "Turn Left", +
"ros2": { +
"topic": "/cmd_vel", +
"messageType": "geometry_msgs/msg/Twist", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"linear": { +
"x": 0, +
"y": 0, +
"z": 0 +
}, +
"angular": { +
"x": 0, +
"y": 0, +
"z": "{{speed}}" +
} +
} +
} +
}, +
"timeout": 30000, +
"category": "movement", +
"retryable": true, +
"description": "Make the robot turn left at specified angular speed", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"speed" +
], +
"properties": { +
"speed": { +
"type": "number", +
"default": 0.3, +
"maximum": 1, +
"minimum": 0.1, +
"description": "Angular speed in rad/s" +
}, +
"duration": { +
"type": "number", +
"default": 0, +
"maximum": 30, +
"minimum": 0, +
"description": "Duration to turn in seconds (0 = indefinite)" +
} +
} +
} +
}, +
{ +
"id": "turn_right", +
"icon": "rotate-cw", +
"name": "Turn Right", +
"ros2": { +
"topic": "/cmd_vel", +
"messageType": "geometry_msgs/msg/Twist", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"linear": { +
"x": 0, +
"y": 0, +
"z": 0 +
}, +
"angular": { +
"x": 0, +
"y": 0, +
"z": "-{{speed}}" +
} +
} +
} +
}, +
"timeout": 30000, +
"category": "movement", +
"retryable": true, +
"description": "Make the robot turn right at specified angular speed", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"speed" +
], +
"properties": { +
"speed": { +
"type": "number", +
"default": 0.3, +
"maximum": 1, +
"minimum": 0.1, +
"description": "Angular speed in rad/s" +
}, +
"duration": { +
"type": "number", +
"default": 0, +
"maximum": 30, +
"minimum": 0, +
"description": "Duration to turn in seconds (0 = indefinite)" +
} +
} +
} +
}, +
{ +
"id": "stop_walking", +
"icon": "square", +
"name": "Stop Walking", +
"ros2": { +
"qos": { +
"depth": 1, +
"history": "keep_last", +
"durability": "volatile", +
"reliability": "reliable" +
}, +
"topic": "/cmd_vel", +
"messageType": "geometry_msgs/msg/Twist", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"linear": { +
"x": 0, +
"y": 0, +
"z": 0 +
}, +
"angular": { +
"x": 0, +
"y": 0, +
"z": 0 +
} +
} +
} +
}, +
"timeout": 3000, +
"category": "movement", +
"retryable": false, +
"description": "Immediately stop robot movement", +
"parameterSchema": { +
"type": "object", +
"required": [ +
], +
"properties": { +
} +
} +
}, +
{ +
"id": "say_text", +
"icon": "volume-2", +
"name": "Say Text", +
"ros2": { +
"qos": { +
"durability": "volatile", +
"reliability": "reliable" +
}, +
"topic": "/speech", +
"messageType": "std_msgs/msg/String", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "transformToStringMessage" +
} +
}, +
"timeout": 15000, +
"category": "interaction", +
"retryable": true, +
"description": "Make the robot speak using text-to-speech", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"text" +
], +
"properties": { +
"text": { +
"type": "string", +
"default": "Hello from NAO!", +
"description": "Text to speak" +
} +
} +
} +
}, +
{ +
"id": "say_with_emotion", +
"icon": "heart", +
"name": "Say Text with Emotion", +
"ros2": { +
"topic": "/speech", +
"messageType": "std_msgs/msg/String", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"data": "\\rspd={{speed}}\\\\rst={{emotion}}\\{{text}}" +
} +
} +
}, +
"timeout": 15000, +
"category": "interaction", +
"retryable": true, +
"description": "Speak text with emotional expression using SSML-like markup",+
"parameterSchema": { +
"type": "object", +
"required": [ +
"text" +
], +
"properties": { +
"text": { +
"type": "string", +
"default": "Hello! I'm feeling great today!", +
"description": "Text for the robot to speak" +
}, +
"speed": { +
"type": "number", +
"default": 1, +
"maximum": 2, +
"minimum": 0.5, +
"description": "Speech speed multiplier" +
}, +
"emotion": { +
"enum": [ +
"neutral", +
"happy", +
"sad", +
"excited", +
"calm" +
], +
"type": "string", +
"default": "neutral", +
"description": "Emotional tone for speech" +
} +
} +
} +
}, +
{ +
"id": "set_volume", +
"icon": "volume-x", +
"name": "Set Volume", +
"ros2": { +
"topic": "/audio_volume", +
"messageType": "std_msgs/msg/Float32", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"data": "{{volume}}" +
} +
} +
}, +
"timeout": 5000, +
"category": "interaction", +
"retryable": true, +
"description": "Adjust the robot's audio volume level", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"volume" +
], +
"properties": { +
"volume": { +
"type": "number", +
"default": 0.5, +
"maximum": 1, +
"minimum": 0, +
"description": "Volume level (0.0 = silent, 1.0 = maximum)" +
} +
} +
} +
}, +
{ +
"id": "set_language", +
"icon": "globe", +
"name": "Set Language", +
"ros2": { +
"topic": "/set_language", +
"messageType": "std_msgs/msg/String", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"data": "{{language}}" +
} +
} +
}, +
"timeout": 5000, +
"category": "interaction", +
"retryable": true, +
"description": "Change the robot's speech language", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"language" +
], +
"properties": { +
"language": { +
"enum": [ +
"en-US", +
"en-GB", +
"fr-FR", +
"de-DE", +
"es-ES", +
"it-IT", +
"ja-JP", +
"ko-KR", +
"zh-CN" +
], +
"type": "string", +
"default": "en-US", +
"description": "Speech language" +
} +
} +
} +
}, +
{ +
"id": "move_head", +
"icon": "eye", +
"name": "Move Head", +
"ros2": { +
"topic": "/joint_angles", +
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"speed": "{{speed}}", +
"joint_names": [ +
"HeadYaw", +
"HeadPitch" +
], +
"joint_angles": [ +
"{{yaw}}", +
"{{pitch}}" +
] +
} +
} +
}, +
"timeout": 10000, +
"category": "movement", +
"retryable": true, +
"description": "Control head orientation (yaw and pitch)", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"yaw", +
"pitch" +
], +
"properties": { +
"yaw": { +
"type": "number", +
"default": 0, +
"maximum": 2.09, +
"minimum": -2.09, +
"description": "Head yaw angle in radians" +
}, +
"pitch": { +
"type": "number", +
"default": 0, +
"maximum": 0.51, +
"minimum": -0.67, +
"description": "Head pitch angle in radians" +
}, +
"speed": { +
"type": "number", +
"default": 0.3, +
"maximum": 1, +
"minimum": 0.1, +
"description": "Movement speed (0.1 = slow, 1.0 = fast)" +
} +
} +
} +
}, +
{ +
"id": "move_arm", +
"icon": "hand", +
"name": "Move Arm", +
"ros2": { +
"topic": "/joint_angles", +
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", +
"payloadMapping": { +
"type": "static", +
"payload": { +
"speed": "{{speed}}", +
"joint_names": [ +
"{{arm === 'left' ? 'L' : 'R'}}ShoulderPitch", +
"{{arm === 'left' ? 'L' : 'R'}}ShoulderRoll", +
"{{arm === 'left' ? 'L' : 'R'}}ElbowYaw", +
"{{arm === 'left' ? 'L' : 'R'}}ElbowRoll" +
], +
"joint_angles": [ +
"{{shoulder_pitch}}", +
"{{shoulder_roll}}", +
"{{elbow_yaw}}", +
"{{elbow_roll}}" +
] +
} +
} +
}, +
"timeout": 10000, +
"category": "movement", +
"retryable": true, +
"description": "Control arm joint positions", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"arm", +
"shoulder_pitch", +
"shoulder_roll", +
"elbow_yaw", +
"elbow_roll" +
], +
"properties": { +
"arm": { +
"enum": [ +
"left", +
"right" +
], +
"type": "string", +
"default": "right", +
"description": "Which arm to control" +
}, +
"speed": { +
"type": "number", +
"default": 0.3, +
"maximum": 1, +
"minimum": 0.1, +
"description": "Movement speed (0.1 = slow, 1.0 = fast)" +
}, +
"elbow_yaw": { +
"type": "number", +
"default": 0, +
"maximum": 2.09, +
"minimum": -2.09, +
"description": "Elbow yaw angle in radians" +
}, +
"elbow_roll": { +
"type": "number", +
"default": -0.5, +
"maximum": -0.03, +
"minimum": -1.54, +
"description": "Elbow roll angle in radians" +
}, +
"shoulder_roll": { +
"type": "number", +
"default": 0.2, +
"maximum": 1.33, +
"minimum": -0.31, +
"description": "Shoulder roll angle in radians" +
}, +
"shoulder_pitch": { +
"type": "number", +
"default": 1.4, +
"maximum": 2.09, +
"minimum": -2.09, +
"description": "Shoulder pitch angle in radians" +
} +
} +
} +
}, +
{ +
"id": "set_joint_angle", +
"icon": "settings", +
"name": "Set Joint Angle", +
"ros2": { +
"topic": "/joint_angles", +
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "transformToJointAngles" +
} +
}, +
"timeout": 10000, +
"category": "movement", +
"retryable": true, +
"description": "Control individual joint angles", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"joint_name", +
"angle" +
], +
"properties": { +
"angle": { +
"type": "number", +
"default": 0, +
"maximum": 3.14159, +
"minimum": -3.14159, +
"description": "Target angle in radians" +
}, +
"speed": { +
"type": "number", +
"default": 0.2, +
"maximum": 1, +
"minimum": 0.01, +
"description": "Movement speed (fraction of max)" +
}, +
"joint_name": { +
"enum": [ +
"HeadYaw", +
"HeadPitch", +
"LShoulderPitch", +
"LShoulderRoll", +
"LElbowYaw", +
"LElbowRoll", +
"LWristYaw", +
"RShoulderPitch", +
"RShoulderRoll", +
"RElbowYaw", +
"RElbowRoll", +
"RWristYaw", +
"LHipYawPitch", +
"LHipRoll", +
"LHipPitch", +
"LKneePitch", +
"LAnklePitch", +
"LAnkleRoll", +
"RHipRoll", +
"RHipPitch", +
"RKneePitch", +
"RAnklePitch", +
"RAnkleRoll" +
], +
"type": "string", +
"default": "HeadYaw", +
"description": "Joint to control" +
} +
} +
} +
}, +
{ +
"id": "turn_head", +
"icon": "rotate-ccw", +
"name": "Turn Head", +
"ros2": { +
"topic": "/joint_angles", +
"messageType": "naoqi_bridge_msgs/msg/JointAnglesWithSpeed", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "transformToHeadMovement" +
} +
}, +
"timeout": 8000, +
"category": "movement", +
"retryable": true, +
"description": "Control head orientation", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"yaw", +
"pitch" +
], +
"properties": { +
"yaw": { +
"type": "number", +
"default": 0, +
"maximum": 2.0857, +
"minimum": -2.0857, +
"description": "Head yaw angle in radians (left-right)" +
}, +
"pitch": { +
"type": "number", +
"default": 0, +
"maximum": 0.5149, +
"minimum": -0.672, +
"description": "Head pitch angle in radians (up-down)" +
}, +
"speed": { +
"type": "number", +
"default": 0.3, +
"maximum": 1, +
"minimum": 0.1, +
"description": "Movement speed fraction" +
} +
} +
} +
}, +
{ +
"id": "get_camera_image", +
"icon": "camera", +
"name": "Get Camera Image", +
"ros2": { +
"qos": { +
"durability": "volatile", +
"reliability": "reliable" +
}, +
"topic": "/camera/{camera}/image_raw", +
"messageType": "sensor_msgs/msg/Image", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "getCameraImage" +
} +
}, +
"timeout": 5000, +
"category": "sensors", +
"retryable": true, +
"description": "Capture image from front or bottom camera", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"camera" +
], +
"properties": { +
"camera": { +
"enum": [ +
"front", +
"bottom" +
], +
"type": "string", +
"default": "front", +
"description": "Camera to use" +
} +
} +
} +
}, +
{ +
"id": "get_joint_states", +
"icon": "activity", +
"name": "Get Joint States", +
"ros2": { +
"qos": { +
"durability": "volatile", +
"reliability": "reliable" +
}, +
"topic": "/joint_states", +
"messageType": "sensor_msgs/msg/JointState", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "getJointStates" +
} +
}, +
"timeout": 3000, +
"category": "sensors", +
"retryable": true, +
"description": "Read current joint positions and velocities", +
"parameterSchema": { +
"type": "object", +
"required": [ +
], +
"properties": { +
} +
} +
}, +
{ +
"id": "get_imu_data", +
"icon": "compass", +
"name": "Get IMU Data", +
"ros2": { +
"qos": { +
"durability": "volatile", +
"reliability": "reliable" +
}, +
"topic": "/imu/torso", +
"messageType": "sensor_msgs/msg/Imu", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "getImuData" +
} +
}, +
"timeout": 3000, +
"category": "sensors", +
"retryable": true, +
"description": "Read inertial measurement unit data from torso", +
"parameterSchema": { +
"type": "object", +
"required": [ +
], +
"properties": { +
} +
} +
}, +
{ +
"id": "get_bumper_status", +
"icon": "zap", +
"name": "Get Bumper Status", +
"ros2": { +
"topic": "/bumper", +
"messageType": "naoqi_bridge_msgs/msg/Bumper", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "getBumperStatus" +
} +
}, +
"timeout": 3000, +
"category": "sensors", +
"retryable": true, +
"description": "Read foot bumper contact sensors", +
"parameterSchema": { +
"type": "object", +
"required": [ +
], +
"properties": { +
} +
} +
}, +
{ +
"id": "get_touch_sensors", +
"icon": "hand", +
"name": "Get Touch Sensors", +
"ros2": { +
"topic": "/{sensor_type}_touch", +
"messageType": "naoqi_bridge_msgs/msg/HandTouch", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "getTouchSensors" +
} +
}, +
"timeout": 3000, +
"category": "sensors", +
"retryable": true, +
"description": "Read hand and head touch sensor states", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"sensor_type" +
], +
"properties": { +
"sensor_type": { +
"enum": [ +
"hand", +
"head" +
], +
"type": "string", +
"default": "hand", +
"description": "Touch sensor type to read" +
} +
} +
} +
}, +
{ +
"id": "get_sonar_range", +
"icon": "radio", +
"name": "Get Sonar Range", +
"ros2": { +
"topic": "/sonar/{sensor}", +
"messageType": "sensor_msgs/msg/Range", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "getSonarRange" +
} +
}, +
"timeout": 3000, +
"category": "sensors", +
"retryable": true, +
"description": "Read ultrasonic range sensor data", +
"parameterSchema": { +
"type": "object", +
"required": [ +
"sensor" +
], +
"properties": { +
"sensor": { +
"enum": [ +
"left", +
"right", +
"both" +
], +
"type": "string", +
"default": "both", +
"description": "Sonar sensor to read" +
} +
} +
} +
}, +
{ +
"id": "get_robot_info", +
"icon": "info", +
"name": "Get Robot Info", +
"ros2": { +
"topic": "/info", +
"messageType": "naoqi_bridge_msgs/msg/RobotInfo", +
"payloadMapping": { +
"type": "transform", +
"transformFn": "getRobotInfo" +
} +
}, +
"timeout": 3000, +
"category": "sensors", +
"retryable": true, +
"description": "Read general robot information and status", +
"parameterSchema": { +
"type": "object", +
"required": [ +
], +
"properties": { +
} +
} +
} +
]
(1 row)

View File

@@ -1,64 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { AlertCircle, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function AnalyticsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study analytics
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/analytics`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
<AlertCircle className="h-8 w-8 text-blue-500" />
</div>
<CardTitle className="text-2xl">Analytics Moved</CardTitle>
<CardDescription>
Analytics are now organized by study for better data insights.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To view analytics, please:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s analytics page</li>
<li> Get study-specific insights and data</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,15 +0,0 @@
import { ExperimentForm } from "~/components/experiments/ExperimentForm";
interface EditExperimentPageProps {
params: Promise<{
id: string;
}>;
}
export default async function EditExperimentPage({
params,
}: EditExperimentPageProps) {
const { id } = await params;
return <ExperimentForm mode="edit" experimentId={id} />;
}

View File

@@ -1,459 +0,0 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { api } from "~/trpc/react";
import { useSession } from "next-auth/react";
interface ExperimentDetailPageProps {
params: Promise<{ id: string }>;
}
const statusConfig = {
draft: {
label: "Draft",
variant: "secondary" as const,
icon: "FileText" as const,
},
testing: {
label: "Testing",
variant: "outline" as const,
icon: "TestTube" as const,
},
ready: {
label: "Ready",
variant: "default" as const,
icon: "CheckCircle" as const,
},
deprecated: {
label: "Deprecated",
variant: "destructive" as const,
icon: "AlertTriangle" as const,
},
};
type Experiment = {
id: string;
name: string;
description: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
study: { id: string; name: string };
robot: { id: string; name: string; description: string | null } | null;
protocol?: { blocks: unknown[] } | null;
visualDesign?: unknown;
studyId: string;
createdBy: string;
robotId: string | null;
version: number;
};
type Trial = {
id: string;
status: string;
createdAt: Date;
duration: number | null;
participant: {
id: string;
participantCode: string;
name?: string | null;
} | null;
experiment: { name: string } | null;
participantId: string | null;
experimentId: string;
startedAt: Date | null;
completedAt: Date | null;
notes: string | null;
updatedAt: Date;
canAccess: boolean;
userRole: string;
};
export default function ExperimentDetailPage({
params,
}: ExperimentDetailPageProps) {
const { data: session } = useSession();
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [trials, setTrials] = useState<Trial[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
const experimentQuery = api.experiments.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const trialsQuery = api.trials.list.useQuery(
{ experimentId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
useEffect(() => {
if (experimentQuery.data) {
setExperiment(experimentQuery.data);
}
}, [experimentQuery.data]);
useEffect(() => {
if (trialsQuery.data) {
setTrials(trialsQuery.data);
}
}, [trialsQuery.data]);
useEffect(() => {
if (experimentQuery.isLoading || trialsQuery.isLoading) {
setLoading(true);
} else {
setLoading(false);
}
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment?.study?.name ?? "Unknown Study",
href: `/studies/${experiment?.study?.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment?.study?.id}/experiments`,
},
{
label: experiment?.name ?? "Experiment",
},
]);
if (loading) return <div>Loading...</div>;
if (experimentQuery.error) return notFound();
if (!experiment) return notFound();
const displayName = experiment.name ?? "Untitled Experiment";
const description = experiment.description;
// Check if user can edit this experiment
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
const canEdit =
userRoles.includes("administrator") || userRoles.includes("researcher");
const statusInfo =
statusConfig[experiment.status as keyof typeof statusConfig];
return (
<EntityView>
<EntityViewHeader
title={displayName}
subtitle={description ?? undefined}
icon="TestTube"
status={{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
icon: statusInfo?.icon ?? "TestTube",
}}
actions={
canEdit ? (
<>
<Button asChild variant="outline">
<Link href={`/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/experiments/${experiment.id}/designer`}>
<Settings className="mr-2 h-4 w-4" />
Designer
</Link>
</Button>
<Button asChild>
<Link
href={`/studies/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
</>
) : undefined
}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}/designer`}>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link href={`/experiments/${experiment.id}/designer`}>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
href: `/experiments/${experiment.id}/export`,
},
...(canEdit
? [
{
label: "Edit Experiment",
icon: "Edit" as const,
href: `/experiments/${experiment.id}/edit`,
},
{
label: "Open Designer",
icon: "Palette" as const,
href: `/experiments/${experiment.id}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
</div>
</EntityView>
);
}

View File

@@ -1,65 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FlaskConical, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function ExperimentsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study experiments
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/experiments`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
<FlaskConical className="h-8 w-8 text-blue-500" />
</div>
<CardTitle className="text-2xl">Experiments Moved</CardTitle>
<CardDescription>
Experiment management is now organized by study for better
workflow organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To manage experiments:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s experiments page</li>
<li> Create and manage experiment protocols for that specific study</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,65 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Users, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function ParticipantsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study participants
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/participants`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<Users className="h-8 w-8 text-green-500" />
</div>
<CardTitle className="text-2xl">Participants Moved</CardTitle>
<CardDescription>
Participant management is now organized by study for better
organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To manage participants:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s participants page</li>
<li> Add and manage participants for that specific study</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,67 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowRight, Store } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function PluginBrowseRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study plugin browse
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/plugins/browse`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-purple-50">
<Store className="h-8 w-8 text-purple-500" />
</div>
<CardTitle className="text-2xl">Plugin Store Moved</CardTitle>
<CardDescription>
Plugin browsing is now organized by study for better robot
capability management.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To browse and install plugins:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s plugin store</li>
<li>
Browse and install robot capabilities for that specific study
</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,68 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Puzzle, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function PluginsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study plugins
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/plugins`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-purple-50">
<Puzzle className="h-8 w-8 text-purple-500" />
</div>
<CardTitle className="text-2xl">Plugins Moved</CardTitle>
<CardDescription>
Plugin management is now organized by study for better robot
capability management.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To manage plugins:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s plugins page</li>
<li>
Install and configure robot capabilities for that specific
study
</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { useRouter } from "next/navigation";
import { type Experiment } from "~/lib/experiments/types";
const formSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
description: z.string().optional(),
status: z.enum([
"draft",
"ready",
"data_collection",
"analysis",
"completed",
"archived",
]),
});
interface ExperimentFormProps {
experiment: Experiment;
}
export function ExperimentForm({ experiment }: ExperimentFormProps) {
const router = useRouter();
const updateExperiment = api.experiments.update.useMutation({
onSuccess: () => {
toast.success("Experiment updated successfully");
router.refresh();
router.push(`/studies/${experiment.studyId}/experiments/${experiment.id}`);
},
onError: (error) => {
toast.error(`Error updating experiment: ${error.message}`);
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status,
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
updateExperiment.mutate({
id: experiment.id,
name: values.name,
description: values.description,
status: values.status,
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
The name of your experiment.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment..."
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
A short description of the experiment goals.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="ready">Ready</SelectItem>
<SelectItem value="data_collection">Data Collection</SelectItem>
<SelectItem value="analysis">Analysis</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The current status of the experiment.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<Button type="submit" disabled={updateExperiment.isPending}>
{updateExperiment.isPending ? "Saving..." : "Save Changes"}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
router.push(
`/studies/${experiment.studyId}/experiments/${experiment.id}`,
)
}
>
Cancel
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,63 @@
import { notFound } from "next/navigation";
import { type Experiment } from "~/lib/experiments/types";
import { api } from "~/trpc/server";
import { ExperimentForm } from "./experiment-form";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
} from "~/components/ui/entity-view";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
interface ExperimentEditPageProps {
params: Promise<{ id: string; experimentId: string }>;
}
export default async function ExperimentEditPage({
params,
}: ExperimentEditPageProps) {
const { id: studyId, experimentId } = await params;
const experiment = await api.experiments.get({ id: experimentId });
if (!experiment) {
notFound();
}
// Ensure experiment belongs to study
if (experiment.studyId !== studyId) {
notFound();
}
// Convert to type expected by form
const experimentData: Experiment = {
...experiment,
status: experiment.status as Experiment["status"],
};
return (
<EntityView>
<EntityViewHeader
title="Edit Experiment"
subtitle={`Update settings for ${experiment.name}`}
icon="Edit"
backButton={
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-2">
<Link href={`/studies/${studyId}/experiments/${experimentId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Experiment
</Link>
</Button>
}
/>
<div className="max-w-2xl">
<EntityViewSection title="Experiment Details" icon="Settings">
<ExperimentForm experiment={experimentData} />
</EntityViewSection>
</div>
</EntityView>
);
}

View File

@@ -0,0 +1,468 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { api } from "~/trpc/react";
import { useSession } from "next-auth/react";
import { useStudyManagement } from "~/hooks/useStudyManagement";
interface ExperimentDetailPageProps {
params: Promise<{ id: string; experimentId: string }>;
}
const statusConfig = {
draft: {
label: "Draft",
variant: "secondary" as const,
icon: "FileText" as const,
},
testing: {
label: "Testing",
variant: "outline" as const,
icon: "TestTube" as const,
},
ready: {
label: "Ready",
variant: "default" as const,
icon: "CheckCircle" as const,
},
deprecated: {
label: "Deprecated",
variant: "destructive" as const,
icon: "AlertTriangle" as const,
},
};
type Experiment = {
id: string;
name: string;
description: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
study: { id: string; name: string };
robot: { id: string; name: string; description: string | null } | null;
protocol?: { blocks: unknown[] } | null;
visualDesign?: unknown;
studyId: string;
createdBy: string;
robotId: string | null;
version: number;
};
type Trial = {
id: string;
status: string;
createdAt: Date;
duration: number | null;
participant: {
id: string;
participantCode: string;
name?: string | null;
} | null;
experiment: { name: string } | null;
participantId: string | null;
experimentId: string;
startedAt: Date | null;
completedAt: Date | null;
notes: string | null;
updatedAt: Date;
canAccess: boolean;
userRole: string;
};
export default function ExperimentDetailPage({
params,
}: ExperimentDetailPageProps) {
const { data: session } = useSession();
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [trials, setTrials] = useState<Trial[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string; experimentId: string } | null>(
null,
);
const { selectStudy } = useStudyManagement();
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
// Ensure study context is synced
if (resolved.id) {
void selectStudy(resolved.id);
}
};
void resolveParams();
}, [params, selectStudy]);
const experimentQuery = api.experiments.get.useQuery(
{ id: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId },
);
const trialsQuery = api.trials.list.useQuery(
{ experimentId: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId },
);
useEffect(() => {
if (experimentQuery.data) {
setExperiment(experimentQuery.data);
}
}, [experimentQuery.data]);
useEffect(() => {
if (trialsQuery.data) {
setTrials(trialsQuery.data);
}
}, [trialsQuery.data]);
useEffect(() => {
if (experimentQuery.isLoading || trialsQuery.isLoading) {
setLoading(true);
} else {
setLoading(false);
}
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${experiment?.study?.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment?.study?.id}/experiments`,
},
{
label: experiment?.name ?? "Experiment",
},
]);
if (loading) return <div>Loading...</div>;
if (experimentQuery.error) return notFound();
if (!experiment) return notFound();
const displayName = experiment.name ?? "Untitled Experiment";
const description = experiment.description;
// Check if user can edit this experiment
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
const canEdit =
userRoles.includes("administrator") || userRoles.includes("researcher");
const statusInfo =
statusConfig[experiment.status as keyof typeof statusConfig];
const studyId = experiment.study.id;
const experimentId = experiment.id;
return (
<EntityView>
<EntityViewHeader
title={displayName}
subtitle={description ?? undefined}
icon="TestTube"
status={{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
icon: statusInfo?.icon ?? "TestTube",
}}
actions={
canEdit ? (
<>
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/experiments/${experimentId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Settings className="mr-2 h-4 w-4" />
Designer
</Link>
</Button>
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
</>
) : undefined
}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
href: `/studies/${studyId}/experiments/${experimentId}/export`,
},
...(canEdit
? [
{
label: "Edit Experiment",
icon: "Edit" as const,
href: `/studies/${studyId}/experiments/${experimentId}/edit`,
},
{
label: "Open Designer",
icon: "Palette" as const,
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
</div>
</EntityView>
);
}

View File

@@ -0,0 +1,31 @@
import { ParticipantForm } from "~/components/participants/ParticipantForm";
import { api } from "~/trpc/server";
import { notFound } from "next/navigation";
interface EditParticipantPageProps {
params: Promise<{
id: string;
participantId: string;
}>;
}
export default async function EditParticipantPage({
params,
}: EditParticipantPageProps) {
const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId });
if (!participant || participant.studyId !== studyId) {
notFound();
}
// Transform data to match form expectations if needed, or pass directly
return (
<ParticipantForm
mode="edit"
studyId={studyId}
participantId={participantId}
/>
);
}

View File

@@ -0,0 +1,109 @@
import { notFound } from "next/navigation";
import { api } from "~/trpc/server";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
} from "~/components/ui/entity-view";
import { ParticipantDocuments } from "./participant-documents";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { Edit } from "lucide-react";
import Link from "next/link";
interface ParticipantDetailPageProps {
params: Promise<{ id: string; participantId: string }>;
}
export default async function ParticipantDetailPage({
params,
}: ParticipantDetailPageProps) {
const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId });
if (!participant) {
notFound();
}
// Ensure participant belongs to study
if (participant.studyId !== studyId) {
notFound();
}
return (
<EntityView>
<EntityViewHeader
title={participant.participantCode}
subtitle={participant.name ?? "Unnamed Participant"}
icon="Users"
badge={
<Badge variant={participant.consentGiven ? "default" : "secondary"}>
{participant.consentGiven ? "Consent Given" : "No Consent"}
</Badge>
}
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Participant
</Link>
</Button>
}
/>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="files">Files & Documents</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid gap-6 grid-cols-1">
<EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<span className="text-muted-foreground block mb-1">Code</span>
<span className="font-medium text-base">{participant.participantCode}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Name</span>
<span className="font-medium text-base">{participant.name || "-"}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Email</span>
<span className="font-medium text-base">{participant.email || "-"}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Added</span>
<span className="font-medium text-base">{new Date(participant.createdAt).toLocaleDateString()}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Age</span>
<span className="font-medium text-base">{(participant.demographics as any)?.age || "-"}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Gender</span>
<span className="font-medium capitalize text-base">{(participant.demographics as any)?.gender?.replace("_", " ") || "-"}</span>
</div>
</div>
</EntityViewSection>
</div>
</TabsContent>
<TabsContent value="files">
<EntityViewSection title="Documents" icon="FileText">
<ParticipantDocuments participantId={participantId} />
</EntityViewSection>
</TabsContent>
</Tabs>
</EntityView>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { api } from "~/trpc/react";
import { formatBytes } from "~/lib/utils";
import { toast } from "sonner";
interface ParticipantDocumentsProps {
participantId: string;
}
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) {
const [isUploading, setIsUploading] = useState(false);
const utils = api.useUtils();
const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({
participantId,
});
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
const registerUpload = api.files.registerUpload.useMutation();
const deleteDocument = api.files.deleteDocument.useMutation({
onSuccess: () => {
toast.success("Document deleted");
utils.files.listParticipantDocuments.invalidate({ participantId });
},
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
});
// Since presigned URLs are for PUT, we can use a direct fetch
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
// 1. Get presigned URL
const { url, storagePath } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type || "application/octet-stream",
participantId,
});
// 2. Upload to MinIO/S3
const uploadRes = await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type || "application/octet-stream",
},
});
if (!uploadRes.ok) {
throw new Error("Upload to storage failed");
}
// 3. Register in DB
await registerUpload.mutateAsync({
participantId,
name: file.name,
type: file.type,
storagePath,
fileSize: file.size,
});
toast.success("File uploaded successfully");
utils.files.listParticipantDocuments.invalidate({ participantId });
} catch (error) {
console.error(error);
toast.error("Failed to upload file");
} finally {
setIsUploading(false);
// Reset input
e.target.value = "";
}
};
const handleDownload = async (storagePath: string, filename: string) => {
// We would typically get a temporary download URL here
// For now assuming public bucket or implementing a separate download procedure
// Let's implement a quick procedure call right here via client or assume the server router has it.
// I added getDownloadUrl to the router in previous steps.
try {
const { url } = await utils.client.files.getDownloadUrl.query({ storagePath });
window.open(url, "_blank");
} catch (e) {
toast.error("Could not get download URL");
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle>Documents</CardTitle>
<CardDescription>
Manage consent forms and other files for this participant.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button disabled={isUploading} asChild>
<label className="cursor-pointer">
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload PDF
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
onChange={handleFileUpload}
disabled={isUploading}
/>
</label>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : documents?.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8 opacity-50" />
<p>No documents uploaded yet.</p>
</div>
) : (
<div className="space-y-2">
{documents?.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-50 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="font-medium">{doc.name}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(doc.fileSize ?? 0)} {new Date(doc.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(doc.storagePath, doc.name)}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm("Are you sure you want to delete this file?")) {
deleteDocument.mutate({ id: doc.id });
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import Link from "next/link";
import { LineChart, ArrowLeft } from "lucide-react";
import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
import { api } from "~/trpc/react";
function AnalysisPageContent() {
const params = useParams();
const studyId: string = typeof params.id === "string" ? params.id : "";
const trialId: string =
typeof params.trialId === "string" ? params.trialId : "";
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails();
// Get trial data
const {
data: trial,
isLoading,
error,
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Trials", href: `/studies/${studyId}/trials` },
{
label: trial?.experiment.name ?? "Trial",
href: `/studies/${studyId}/trials`,
},
{ label: "Analysis" },
]);
// Sync selected study (unified study-context)
useEffect(() => {
if (studyId && selectedStudyId !== studyId) {
setSelectedStudyId(studyId);
}
}, [studyId, selectedStudyId, setSelectedStudyId]);
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading analysis...</div>
</div>
);
}
if (error || !trial) {
return (
<div className="space-y-6">
<PageHeader
title="Trial Analysis"
description="Analyze trial results"
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trials
</Link>
</Button>
}
/>
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h3 className="text-destructive mb-2 text-lg font-semibold">
{error ? "Error Loading Trial" : "Trial Not Found"}
</h3>
<p className="text-muted-foreground">
{error?.message || "The requested trial could not be found."}
</p>
</div>
</div>
</div>
);
}
const trialData = {
...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
eventCount: (trial as any).eventCount,
mediaCount: (trial as any).mediaCount,
};
return (
<div className="flex h-full flex-col">
<PageHeader
title="Trial Analysis"
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials/${trialId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial Details
</Link>
</Button>
}
/>
<div className="min-h-0 flex-1">
<TrialAnalysisView trial={trialData} />
</div>
</div>
);
}
export default function TrialAnalysisPage() {
return (
<Suspense
fallback={
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
}
>
<AnalysisPageContent />
</Suspense>
);
}

View File

@@ -233,6 +233,22 @@ export function AppSidebar({
// Show debug info in development // Show debug info in development
const showDebug = process.env.NODE_ENV === "development"; const showDebug = process.env.NODE_ENV === "development";
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Sidebar collapsible="icon" variant="sidebar" {...props}>
<SidebarHeader />
<SidebarContent />
<SidebarFooter />
</Sidebar>
);
}
return ( return (
<Sidebar collapsible="icon" variant="sidebar" {...props}> <Sidebar collapsible="icon" variant="sidebar" {...props}>
<SidebarHeader> <SidebarHeader>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@@ -259,20 +259,26 @@ export const columns: ColumnDef<Experiment>[] = [
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigator.clipboard.writeText(experiment.id)} onClick={() => navigator.clipboard.writeText(experiment.id)}
> >
Copy experiment ID <Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View details</Link> <Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}> <Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
Edit experiment <Edit className="mr-2 h-4 w-4" />
Edit
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}> <Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
Open designer <LayoutTemplate className="mr-2 h-4 w-4" />
Designer
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -280,12 +286,14 @@ export const columns: ColumnDef<Experiment>[] = [
<Link <Link
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`} href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
> >
Create trial <PlayCircle className="mr-2 h-4 w-4" />
Start Trial
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600"> <DropdownMenuItem className="text-red-600">
Archive experiment <Archive className="mr-2 h-4 w-4" />
Archive
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -736,6 +736,16 @@ export function DesignerRoot({
const targetStep = steps.find((s) => s.id === stepId); const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return; if (!targetStep) return;
const fullDef = actionRegistry.getAction(actionDef.type);
const defaultParams: Record<string, unknown> = {};
if (fullDef?.parameters) {
for (const param of fullDef.parameters) {
if (param.default !== undefined) {
defaultParams[param.id] = param.default;
}
}
}
const execution: ExperimentAction["execution"] = const execution: ExperimentAction["execution"] =
actionDef.execution && actionDef.execution &&
(actionDef.execution.transport === "internal" || (actionDef.execution.transport === "internal" ||
@@ -754,7 +764,7 @@ export function DesignerRoot({
type: actionDef.type, type: actionDef.type,
name: actionDef.name, name: actionDef.name,
category: actionDef.category as ExperimentAction["category"], category: actionDef.category as ExperimentAction["category"],
parameters: {}, parameters: defaultParams,
source: actionDef.source as ExperimentAction["source"], source: actionDef.source as ExperimentAction["source"],
execution, execution,
}; };
@@ -818,6 +828,7 @@ export function DesignerRoot({
description={designMeta.description || "No description"} description={designMeta.description || "No description"}
icon={Play} icon={Play}
actions={actions} actions={actions}
className="pb-6"
/> />
<div className="relative flex flex-1 flex-col overflow-hidden"> <div className="relative flex flex-1 flex-col overflow-hidden">

View File

@@ -70,7 +70,7 @@ export interface PropertiesPanelProps {
className?: string; className?: string;
} }
export function PropertiesPanel({ export function PropertiesPanelBase({
design, design,
selectedStep, selectedStep,
selectedAction, selectedAction,
@@ -633,3 +633,5 @@ export function PropertiesPanel({
</div> </div>
); );
} }
export const PropertiesPanel = React.memo(PropertiesPanelBase);

View File

@@ -284,14 +284,17 @@ export function InspectorPanel({
<div className="flex-1 overflow-x-hidden overflow-y-auto"> <div className="flex-1 overflow-x-hidden overflow-y-auto">
<div className="w-full px-0 py-2 break-words whitespace-normal"> <div className="w-full px-0 py-2 break-words whitespace-normal">
<PropertiesPanel <PropertiesPanel
design={{ design={useMemo(
() => ({
id: "design", id: "design",
name: "Design", name: "Design",
description: "", description: "",
version: 1, version: 1,
steps, steps,
lastSaved: new Date(), lastSaved: new Date(),
}} }),
[steps],
)}
selectedStep={selectedStep} selectedStep={selectedStep}
selectedAction={selectedAction} selectedAction={selectedAction}
onActionUpdate={handleActionUpdate} onActionUpdate={handleActionUpdate}

View File

@@ -645,7 +645,7 @@ export function validateExecution(
issues.push({ issues.push({
severity: "warning", severity: "warning",
message: message:
"Multiple steps with trial_start trigger may cause execution conflicts", "Multiple steps will start simultaneously. Ensure parallel execution is intended.",
category: "execution", category: "execution",
field: "trigger.type", field: "trigger.type",
stepId: step.id, stepId: step.id,

View File

@@ -203,7 +203,7 @@ export function ParticipantForm({
email: data.email ?? undefined, email: data.email ?? undefined,
demographics, demographics,
}); });
router.push(`/participants/${newParticipant.id}`); router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`);
} else { } else {
const updatedParticipant = await updateParticipantMutation.mutateAsync({ const updatedParticipant = await updateParticipantMutation.mutateAsync({
id: participantId!, id: participantId!,
@@ -212,7 +212,7 @@ export function ParticipantForm({
email: data.email ?? undefined, email: data.email ?? undefined,
demographics, demographics,
}); });
router.push(`/participants/${updatedParticipant.id}`); router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`);
} }
} catch (error) { } catch (error) {
setError( setError(
@@ -505,7 +505,8 @@ export function ParticipantForm({
error={error} error={error}
onDelete={mode === "edit" ? onDelete : undefined} onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting} isDeleting={isDeleting}
sidebar={sidebar} isDeleting={isDeleting}
// sidebar={sidebar} // Removed for cleaner UI per user request
submitText={mode === "create" ? "Register Participant" : "Save Changes"} submitText={mode === "create" ? "Register Participant" : "Save Changes"}
> >
{formFields} {formFields}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
@@ -27,6 +27,7 @@ import { api } from "~/trpc/react";
export type Participant = { export type Participant = {
id: string; id: string;
studyId: string;
participantCode: string; participantCode: string;
email: string | null; email: string | null;
name: string | null; name: string | null;
@@ -75,7 +76,7 @@ export const columns: ColumnDef<Participant>[] = [
cell: ({ row }) => ( cell: ({ row }) => (
<div className="font-mono text-sm"> <div className="font-mono text-sm">
<Link <Link
href={`/participants/${row.original.id}`} href={`/studies/${row.original.studyId ?? ""}/participants/${row.original.id}`}
className="hover:underline" className="hover:underline"
> >
{row.getValue("participantCode")} {row.getValue("participantCode")}
@@ -176,6 +177,13 @@ export const columns: ColumnDef<Participant>[] = [
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => {
const participant = row.original; const participant = row.original;
// Use studyId from participant or fallback might be needed but for now presume row has it?
// Wait, the Participant type definition above doesn't have studyId!
// I need to add studyId to the type definition in this file or rely on context if I'm inside the component,
// but 'columns' is defined outside.
// Best practice: Add studyId to the Participant type.
const studyId = participant.studyId;
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -190,26 +198,27 @@ export const columns: ColumnDef<Participant>[] = [
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigator.clipboard.writeText(participant.id)} onClick={() => navigator.clipboard.writeText(participant.id)}
> >
Copy participant ID <Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/participants/${participant.id}`}>View details</Link> <Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
</DropdownMenuItem> <Edit className="mr-2 h-4 w-4" />
<DropdownMenuItem asChild>
<Link href={`/participants/${participant.id}/edit`}>
Edit participant Edit participant
</Link> </Link >
</DropdownMenuItem >
<DropdownMenuItem disabled>
<Mail className="mr-2 h-4 w-4" />
Send consent
</DropdownMenuItem> </DropdownMenuItem>
{!participant.consentGiven && (
<DropdownMenuItem>Send consent form</DropdownMenuItem>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600"> <DropdownMenuItem className="text-red-600">
Remove participant <Trash2 className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent >
</DropdownMenu> </DropdownMenu >
); );
}, },
}, },
@@ -250,6 +259,7 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
return participantsData.participants.map( return participantsData.participants.map(
(p): Participant => ({ (p): Participant => ({
id: p.id, id: p.id,
studyId: p.studyId,
participantCode: p.participantCode, participantCode: p.participantCode,
email: p.email, email: p.email,
name: p.name, name: p.name,

View File

@@ -161,7 +161,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
sessionNumber: data.sessionNumber ?? 1, sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined, notes: data.notes ?? undefined,
}); });
router.push(`/trials/${newTrial!.id}`); router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
} else { } else {
const updatedTrial = await updateTrialMutation.mutateAsync({ const updatedTrial = await updateTrialMutation.mutateAsync({
id: trialId!, id: trialId!,
@@ -170,7 +170,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
sessionNumber: data.sessionNumber ?? 1, sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined, notes: data.notes ?? undefined,
}); });
router.push(`/trials/${updatedTrial!.id}`); router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
} }
} catch (error) { } catch (error) {
setError( setError(

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react"; import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
@@ -51,27 +51,22 @@ const statusConfig = {
scheduled: { scheduled: {
label: "Scheduled", label: "Scheduled",
className: "bg-blue-100 text-blue-800", className: "bg-blue-100 text-blue-800",
icon: "📅",
}, },
in_progress: { in_progress: {
label: "In Progress", label: "In Progress",
className: "bg-yellow-100 text-yellow-800", className: "bg-yellow-100 text-yellow-800",
icon: "▶️",
}, },
completed: { completed: {
label: "Completed", label: "Completed",
className: "bg-green-100 text-green-800", className: "bg-green-100 text-green-800",
icon: "✅",
}, },
aborted: { aborted: {
label: "Aborted", label: "Aborted",
className: "bg-gray-100 text-gray-800", className: "bg-gray-100 text-gray-800",
icon: "❌",
}, },
failed: { failed: {
label: "Failed", label: "Failed",
className: "bg-red-100 text-red-800", className: "bg-red-100 text-red-800",
icon: "⚠️",
}, },
}; };
@@ -145,7 +140,7 @@ export const columns: ColumnDef<Trial>[] = [
<div className="max-w-[250px]"> <div className="max-w-[250px]">
<div className="truncate font-medium"> <div className="truncate font-medium">
<Link <Link
href={`/experiments/${experimentId}`} href={`/studies/${row.original.studyId}/experiments/${experimentId}`}
className="hover:underline" className="hover:underline"
> >
{String(experimentName)} {String(experimentName)}
@@ -175,7 +170,7 @@ export const columns: ColumnDef<Trial>[] = [
<div className="max-w-[150px]"> <div className="max-w-[150px]">
{participantId ? ( {participantId ? (
<Link <Link
href={`/participants/${participantId}`} href={`/studies/${row.original.studyId}/participants/${participantId}`}
className="font-mono text-sm hover:underline" className="font-mono text-sm hover:underline"
> >
{(participantCode ?? "Unknown") as string} {(participantCode ?? "Unknown") as string}
@@ -232,10 +227,10 @@ export const columns: ColumnDef<Trial>[] = [
return ( return (
<Badge className={statusInfo.className}> <Badge className={statusInfo.className}>
<span className="mr-1">{statusInfo.icon}</span>
{statusInfo.label} {statusInfo.label}
</Badge> </Badge>
); );
}, },
}, },
{ {
@@ -366,13 +361,28 @@ export const columns: ColumnDef<Trial>[] = [
{ {
id: "actions", id: "actions",
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => <ActionsCell row={row} />,
},
];
function ActionsCell({ row }: { row: { original: Trial } }) {
const trial = row.original; const trial = row.original;
const router = React.useMemo(() => require("next/navigation").useRouter(), []); // Dynamic import to avoid hook rules in static context? No, this component is rendered in Table.
// Actually, hooks must be at top level. This ActionsCell will be a regular component.
// But useRouter might fail if columns is not in component tree?
// Table cells are rendered by flexRender in React, so they are components.
// importing useRouter is fine.
const utils = api.useUtils();
const duplicateMutation = api.trials.duplicate.useMutation({
onSuccess: () => {
utils.trials.list.invalidate();
// toast.success("Trial duplicated"); // We need toast
},
});
if (!trial?.id) { if (!trial?.id) {
return ( return <span className="text-muted-foreground text-sm">No actions</span>;
<span className="text-muted-foreground text-sm">No actions</span>
);
} }
return ( return (
@@ -388,56 +398,56 @@ export const columns: ColumnDef<Trial>[] = [
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigator.clipboard.writeText(trial.id)} onClick={() => navigator.clipboard.writeText(trial.id)}
> >
Copy trial ID <Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}> <Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
View details <Eye className="mr-2 h-4 w-4" />
Details
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{trial.status === "scheduled" && ( {trial.status === "scheduled" && (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`} <Play className="mr-2 h-4 w-4" />
> Start Trial
Start trial
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{trial.status === "in_progress" && ( {trial.status === "in_progress" && (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`} <Gamepad2 className="mr-2 h-4 w-4" />
> Control Trial
Control trial
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{trial.status === "completed" && ( {trial.status === "completed" && (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}> <Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
View analysis <LineChart className="mr-2 h-4 w-4" />
Analysis
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}> <Copy className="mr-2 h-4 w-4" />
Edit trial Duplicate
</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
{(trial.status === "scheduled" || trial.status === "failed") && ( {(trial.status === "scheduled" || trial.status === "failed") && (
<DropdownMenuItem className="text-red-600"> <DropdownMenuItem className="text-red-600">
Cancel trial <Ban className="mr-2 h-4 w-4" />
Cancel
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
}, }
},
];
interface TrialsTableProps { interface TrialsTableProps {
studyId?: string; studyId?: string;

View File

@@ -0,0 +1,203 @@
"use client";
import React, { useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent } from "~/components/ui/card";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
interface TimelineEvent {
type: string;
timestamp: Date;
message?: string;
data?: unknown;
}
interface HorizontalTimelineProps {
events: TimelineEvent[];
startTime?: Date;
endTime?: Date;
}
export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTimelineProps) {
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
if (events.length === 0) {
return (
<div className="text-center text-sm text-muted-foreground py-8">
No events recorded yet
</div>
);
}
// Calculate time range
const timestamps = events.map(e => e.timestamp.getTime());
const minTime = startTime?.getTime() ?? Math.min(...timestamps);
const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
const duration = maxTime - minTime;
// Generate time markers (every 10 seconds or appropriate interval)
const getTimeMarkers = () => {
const markers: Date[] = [];
const interval = duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
for (let time = minTime; time <= maxTime; time += interval) {
markers.push(new Date(time));
}
if (markers[markers.length - 1]?.getTime() !== maxTime) {
markers.push(new Date(maxTime));
}
return markers;
};
const timeMarkers = getTimeMarkers();
// Get position percentage for a timestamp
const getPosition = (timestamp: Date) => {
if (duration === 0) return 50;
return ((timestamp.getTime() - minTime) / duration) * 100;
};
// Get color and icon for event type
const getEventStyle = (eventType: string) => {
if (eventType.includes("start") || eventType === "trial_started") {
return { color: "bg-blue-500", Icon: Flag };
} else if (eventType.includes("complete") || eventType === "trial_completed") {
return { color: "bg-green-500", Icon: CheckCircle };
} else if (eventType.includes("robot") || eventType.includes("action")) {
return { color: "bg-purple-500", Icon: Bot };
} else if (eventType.includes("wizard") || eventType.includes("intervention")) {
return { color: "bg-orange-500", Icon: User };
} else if (eventType.includes("note") || eventType.includes("annotation")) {
return { color: "bg-yellow-500", Icon: MessageSquare };
} else if (eventType.includes("error") || eventType.includes("issue")) {
return { color: "bg-red-500", Icon: AlertTriangle };
}
return { color: "bg-gray-500", Icon: Activity };
};
return (
<div className="space-y-4">
{/* Timeline visualization */}
<div className="relative">
<ScrollArea className="w-full">
<div className="min-w-[800px] px-4 py-8">
{/* Time markers */}
<div className="relative h-20 mb-8">
{/* Main horizontal line */}
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-border" style={{ transform: 'translateY(-50%)' }} />
{/* Time labels */}
{timeMarkers.map((marker, i) => {
const pos = getPosition(marker);
return (
<div
key={i}
className="absolute"
style={{ left: `${pos}%`, top: '50%', transform: 'translate(-50%, -50%)' }}
>
<div className="flex flex-col items-center">
<div className="text-[10px] font-mono text-muted-foreground mb-2">
{marker.toLocaleTimeString([], {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</div>
<div className="w-px h-4 bg-border" />
</div>
</div>
);
})}
</div>
{/* Event markers */}
<div className="relative h-40">
{/* Timeline line for events */}
<div className="absolute top-20 left-0 right-0 h-0.5 bg-border" />
{events.map((event, i) => {
const pos = getPosition(event.timestamp);
const { color, Icon } = getEventStyle(event.type);
const isSelected = selectedEvent === event;
return (
<div
key={i}
className="absolute"
style={{
left: `${pos}%`,
top: '50%',
transform: 'translate(-50%, -50%)'
}}
>
{/* Clickable marker group */}
<button
onClick={() => setSelectedEvent(isSelected ? null : event)}
className="flex flex-col items-center gap-1 cursor-pointer group"
title={event.message || event.type}
>
{/* Vertical dash */}
<div className={`
w-1 h-20 ${color} rounded-full
group-hover:w-1.5 transition-all
${isSelected ? 'w-1.5 ring-2 ring-offset-2 ring-offset-background ring-primary' : ''}
`} />
{/* Icon indicator */}
<div className={`
p-1.5 rounded-full ${color} bg-opacity-20
group-hover:bg-opacity-30 transition-all
${isSelected ? 'ring-2 ring-primary bg-opacity-40' : ''}
`}>
<Icon className={`h-3.5 w-3.5 ${color.replace('bg-', 'text-')}`} />
</div>
</button>
</div>
);
})}
</div>
</div>
</ScrollArea>
</div>
{/* Selected event details */}
{selectedEvent && (
<Card>
<CardContent className="pt-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{selectedEvent.type.replace(/_/g, " ")}
</Badge>
<span className="text-xs font-mono text-muted-foreground">
{selectedEvent.timestamp.toLocaleTimeString([], {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
})}
</span>
</div>
{selectedEvent.message && (
<p className="text-sm">{selectedEvent.message}</p>
)}
{selectedEvent.data !== undefined && selectedEvent.data !== null && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Event data
</summary>
<pre className="mt-2 p-2 bg-muted rounded text-[10px] overflow-auto">
{JSON.stringify(selectedEvent.data, null, 2)}
</pre>
</details>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { LineChart, BarChart, Clock, Database, FileText } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
interface TrialAnalysisViewProps {
trial: {
id: string;
status: string;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
experiment: { name: string };
participant: { participantCode: string };
eventCount?: number;
mediaCount?: number;
};
}
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
return (
<div className="container mx-auto p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Status</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold capitalize">{trial.status.replace("_", " ")}</div>
<p className="text-xs text-muted-foreground">
{trial.completedAt
? `Completed ${formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}`
: "Not completed"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Duration</CardTitle>
<BarChart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.duration ? `${Math.floor(trial.duration / 60)}m ${trial.duration % 60}s` : "N/A"}
</div>
<p className="text-xs text-muted-foreground">
Total execution time
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Events Logged</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{trial.eventCount ?? 0}</div>
<p className="text-xs text-muted-foreground">
System & user events
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Media Files</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{trial.mediaCount ?? 0}</div>
<p className="text-xs text-muted-foreground">
Recordings & snapshots
</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="events">Event Log</TabsTrigger>
<TabsTrigger value="charts">Charts</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Analysis Overview</CardTitle>
<CardDescription>
Summary of trial execution for {trial.participant.participantCode} in experiment {trial.experiment.name}.
</CardDescription>
</CardHeader>
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
<div className="text-center text-muted-foreground">
<LineChart className="h-10 w-10 mx-auto mb-2 opacity-20" />
<p>Detailed analysis visualizations coming soon.</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="events">
<Card>
<CardHeader>
<CardTitle>Event Log</CardTitle>
<CardDescription>
Chronological record of all trial events.
</CardDescription>
</CardHeader>
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
<div className="text-center text-muted-foreground">
<p>Event log view placeholder.</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react"; import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress"; import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -9,6 +10,12 @@ import { PanelsContainer } from "~/components/experiments/designer/layout/Panels
import { WizardControlPanel } from "./panels/WizardControlPanel"; import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel"; import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel"; import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { WizardObservationPane } from "./panels/WizardObservationPane";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos"; import { useWizardRos } from "~/hooks/useWizardRos";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -42,6 +49,16 @@ interface WizardInterfaceProps {
userRole: string; userRole: string;
} }
interface ActionData {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}
interface StepData { interface StepData {
id: string; id: string;
name: string; name: string;
@@ -53,6 +70,7 @@ interface StepData {
| "conditional_branch"; | "conditional_branch";
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
order: number; order: number;
actions: ActionData[];
} }
export const WizardInterface = React.memo(function WizardInterface({ export const WizardInterface = React.memo(function WizardInterface({
@@ -65,6 +83,7 @@ export const WizardInterface = React.memo(function WizardInterface({
initialTrial.startedAt ? new Date(initialTrial.startedAt) : null, initialTrial.startedAt ? new Date(initialTrial.startedAt) : null,
); );
const [elapsedTime, setElapsedTime] = useState(0); const [elapsedTime, setElapsedTime] = useState(0);
const router = useRouter();
// Persistent tab states to prevent resets from parent re-renders // Persistent tab states to prevent resets from parent re-renders
const [controlPanelTab, setControlPanelTab] = useState< const [controlPanelTab, setControlPanelTab] = useState<
@@ -73,9 +92,16 @@ export const WizardInterface = React.memo(function WizardInterface({
const [executionPanelTab, setExecutionPanelTab] = useState< const [executionPanelTab, setExecutionPanelTab] = useState<
"current" | "timeline" | "events" "current" | "timeline" | "events"
>(trial.status === "in_progress" ? "current" : "timeline"); >(trial.status === "in_progress" ? "current" : "timeline");
const [isExecutingAction, setIsExecutingAction] = useState(false);
const [monitoringPanelTab, setMonitoringPanelTab] = useState< const [monitoringPanelTab, setMonitoringPanelTab] = useState<
"status" | "robot" | "events" "status" | "robot" | "events"
>("status"); >("status");
const [completedActionsCount, setCompletedActionsCount] = useState(0);
// Reset completed actions when step changes
useEffect(() => {
setCompletedActionsCount(0);
}, [currentStepIndex]);
// Get experiment steps from API // Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery( const { data: experimentSteps } = api.experiments.getSteps.useQuery(
@@ -100,6 +126,13 @@ export const WizardInterface = React.memo(function WizardInterface({
}, },
}); });
// Log robot action mutation (for client-side execution)
const logRobotActionMutation = api.trials.logRobotAction.useMutation({
onError: (error) => {
console.error("Failed to log robot action:", error);
},
});
// Map database step types to component step types // Map database step types to component step types
const mapStepType = (dbType: string) => { const mapStepType = (dbType: string) => {
switch (dbType) { switch (dbType) {
@@ -136,6 +169,7 @@ export const WizardInterface = React.memo(function WizardInterface({
connect: connectRos, connect: connectRos,
disconnect: disconnectRos, disconnect: disconnectRos,
executeRobotAction: executeRosAction, executeRobotAction: executeRosAction,
setAutonomousLife,
} = useWizardRos({ } = useWizardRos({
autoConnect: true, autoConnect: true,
onActionCompleted, onActionCompleted,
@@ -152,6 +186,15 @@ export const WizardInterface = React.memo(function WizardInterface({
}, },
); );
// Poll for trial events
const { data: fetchedEvents } = api.trials.getEvents.useQuery(
{ trialId: trial.id, limit: 100 },
{
refetchInterval: 3000,
staleTime: 1000,
}
);
// Update local trial state from polling // Update local trial state from polling
useEffect(() => { useEffect(() => {
if (pollingData) { if (pollingData) {
@@ -168,7 +211,15 @@ export const WizardInterface = React.memo(function WizardInterface({
} }
}, [pollingData]); }, [pollingData]);
// Auto-start trial on mount if scheduled
useEffect(() => {
if (trial.status === "scheduled") {
handleStartTrial();
}
}, []); // Run once on mount
// Trial events from robot actions // Trial events from robot actions
const trialEvents = useMemo< const trialEvents = useMemo<
Array<{ Array<{
type: string; type: string;
@@ -176,7 +227,38 @@ export const WizardInterface = React.memo(function WizardInterface({
data?: unknown; data?: unknown;
message?: string; message?: string;
}> }>
>(() => [], []); >(() => {
return (fetchedEvents ?? []).map(event => {
let message: string | undefined;
const eventData = event.data as any;
// Extract or generate message based on event type
if (event.eventType.startsWith('annotation_')) {
message = eventData?.description || eventData?.label || 'Annotation added';
} else if (event.eventType.startsWith('robot_action_')) {
const actionName = event.eventType.replace('robot_action_', '').replace(/_/g, ' ');
message = `Robot action: ${actionName}`;
} else if (event.eventType === 'trial_started') {
message = 'Trial started';
} else if (event.eventType === 'trial_completed') {
message = 'Trial completed';
} else if (event.eventType === 'step_changed') {
message = `Step changed to: ${eventData?.stepName || 'next step'}`;
} else if (event.eventType.startsWith('wizard_')) {
message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' ');
} else {
// Generic fallback
message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' ');
}
return {
type: event.eventType,
timestamp: new Date(event.timestamp),
data: event.data,
message,
};
}).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
}, [fetchedEvents]);
// Transform experiment steps to component format // Transform experiment steps to component format
const steps: StepData[] = const steps: StepData[] =
@@ -187,6 +269,15 @@ export const WizardInterface = React.memo(function WizardInterface({
type: mapStepType(step.type), type: mapStepType(step.type),
parameters: step.parameters ?? {}, parameters: step.parameters ?? {},
order: step.order ?? index, order: step.order ?? index,
actions: step.actions?.map((action) => ({
id: action.id,
name: action.name,
description: action.description,
type: action.type,
parameters: action.parameters ?? {},
order: action.order,
pluginId: action.pluginId,
})) ?? [],
})) ?? []; })) ?? [];
const currentStep = steps[currentStepIndex] ?? null; const currentStep = steps[currentStepIndex] ?? null;
@@ -261,6 +352,8 @@ export const WizardInterface = React.memo(function WizardInterface({
status: data.status, status: data.status,
completedAt: data.completedAt, completedAt: data.completedAt,
}); });
toast.success("Trial completed! Redirecting to analysis...");
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
} }
}, },
}); });
@@ -314,6 +407,7 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleNextStep = () => { const handleNextStep = () => {
if (currentStepIndex < steps.length - 1) { if (currentStepIndex < steps.length - 1) {
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
setCurrentStepIndex(currentStepIndex + 1); setCurrentStepIndex(currentStepIndex + 1);
// Note: Step transitions can be enhanced later with database logging // Note: Step transitions can be enhanced later with database logging
} }
@@ -335,15 +429,72 @@ export const WizardInterface = React.memo(function WizardInterface({
} }
}; };
// Mutations for annotations
const addAnnotationMutation = api.trials.addAnnotation.useMutation({
onSuccess: () => {
toast.success("Note added");
},
onError: (error) => {
toast.error("Failed to add note", { description: error.message });
},
});
const handleAddAnnotation = async (
description: string,
category?: string,
tags?: string[],
) => {
await addAnnotationMutation.mutateAsync({
trialId: trial.id,
description,
category,
tags,
});
};
// Mutation for events (Acknowledge)
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => toast.success("Event logged"),
});
// Mutation for interventions
const addInterventionMutation = api.trials.addIntervention.useMutation({
onSuccess: () => toast.success("Intervention logged"),
});
const handleExecuteAction = async ( const handleExecuteAction = async (
actionId: string, actionId: string,
parameters?: Record<string, unknown>, parameters?: Record<string, unknown>,
) => { ) => {
try { try {
console.log("Executing action:", actionId, parameters); console.log("Executing action:", actionId, parameters);
if (actionId === "acknowledge") {
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "wizard_acknowledge",
data: parameters,
});
handleNextStep();
} else if (actionId === "intervene") {
await addInterventionMutation.mutateAsync({
trialId: trial.id,
type: "manual_intervention",
description: "Wizard manual intervention triggered",
data: parameters,
});
} else if (actionId === "note") {
await addAnnotationMutation.mutateAsync({
trialId: trial.id,
description: String(parameters?.content || "Quick note"),
category: String(parameters?.category || "quick_note")
});
}
// Note: Action execution can be enhanced later with tRPC mutations // Note: Action execution can be enhanced later with tRPC mutations
} catch (error) { } catch (error) {
console.error("Failed to execute action:", error); console.error("Failed to execute action:", error);
toast.error("Failed to execute action");
} }
}; };
@@ -352,22 +503,34 @@ export const WizardInterface = React.memo(function WizardInterface({
pluginName: string, pluginName: string,
actionId: string, actionId: string,
parameters: Record<string, unknown>, parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => { ) => {
try { try {
setIsExecutingAction(true);
// Try direct WebSocket execution first for better performance // Try direct WebSocket execution first for better performance
if (rosConnected) { if (rosConnected) {
try { try {
await executeRosAction(pluginName, actionId, parameters); const result = await executeRosAction(pluginName, actionId, parameters);
const duration =
result.endTime && result.startTime
? result.endTime.getTime() - result.startTime.getTime()
: 0;
// Log to trial events for data capture // Log to trial events for data capture
await executeRobotActionMutation.mutateAsync({ await logRobotActionMutation.mutateAsync({
trialId: trial.id, trialId: trial.id,
pluginName, pluginName,
actionId, actionId,
parameters, parameters,
duration,
result: { status: result.status },
}); });
toast.success(`Robot action executed: ${actionId}`); toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} catch (rosError) { } catch (rosError) {
console.warn( console.warn(
"WebSocket execution failed, falling back to tRPC:", "WebSocket execution failed, falling back to tRPC:",
@@ -383,6 +546,9 @@ export const WizardInterface = React.memo(function WizardInterface({
}); });
toast.success(`Robot action executed via fallback: ${actionId}`); toast.success(`Robot action executed via fallback: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} }
} else { } else {
// Use tRPC execution if WebSocket not connected // Use tRPC execution if WebSocket not connected
@@ -394,17 +560,51 @@ export const WizardInterface = React.memo(function WizardInterface({
}); });
toast.success(`Robot action executed: ${actionId}`); toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} }
} catch (error) { } catch (error) {
console.error("Failed to execute robot action:", error); console.error("Failed to execute robot action:", error);
toast.error(`Failed to execute robot action: ${actionId}`, { toast.error(`Failed to execute robot action: ${actionId}`, {
description: error instanceof Error ? error.message : "Unknown error", description: error instanceof Error ? error.message : "Unknown error",
}); });
} finally {
setIsExecutingAction(false);
} }
}, },
[rosConnected, executeRosAction, executeRobotActionMutation, trial.id], [rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
); );
const handleSkipAction = useCallback(
async (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => {
try {
await logRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
duration: 0,
result: { skipped: true },
});
toast.info(`Action skipped: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} catch (error) {
console.error("Failed to skip action:", error);
toast.error("Failed to skip action");
}
},
[logRobotActionMutation, trial.id],
);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Compact Status Bar */} {/* Compact Status Bar */}
@@ -451,10 +651,10 @@ export const WizardInterface = React.memo(function WizardInterface({
</div> </div>
</div> </div>
{/* No connection status alert - ROS connection shown in monitoring panel */} {/* Main Content with Vertical Resizable Split */}
{/* Main Content - Three Panel Layout */}
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={75} minSize={30}>
<PanelsContainer <PanelsContainer
left={ left={
<WizardControlPanel <WizardControlPanel
@@ -474,6 +674,7 @@ export const WizardInterface = React.memo(function WizardInterface({
activeTab={controlPanelTab} activeTab={controlPanelTab}
onTabChange={setControlPanelTab} onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending} isStarting={startTrialMutation.isPending}
onSetAutonomousLife={setAutonomousLife}
/> />
} }
center={ center={
@@ -485,8 +686,15 @@ export const WizardInterface = React.memo(function WizardInterface({
trialEvents={trialEvents} trialEvents={trialEvents}
onStepSelect={(index: number) => setCurrentStepIndex(index)} onStepSelect={(index: number) => setCurrentStepIndex(index)}
onExecuteAction={handleExecuteAction} onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
activeTab={executionPanelTab} activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab} onTabChange={setExecutionPanelTab}
onSkipAction={handleSkipAction}
isExecuting={isExecutingAction}
onNextStep={handleNextStep}
completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial}
/> />
} }
right={ right={
@@ -503,6 +711,18 @@ export const WizardInterface = React.memo(function WizardInterface({
showDividers={true} showDividers={true}
className="h-full" className="h-full"
/> />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={25} minSize={10}>
<WizardObservationPane
onAddAnnotation={handleAddAnnotation}
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div> </div>
</div> </div>
); );

View File

@@ -19,6 +19,8 @@ import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress"; import { Progress } from "~/components/ui/progress";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
@@ -35,6 +37,15 @@ interface StepData {
| "conditional_branch"; | "conditional_branch";
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
order: number; order: number;
actions?: {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}[];
} }
interface TrialData { interface TrialData {
@@ -86,6 +97,7 @@ interface WizardControlPanelProps {
activeTab: "control" | "step" | "actions" | "robot"; activeTab: "control" | "step" | "actions" | "robot";
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void; onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
isStarting?: boolean; isStarting?: boolean;
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
} }
export function WizardControlPanel({ export function WizardControlPanel({
@@ -105,65 +117,28 @@ export function WizardControlPanel({
activeTab, activeTab,
onTabChange, onTabChange,
isStarting = false, isStarting = false,
onSetAutonomousLife,
}: WizardControlPanelProps) { }: WizardControlPanelProps) {
const progress = const [autonomousLife, setAutonomousLife] = React.useState(true);
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
const getStatusConfig = (status: string) => { const handleAutonomousLifeChange = async (checked: boolean) => {
switch (status) { setAutonomousLife(checked); // Optimistic update
case "scheduled": if (onSetAutonomousLife) {
return { variant: "outline" as const, icon: Clock }; try {
case "in_progress": const result = await onSetAutonomousLife(checked);
return { variant: "default" as const, icon: Play }; if (result === false) {
case "completed": throw new Error("Service unavailable");
return { variant: "secondary" as const, icon: CheckCircle }; }
case "aborted": } catch (error) {
case "failed": console.error("Failed to set autonomous life:", error);
return { variant: "destructive" as const, icon: X }; setAutonomousLife(!checked); // Revert on failure
default: // Optional: Toast error?
return { variant: "outline" as const, icon: Clock }; }
} }
}; };
const statusConfig = getStatusConfig(trial.status);
const StatusIcon = statusConfig.icon;
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Trial Info Header */}
<div className="border-b p-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Badge
variant={statusConfig.variant}
className="flex items-center gap-1"
>
<StatusIcon className="h-3 w-3" />
{trial.status.replace("_", " ")}
</Badge>
<span className="text-muted-foreground text-xs">
Session #{trial.sessionNumber}
</span>
</div>
<div className="text-sm font-medium">
{trial.participant.participantCode}
</div>
{trial.status === "in_progress" && steps.length > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Progress</span>
<span>
{currentStepIndex + 1} of {steps.length}
</span>
</div>
<Progress value={progress} className="h-1.5" />
</div>
)}
</div>
</div>
{/* Tabbed Content */} {/* Tabbed Content */}
<Tabs <Tabs
value={activeTab} value={activeTab}
@@ -275,17 +250,36 @@ export function WizardControlPanel({
</Alert> </Alert>
)} )}
{/* Connection Status */}
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-4">
<div className="text-xs font-medium">Connection</div> <div className="text-xs font-medium">Robot Status</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
Status Connection
</span> </span>
<Badge variant="default" className="text-xs"> {_isConnected ? (
Polling <Badge variant="default" className="bg-green-600 text-xs">
Connected
</Badge> </Badge>
) : (
<Badge variant="outline" className="text-yellow-600 border-yellow-600 text-xs">
Polling...
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
</div>
<Switch
id="autonomous-life"
checked={autonomousLife}
onCheckedChange={handleAutonomousLifeChange}
disabled={!_isConnected}
className="scale-75"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,10 @@ import {
Zap, Zap,
Eye, Eye,
List, List,
Loader2,
ArrowRight,
AlertTriangle,
RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -30,6 +34,15 @@ interface StepData {
| "conditional_branch"; | "conditional_branch";
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
order: number; order: number;
actions?: {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}[];
} }
interface TrialData { interface TrialData {
@@ -75,8 +88,25 @@ interface WizardExecutionPanelProps {
actionId: string, actionId: string,
parameters?: Record<string, unknown>, parameters?: Record<string, unknown>,
) => void; ) => void;
activeTab: "current" | "timeline" | "events"; onExecuteRobotAction: (
onTabChange: (tab: "current" | "timeline" | "events") => void; pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => Promise<void>;
activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
onSkipAction: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => Promise<void>;
isExecuting?: boolean;
onNextStep?: () => void;
onCompleteTrial?: () => void;
completedActionsCount: number;
onActionCompleted: () => void;
} }
export function WizardExecutionPanel({ export function WizardExecutionPanel({
@@ -87,9 +117,21 @@ export function WizardExecutionPanel({
trialEvents, trialEvents,
onStepSelect, onStepSelect,
onExecuteAction, onExecuteAction,
onExecuteRobotAction,
activeTab, activeTab,
onTabChange, onTabChange,
onSkipAction,
isExecuting = false,
onNextStep,
onCompleteTrial,
completedActionsCount,
onActionCompleted,
}: WizardExecutionPanelProps) { }: WizardExecutionPanelProps) {
// Local state removed in favor of parent state to prevent reset on re-render
// const [completedCount, setCompletedCount] = React.useState(0);
const activeActionIndex = completedActionsCount;
const getStepIcon = (type: string) => { const getStepIcon = (type: string) => {
switch (type) { switch (type) {
case "wizard_action": case "wizard_action":
@@ -169,7 +211,7 @@ export function WizardExecutionPanel({
</h3> </h3>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{trial.completedAt && {trial.completedAt &&
`Ended at ${new Date(trial.completedAt).toLocaleTimeString()}`} `Ended at ${new Date(trial.completedAt).toLocaleTimeString()} `}
</p> </p>
</div> </div>
@@ -209,281 +251,228 @@ export function WizardExecutionPanel({
)} )}
</div> </div>
{/* Tabbed Content */} {/* Simplified Content - Sequential Focus */}
<Tabs <div className="flex-1 overflow-hidden">
value={activeTab}
onValueChange={(value: string) => {
if (
value === "current" ||
value === "timeline" ||
value === "events"
) {
onTabChange(value);
}
}}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-2 py-1">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="current" className="text-xs">
<Eye className="mr-1 h-3 w-3" />
Current
</TabsTrigger>
<TabsTrigger value="timeline" className="text-xs">
<List className="mr-1 h-3 w-3" />
Timeline
</TabsTrigger>
<TabsTrigger value="events" className="text-xs">
<Activity className="mr-1 h-3 w-3" />
Events
{trialEvents.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
{trialEvents.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
</div>
<div className="min-h-0 flex-1">
{/* Current Step Tab */}
<TabsContent value="current" className="m-0 h-full">
<div className="h-full">
{currentStep ? (
<div className="flex h-full flex-col p-4">
{/* Current Step Display */}
<div className="flex-1 space-y-4 text-left">
<div className="flex items-start gap-3">
<div className="bg-primary/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full">
{React.createElement(getStepIcon(currentStep.type), {
className: "h-5 w-5 text-primary",
})}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-medium">
{currentStep.name}
</h4>
<Badge variant="outline" className="mt-1 text-xs">
{currentStep.type.replace("_", " ")}
</Badge>
</div>
</div>
{currentStep.description && (
<div className="text-muted-foreground text-sm">
{currentStep.description}
</div>
)}
{/* Step-specific content */}
{currentStep.type === "wizard_action" && (
<div className="space-y-3">
<div className="text-sm font-medium">
Available Actions
</div>
<div className="space-y-2">
<Button
size="sm"
variant="outline"
className="w-full justify-start"
onClick={() => onExecuteAction("acknowledge")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Acknowledge Step
</Button>
<Button
size="sm"
variant="outline"
className="w-full justify-start"
onClick={() => onExecuteAction("intervene")}
>
<Zap className="mr-2 h-4 w-4" />
Manual Intervention
</Button>
<Button
size="sm"
variant="outline"
className="w-full justify-start"
onClick={() =>
onExecuteAction("note", {
content: "Step observation",
})
}
>
<User className="mr-2 h-4 w-4" />
Add Observation
</Button>
</div>
</div>
)}
{currentStep.type === "robot_action" && (
<Alert>
<Bot className="h-4 w-4" />
<AlertDescription className="text-sm">
<div className="font-medium">
Robot Action in Progress
</div>
<div className="mt-1 text-xs">
The robot is executing this step. Monitor status in
the monitoring panel.
</div>
</AlertDescription>
</Alert>
)}
{currentStep.type === "parallel_steps" && (
<Alert>
<Activity className="h-4 w-4" />
<AlertDescription className="text-sm">
<div className="font-medium">Parallel Execution</div>
<div className="mt-1 text-xs">
Multiple actions are running simultaneously.
</div>
</AlertDescription>
</Alert>
)}
</div>
</div>
) : (
<div className="flex h-full items-center justify-center p-6">
<div className="w-full max-w-md text-center">
<div className="text-muted-foreground text-sm">
No current step available
</div>
</div>
</div>
)}
</div>
</TabsContent>
{/* Timeline Tab */}
<TabsContent value="timeline" className="m-0 h-full">
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="space-y-2 p-3"> {currentStep ? (
{steps.map((step, index) => { <div className="flex flex-col gap-6 p-6">
const status = getStepStatus(index); {/* Header Info (Simplified) */}
const StepIcon = getStepIcon(step.type); <div className="space-y-4">
const isActive = index === currentStepIndex; <div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
{currentStep.description && (
<div className="text-muted-foreground text-sm mt-1">{currentStep.description}</div>
)}
</div>
</div>
</div>
{/* Action Sequence */}
{currentStep.actions && currentStep.actions.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
Execution Sequence
</h3>
</div>
<div className="grid gap-3">
{currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex;
const isActive = idx === activeActionIndex;
const isPending = idx > activeActionIndex;
return ( return (
<div <div
key={step.id} key={action.id}
className={`hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors ${ className={`group relative flex items-center gap-4 rounded-xl border p-5 transition-all ${isActive ? "bg-card border-primary ring-1 ring-primary shadow-md" :
isActive ? "bg-primary/5 border-primary/20 border" : "" isCompleted ? "bg-muted/30 border-transparent opacity-70" :
}`} "bg-card border-border opacity-50"
onClick={() => onStepSelect(index)}
>
{/* Step Number and Status */}
<div className="flex flex-col items-center">
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
status === "completed"
? "bg-green-100 text-green-700"
: status === "active"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
}`} }`}
> >
{status === "completed" ? ( <div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border text-sm font-medium ${isCompleted ? "bg-transparent text-green-600 border-green-600" :
<CheckCircle className="h-3 w-3" /> isActive ? "bg-transparent text-primary border-primary font-bold shadow-sm" :
) : ( "bg-transparent text-muted-foreground border-transparent"
index + 1 }`}>
)} {isCompleted ? <CheckCircle className="h-5 w-5" /> : idx + 1}
</div>
<div className="flex-1 min-w-0">
<div className={`font - medium truncate ${isCompleted ? "line-through text-muted-foreground" : ""} `}>{action.name}</div>
{action.description && (
<div className="text-xs text-muted-foreground line-clamp-1">
{action.description}
</div> </div>
{index < steps.length - 1 && (
<div
className={`mt-1 h-4 w-0.5 ${
status === "completed"
? "bg-green-200"
: "bg-border"
}`}
/>
)} )}
</div> </div>
{/* Step Content */} {action.pluginId && isActive && (
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StepIcon className="text-muted-foreground h-3 w-3 flex-shrink-0" /> <Button
<div className="truncate text-sm font-medium"> size="sm"
{step.name} variant="ghost"
</div> className="h-9 px-3 text-muted-foreground hover:text-foreground"
<Badge onClick={(e) => {
variant={getStepVariant(status)} e.preventDefault();
className="ml-auto flex-shrink-0 text-xs" e.stopPropagation();
> console.log("Skip clicked");
{step.type.replace("_", " ")} // Fire and forget
</Badge> onSkipAction(
</div> action.pluginId!,
action.type.includes(".")
{step.description && ( ? action.type.split(".").pop()!
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs"> : action.type,
{step.description} action.parameters || {},
</p> { autoAdvance: false }
)}
{isActive && trial.status === "in_progress" && (
<div className="mt-1 flex items-center gap-1">
<div className="bg-primary h-1.5 w-1.5 animate-pulse rounded-full" />
<span className="text-primary text-xs">
Executing
</span>
</div>
)}
</div>
</div>
); );
onActionCompleted();
}}
>
Skip
</Button>
<Button
size="default"
className="h-10 px-4 shadow-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("Execute clicked");
onExecuteRobotAction(
action.pluginId!,
action.type.includes(".")
? action.type.split(".").pop()!
: action.type,
action.parameters || {},
{ autoAdvance: false },
);
onActionCompleted();
}}
>
<Play className="mr-2 h-4 w-4" />
Execute
</Button>
</div>
)}
{/* Fallback for actions with no plugin ID (e.g. manual steps) */}
{!action.pluginId && isActive && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={(e) => {
e.preventDefault();
onActionCompleted();
}}
>
Mark Done
</Button>
</div>
)}
{/* Completed State Indicator */}
{isCompleted && (
<div className="flex items-center gap-2 px-3">
<div className="text-xs font-medium text-green-600">
Done
</div>
{action.pluginId && (
<>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
title="Retry Action"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Execute again without advancing count
onExecuteRobotAction(
action.pluginId!,
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
action.parameters || {},
{ autoAdvance: false },
);
}}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-100"
title="Mark Issue"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onExecuteAction("note", {
content: `Reported issue with action: ${action.name}`,
category: "system_issue"
});
}}
>
<AlertTriangle className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
)}
</div>
)
})} })}
</div> </div>
</ScrollArea>
</TabsContent>
{/* Events Tab */} {/* Manual Advance Button */}
<TabsContent value="events" className="m-0 h-full"> {activeActionIndex >= (currentStep.actions?.length || 0) && (
<ScrollArea className="h-full"> <div className="mt-6 flex justify-end">
<div className="p-3"> <Button
{trialEvents.length === 0 ? ( size="lg"
<div className="flex h-32 items-center justify-center"> onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
<div className="text-muted-foreground text-center text-sm"> className={`w-full text-white shadow-md transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
No events recorded yet ? "bg-blue-600 hover:bg-blue-700"
: "bg-green-600 hover:bg-green-700"
}`}
>
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</div> </div>
)}
</div>
)}
{/* Manual Wizard Controls (If applicable) */}
{currentStep.type === "wizard_action" && (
<div className="rounded-xl border border-dashed p-6 space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className="h-12 justify-start"
onClick={() => onExecuteAction("acknowledge")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Acknowledge
</Button>
<Button
variant="outline"
className="h-12 justify-start"
onClick={() => onExecuteAction("intervene")}
>
<Zap className="mr-2 h-4 w-4" />
Intervene
</Button>
</div>
</div>
)}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="flex h-full items-center justify-center text-muted-foreground">
{trialEvents No active step
.slice()
.reverse()
.map((event, index) => (
<div
key={`${event.timestamp.getTime()}-${index}`}
className="border-border/50 flex items-start gap-2 rounded-lg border p-2"
>
<div className="bg-muted flex h-6 w-6 flex-shrink-0 items-center justify-center rounded">
<Activity className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium capitalize">
{event.type.replace(/_/g, " ")}
</div>
{event.message && (
<div className="text-muted-foreground mt-1 text-xs">
{event.message}
</div> </div>
)} )}
<div className="text-muted-foreground mt-1 text-xs">
{event.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea> </ScrollArea>
</TabsContent>
</div>
</Tabs>
</div> </div>
</div >
); );
} }

View File

@@ -0,0 +1,158 @@
"use client";
import React, { useState } from "react";
import { Send, Hash, Tag, Clock, Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Badge } from "~/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { HorizontalTimeline } from "~/components/trials/timeline/HorizontalTimeline";
interface TrialEvent {
type: string;
timestamp: Date;
data?: unknown;
message?: string;
}
interface WizardObservationPaneProps {
onAddAnnotation: (
description: string,
category?: string,
tags?: string[],
) => Promise<void>;
isSubmitting?: boolean;
}
export function WizardObservationPane({
onAddAnnotation,
isSubmitting = false,
trialEvents = [],
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
const [note, setNote] = useState("");
const [category, setCategory] = useState("observation");
const [tags, setTags] = useState<string[]>([]);
const [currentTag, setCurrentTag] = useState("");
const handleSubmit = async () => {
if (!note.trim()) return;
await onAddAnnotation(note, category, tags);
setNote("");
setTags([]);
setCurrentTag("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
};
const addTag = () => {
const trimmed = currentTag.trim();
if (trimmed && !tags.includes(trimmed)) {
setTags([...tags, trimmed]);
setCurrentTag("");
}
};
return (
<div className="flex h-full flex-col border-t bg-background">
<Tabs defaultValue="notes" className="flex h-full flex-col">
<div className="border-b px-4 bg-muted/30">
<TabsList className="h-9 -mb-px bg-transparent p-0">
<TabsTrigger value="notes" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
Notes & Observations
</TabsTrigger>
<TabsTrigger value="timeline" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
Timeline
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="notes" className="flex-1 flex flex-col p-4 m-0 data-[state=inactive]:hidden">
<div className="flex flex-1 flex-col gap-2">
<Textarea
placeholder="Type your observation here..."
className="flex-1 resize-none font-mono text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className="flex items-center gap-2">
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="observation">Observation</SelectItem>
<SelectItem value="participant_behavior">Behavior</SelectItem>
<SelectItem value="system_issue">System Issue</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
<Tag className="h-3 w-3 text-muted-foreground" />
<input
type="text"
placeholder="Add tags..."
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
onBlur={addTag}
/>
</div>
<Button
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim()}
className="h-8"
>
<Send className="mr-2 h-3 w-3" />
Add Note
</Button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="px-1 py-0 text-[10px] cursor-pointer hover:bg-destructive/10 hover:text-destructive"
onClick={() => setTags(tags.filter((t) => t !== tag))}
>
#{tag}
</Badge>
))}
</div>
)}
</div>
</TabsContent>
<TabsContent value="timeline" className="flex-1 m-0 min-h-0 p-4 data-[state=inactive]:hidden">
<HorizontalTimeline events={trialEvents} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -119,8 +119,8 @@ export function EntityForm<T extends FieldValues = FieldValues>({
{/* Form Layout */} {/* Form Layout */}
<div <div
className={cn( className={cn(
"grid gap-8", "grid gap-8 w-full",
layout === "default" && "grid-cols-1 lg:grid-cols-3", layout === "default" && "grid-cols-1 lg:grid-cols-3", // Keep the column split but remove max-width
layout === "full-width" && "grid-cols-1", layout === "full-width" && "grid-cols-1",
)} )}
> >

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import * as LucideIcons from "lucide-react"; import * as LucideIcons from "lucide-react";
import { cn } from "~/lib/utils";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -45,10 +46,15 @@ interface EntityViewSidebarProps {
children: ReactNode; children: ReactNode;
} }
interface EntityViewProps { interface EntityViewProps {
children: ReactNode; children: ReactNode;
layout?: "default" | "full-width";
} }
// ... existing code ...
export function EntityViewHeader({ export function EntityViewHeader({
title, title,
subtitle, subtitle,
@@ -115,8 +121,15 @@ export function EntityViewSidebar({ children }: EntityViewSidebarProps) {
return <div className="space-y-6">{children}</div>; return <div className="space-y-6">{children}</div>;
} }
export function EntityView({ children }: EntityViewProps) { export function EntityView({ children, layout = "default" }: EntityViewProps) {
return <div className="space-y-6">{children}</div>; // Simplification: Always take full width of the parent container provided by DashboardLayout
// The DashboardLayout already provides padding (p-4).
// We remove 'container mx-auto max-w-5xl' to stop it from shrinking.
return (
<div className="flex flex-col gap-6 w-full h-full">
{children}
</div>
);
} }
// Utility component for empty states // Utility component for empty states
@@ -158,8 +171,7 @@ interface InfoGridProps {
export function InfoGrid({ items, columns = 2 }: InfoGridProps) { export function InfoGrid({ items, columns = 2 }: InfoGridProps) {
return ( return (
<div <div
className={`grid gap-4 ${ className={`grid gap-4 ${columns === 1
columns === 1
? "grid-cols-1" ? "grid-cols-1"
: columns === 2 : columns === 2
? "md:grid-cols-2" ? "md:grid-cols-2"

View File

@@ -39,8 +39,11 @@ export interface UseWizardRosReturn {
}; };
}, },
) => Promise<RobotActionExecution>; ) => Promise<RobotActionExecution>;
callService: (service: string, args?: Record<string, unknown>) => Promise<any>;
setAutonomousLife: (enabled: boolean) => Promise<boolean>;
} }
export function useWizardRos( export function useWizardRos(
options: UseWizardRosOptions = {}, options: UseWizardRosOptions = {},
): UseWizardRosReturn { ): UseWizardRosReturn {
@@ -288,6 +291,24 @@ export function useWizardRos(
[isConnected], [isConnected],
); );
const callService = useCallback(
async (service: string, args?: Record<string, unknown>): Promise<any> => {
const srv = serviceRef.current;
if (!srv || !isConnected) throw new Error("Not connected");
return srv.callService(service, args);
},
[isConnected],
);
const setAutonomousLife = useCallback(
async (enabled: boolean): Promise<boolean> => {
const srv = serviceRef.current;
if (!srv || !isConnected) throw new Error("Not connected");
return srv.setAutonomousLife(enabled);
},
[isConnected],
);
return { return {
isConnected, isConnected,
isConnecting, isConnecting,
@@ -297,5 +318,7 @@ export function useWizardRos(
connect, connect,
disconnect, disconnect,
executeRobotAction, executeRobotAction,
callService,
setAutonomousLife,
}; };
} }

View File

@@ -14,6 +14,17 @@ export interface RosMessage {
values?: Record<string, unknown>; values?: Record<string, unknown>;
} }
export interface ServiceRequest {
service: string;
args?: Record<string, unknown>;
}
export interface ServiceResponse {
result: boolean;
values?: Record<string, unknown>;
error?: string;
}
export interface RobotStatus { export interface RobotStatus {
connected: boolean; connected: boolean;
battery: number; battery: number;
@@ -405,7 +416,8 @@ export class WizardRosService extends EventEmitter {
let msg: Record<string, unknown>; let msg: Record<string, unknown>;
if ( if (
config.payloadMapping.type === "template" && (config.payloadMapping.type === "template" ||
config.payloadMapping.type === "static") &&
config.payloadMapping.payload config.payloadMapping.payload
) { ) {
// Template-based payload construction // Template-based payload construction
@@ -451,10 +463,15 @@ export class WizardRosService extends EventEmitter {
this.executeMovementAction(actionId, parameters); this.executeMovementAction(actionId, parameters);
break; break;
case "move_head":
case "turn_head": case "turn_head":
this.executeTurnHead(parameters); this.executeTurnHead(parameters);
break; break;
case "move_arm":
this.executeMoveArm(parameters);
break;
case "emergency_stop": case "emergency_stop":
this.publish("/cmd_vel", "geometry_msgs/Twist", { this.publish("/cmd_vel", "geometry_msgs/Twist", {
linear: { x: 0, y: 0, z: 0 }, linear: { x: 0, y: 0, z: 0 },
@@ -497,7 +514,7 @@ export class WizardRosService extends EventEmitter {
break; break;
} }
this.publish("/cmd_vel", "geometry_msgs/Twist", { linear, angular }); this.publish("/naoqi_driver/cmd_vel", "geometry_msgs/Twist", { linear, angular });
} }
/** /**
@@ -515,6 +532,139 @@ export class WizardRosService extends EventEmitter {
}); });
} }
/**
* Execute arm movement
*/
private executeMoveArm(parameters: Record<string, unknown>): void {
const arm = String(parameters.arm || "Right");
const roll = Number(parameters.roll) || 0;
const pitch = Number(parameters.pitch) || 0;
const speed = Number(parameters.speed) || 0.2;
const prefix = arm === "Left" ? "L" : "R";
const jointNames = [`${prefix}ShoulderPitch`, `${prefix}ShoulderRoll`];
const jointAngles = [pitch, roll];
this.publish("/naoqi_driver/joint_angles", "naoqi_bridge_msgs/JointAnglesWithSpeed", {
joint_names: jointNames,
joint_angles: jointAngles,
speed: speed,
});
}
/**
* Call a ROS service
*/
async callService(
service: string,
args: Record<string, unknown> = {},
): Promise<ServiceResponse> {
if (!this.isConnected) {
throw new Error("Not connected to ROS bridge");
}
const id = `call_${this.messageId++}`;
return new Promise((resolve, reject) => {
const handleResponse = (message: RosMessage) => {
if (message.op === "service_response" && message.id === id) {
this.off("message", handleResponse);
if (message.result === false) {
resolve({ result: false, error: String(message.values || "Service call failed") });
} else {
resolve({ result: true, values: message.values });
}
}
};
this.on("message", handleResponse);
this.send({
op: "call_service",
service,
args,
id,
});
setTimeout(() => {
this.off("message", handleResponse);
reject(new Error("Service call timed out"));
}, 5000);
});
}
/**
* Set Autonomous Life state with fallbacks
*/
async setAutonomousLife(enabled: boolean): Promise<boolean> {
const desiredState = enabled ? "interactive" : "disabled";
// List of services to try in order
const attempts = [
// Standard NaoQi Bridge pattern
{
service: "/naoqi_driver/ALAutonomousLife/setState",
args: { state: desiredState }
},
{
service: "/naoqi_driver/ALAutonomousLife/set_state",
args: { state: desiredState }
},
// Direct module mapping
{
service: "/ALAutonomousLife/setState",
args: { state: desiredState }
},
// Shortcuts/Aliases
{
service: "/naoqi_driver/set_autonomous_life",
args: { state: desiredState }
},
{
service: "/autonomous_life/set_state",
args: { state: desiredState }
},
// Fallback: Enable/Disable topics/services
{
service: enabled ? "/life/enable" : "/life/disable",
args: {}
},
// Last resort: Generic proxy call (if available)
{
service: "/naoqi_driver/function_call",
args: {
service: "ALAutonomousLife",
function: "setState",
args: [desiredState]
}
}
];
console.log(`[WizardROS] Setting Autonomous Life to: ${desiredState}`);
for (const attempt of attempts) {
try {
console.log(`[WizardROS] Trying service: ${attempt.service}`);
const response = await this.callService(attempt.service, attempt.args);
// If the service call didn't timeout (it resolved), check result
if (response.result) {
console.log(`[WizardROS] Success via ${attempt.service}`);
return true;
} else {
// Resolved but failed? (e.g. internal error)
console.warn(`[WizardROS] Service ${attempt.service} returned false result:`, response.error);
}
} catch (error) {
// Service call failed or timed out
console.warn(`[WizardROS] Service ${attempt.service} failed/timeout:`, error);
}
}
console.error("[WizardROS] All Autonomous Life service attempts failed.");
return false;
}
/** /**
* Build template-based payload * Build template-based payload
*/ */
@@ -574,11 +724,13 @@ export class WizardRosService extends EventEmitter {
}; };
case "naoSpeechTransform": case "naoSpeechTransform":
case "transformToStringMessage":
return { return {
data: String(parameters.text || "Hello"), data: String(parameters.text || "Hello"),
}; };
case "naoHeadTransform": case "naoHeadTransform":
case "transformToHeadMovement":
return { return {
joint_names: ["HeadYaw", "HeadPitch"], joint_names: ["HeadYaw", "HeadPitch"],
joint_angles: [ joint_angles: [
@@ -588,6 +740,13 @@ export class WizardRosService extends EventEmitter {
speed: Number(parameters.speed) || 0.3, speed: Number(parameters.speed) || 0.3,
}; };
case "transformToJointAngles":
return {
joint_names: [String(parameters.joint_name || "HeadYaw")],
joint_angles: [Number(parameters.angle) || 0],
speed: Number(parameters.speed) || 0.2,
};
default: default:
console.warn(`Unknown transform function: ${transformFn}`); console.warn(`Unknown transform function: ${transformFn}`);
return parameters; return parameters;

View File

@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function formatBytes(bytes: number, decimals = 2) {
if (!+bytes) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}

View File

@@ -4,6 +4,7 @@ import { authRouter } from "~/server/api/routers/auth";
import { collaborationRouter } from "~/server/api/routers/collaboration"; import { collaborationRouter } from "~/server/api/routers/collaboration";
import { dashboardRouter } from "~/server/api/routers/dashboard"; import { dashboardRouter } from "~/server/api/routers/dashboard";
import { experimentsRouter } from "~/server/api/routers/experiments"; import { experimentsRouter } from "~/server/api/routers/experiments";
import { filesRouter } from "~/server/api/routers/files";
import { mediaRouter } from "~/server/api/routers/media"; import { mediaRouter } from "~/server/api/routers/media";
import { participantsRouter } from "~/server/api/routers/participants"; import { participantsRouter } from "~/server/api/routers/participants";
import { robotsRouter } from "~/server/api/routers/robots"; import { robotsRouter } from "~/server/api/routers/robots";
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
participants: participantsRouter, participants: participantsRouter,
trials: trialsRouter, trials: trialsRouter,
robots: robotsRouter, robots: robotsRouter,
files: filesRouter,
media: mediaRouter, media: mediaRouter,
analytics: analyticsRouter, analytics: analyticsRouter,
collaboration: collaborationRouter, collaboration: collaborationRouter,

View File

@@ -1542,6 +1542,15 @@ export const experimentsRouter = createTRPCRouter({
parameters: step.conditions as Record<string, unknown>, parameters: step.conditions as Record<string, unknown>,
parentId: undefined, // Not supported in current schema parentId: undefined, // Not supported in current schema
children: [], // TODO: implement hierarchical steps if needed children: [], // TODO: implement hierarchical steps if needed
actions: step.actions.map((action) => ({
id: action.id,
name: action.name,
description: action.description,
type: action.type,
order: action.orderIndex,
parameters: action.parameters as Record<string, unknown>,
pluginId: action.pluginId,
})),
})); }));
}), }),

View File

@@ -0,0 +1,146 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { participantDocuments } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { env } from "~/env";
import * as Minio from "minio";
import { uuid } from "drizzle-orm/pg-core";
import { eq, desc } from "drizzle-orm";
// Initialize MinIO client
// Note: In production, ensure these ENV vars are set.
// For development with docker-compose, we use localhost:9000
const minioClient = new Minio.Client({
endPoint: (env.MINIO_ENDPOINT ?? "localhost").split(":")[0] ?? "localhost",
port: parseInt((env.MINIO_ENDPOINT ?? "9000").split(":")[1] ?? "9000"),
useSSL: false, // Default to false for local dev; adjust for prod
accessKey: env.MINIO_ACCESS_KEY ?? "minioadmin",
secretKey: env.MINIO_SECRET_KEY ?? "minioadmin",
});
const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio-assets";
// Ensure bucket exists on startup (best effort)
const ensureBucket = async () => {
try {
const exists = await minioClient.bucketExists(BUCKET_NAME);
if (!exists) {
await minioClient.makeBucket(BUCKET_NAME, env.MINIO_REGION ?? "us-east-1");
// Set public policy if needed? For now, keep private and use presigned URLs.
}
} catch (e) {
console.error("Error ensuring MinIO bucket exists:", e);
}
}
void ensureBucket(); // Fire and forget on load
export const filesRouter = createTRPCRouter({
// Get a presigned URL for uploading a file
getPresignedUrl: protectedProcedure
.input(z.object({
filename: z.string(),
contentType: z.string(),
participantId: z.string(),
}))
.mutation(async ({ input }) => {
const fileExtension = input.filename.split(".").pop();
const uniqueFilename = `${input.participantId}/${crypto.randomUUID()}.${fileExtension}`;
try {
const presignedUrl = await minioClient.presignedPutObject(
BUCKET_NAME,
uniqueFilename,
60 * 5 // 5 minutes expiry
);
return {
url: presignedUrl,
storagePath: uniqueFilename, // Pass this back to client to save in DB after upload
};
} catch (error) {
console.error("Error generating presigned URL:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate upload URL",
});
}
}),
// Get a presigned URL for downloading/viewing a file
getDownloadUrl: protectedProcedure
.input(z.object({
storagePath: z.string(),
}))
.query(async ({ input }) => {
try {
const url = await minioClient.presignedGetObject(
BUCKET_NAME,
input.storagePath,
60 * 60 // 1 hour
);
return { url };
} catch (error) {
throw new TRPCError({
code: "NOT_FOUND",
message: "File not found or storage error",
});
}
}),
// Record a successful upload in the database
registerUpload: protectedProcedure
.input(z.object({
participantId: z.string(),
name: z.string(),
type: z.string().optional(),
storagePath: z.string(),
fileSize: z.number().optional(),
}))
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(participantDocuments).values({
participantId: input.participantId,
name: input.name,
type: input.type,
storagePath: input.storagePath,
fileSize: input.fileSize,
uploadedBy: ctx.session.user.id,
});
}),
// List documents for a participant
listParticipantDocuments: protectedProcedure
.input(z.object({ participantId: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.participantDocuments.findMany({
where: eq(participantDocuments.participantId, input.participantId),
orderBy: [desc(participantDocuments.createdAt)],
with: {
// Optional: join with uploader info if needed
}
});
}),
// Delete a document
deleteDocument: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const doc = await ctx.db.query.participantDocuments.findFirst({
where: eq(participantDocuments.id, input.id),
});
if (!doc) {
throw new TRPCError({ code: "NOT_FOUND", message: "Document not found" });
}
// Delete from database
await ctx.db.delete(participantDocuments).where(eq(participantDocuments.id, input.id));
// Delete from MinIO (fire and forget or await)
try {
await minioClient.removeObject(BUCKET_NAME, doc.storagePath);
} catch (e) {
console.error("Failed to delete object from S3:", e);
// We still consider the operation successful for the user as the DB record is gone.
}
}),
});

View File

@@ -24,6 +24,7 @@ import {
wizardInterventions, wizardInterventions,
mediaCaptures, mediaCaptures,
users, users,
annotations,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { import {
TrialExecutionEngine, TrialExecutionEngine,
@@ -263,7 +264,22 @@ export const trialsRouter = createTRPCRouter({
}); });
} }
return trial[0]; // Fetch additional stats
const eventCount = await db
.select({ count: count() })
.from(trialEvents)
.where(eq(trialEvents.trialId, input.id));
const mediaCount = await db
.select({ count: count() })
.from(mediaCaptures)
.where(eq(mediaCaptures.trialId, input.id));
return {
...trial[0],
eventCount: eventCount[0]?.count ?? 0,
mediaCount: mediaCount[0]?.count ?? 0,
};
}), }),
create: protectedProcedure create: protectedProcedure
@@ -384,6 +400,58 @@ export const trialsRouter = createTRPCRouter({
return trial; return trial;
}), }),
duplicate: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkTrialAccess(db, userId, input.id, [
"owner",
"researcher",
"wizard",
]);
// Get source trial
const sourceTrial = await db
.select()
.from(trials)
.where(eq(trials.id, input.id))
.limit(1);
if (!sourceTrial[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Source trial not found",
});
}
// Create new trial based on source
const [newTrial] = await db
.insert(trials)
.values({
experimentId: sourceTrial[0].experimentId,
participantId: sourceTrial[0].participantId,
// Scheduled for now + 1 hour by default, or null? Let's use null or source time?
// New duplicate usually implies "planning to run soon".
// I'll leave scheduledAt null or same as source if future?
// Let's set it to tomorrow by default to avoid confusion
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
wizardId: sourceTrial[0].wizardId,
sessionNumber: (sourceTrial[0].sessionNumber || 0) + 1, // Increment session
status: "scheduled",
notes: `Duplicate of trial ${sourceTrial[0].id}. ${sourceTrial[0].notes || ""}`,
metadata: sourceTrial[0].metadata,
})
.returning();
return newTrial;
}),
start: protectedProcedure start: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -414,10 +482,15 @@ export const trialsRouter = createTRPCRouter({
}); });
} }
// Idempotency: If already in progress, return success
if (currentTrial[0].status === "in_progress") {
return currentTrial[0];
}
if (currentTrial[0].status !== "scheduled") { if (currentTrial[0].status !== "scheduled") {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Trial can only be started from scheduled status", message: `Trial is in ${currentTrial[0].status} status and cannot be started`,
}); });
} }
@@ -599,6 +672,61 @@ export const trialsRouter = createTRPCRouter({
return intervention; return intervention;
}), }),
addAnnotation: protectedProcedure
.input(
z.object({
trialId: z.string(),
category: z.string().optional(),
label: z.string().optional(),
description: z.string().optional(),
timestampStart: z.date().optional(),
tags: z.array(z.string()).optional(),
metadata: z.any().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkTrialAccess(db, userId, input.trialId, [
"owner",
"researcher",
"wizard",
]);
const [annotation] = await db
.insert(annotations)
.values({
trialId: input.trialId,
annotatorId: userId,
category: input.category,
label: input.label,
description: input.description,
timestampStart: input.timestampStart ?? new Date(),
tags: input.tags,
metadata: input.metadata,
})
.returning();
// Also create a trial event so it appears in the timeline
if (annotation) {
await db.insert(trialEvents).values({
trialId: input.trialId,
eventType: `annotation_${input.category || 'note'}`,
timestamp: input.timestampStart ?? new Date(),
data: {
annotationId: annotation.id,
description: input.description,
category: input.category,
label: input.label,
tags: input.tags,
},
});
}
return annotation;
}),
getEvents: protectedProcedure getEvents: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -967,4 +1095,46 @@ export const trialsRouter = createTRPCRouter({
duration: result.duration, duration: result.duration,
}; };
}), }),
logRobotAction: protectedProcedure
.input(
z.object({
trialId: z.string(),
pluginName: z.string(),
actionId: z.string(),
parameters: z.record(z.string(), z.unknown()).optional().default({}),
duration: z.number().optional(),
result: z.any().optional(),
error: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkTrialAccess(db, userId, input.trialId, [
"owner",
"researcher",
"wizard",
]);
await db.insert(trialEvents).values({
trialId: input.trialId,
eventType: "manual_robot_action",
data: {
userId,
pluginName: input.pluginName,
actionId: input.actionId,
parameters: input.parameters,
result: input.result,
duration: input.duration,
error: input.error,
executionMode: "websocket_client",
},
timestamp: new Date(),
createdBy: userId,
});
return { success: true };
}),
}); });

View File

@@ -438,6 +438,29 @@ export const participants = createTable(
}), }),
); );
export const participantDocuments = createTable(
"participant_document",
{
id: uuid("id").notNull().primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
type: varchar("type", { length: 100 }), // MIME type or custom category
storagePath: text("storage_path").notNull(),
fileSize: integer("file_size"),
uploadedBy: uuid("uploaded_by").references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
},
(table) => ({
participantDocIdx: index("participant_document_participant_idx").on(
table.participantId,
),
}),
);
export const trials = createTable("trial", { export const trials = createTable("trial", {
id: uuid("id").notNull().primaryKey().defaultRandom(), id: uuid("id").notNull().primaryKey().defaultRandom(),
experimentId: uuid("experiment_id") experimentId: uuid("experiment_id")

View File

@@ -507,6 +507,8 @@ export class TrialExecutionEngine {
// Parse plugin.action format // Parse plugin.action format
const [pluginName, actionId] = action.type.split("."); const [pluginName, actionId] = action.type.split(".");
console.log(`[TrialExecution] Parsed action: pluginName=${pluginName}, actionId=${actionId}`);
if (!pluginName || !actionId) { if (!pluginName || !actionId) {
throw new Error( throw new Error(
`Invalid robot action format: ${action.type}. Expected format: plugin.action`, `Invalid robot action format: ${action.type}. Expected format: plugin.action`,
@@ -516,9 +518,12 @@ export class TrialExecutionEngine {
// Get plugin configuration from database // Get plugin configuration from database
const plugin = await this.getPluginDefinition(pluginName); const plugin = await this.getPluginDefinition(pluginName);
if (!plugin) { if (!plugin) {
throw new Error(`Plugin not found: ${pluginName}`); throw new Error(`Plugin '${pluginName}' not found`);
} }
console.log(`[TrialExecution] Plugin loaded: ${plugin.name} (ID: ${plugin.id})`);
console.log(`[TrialExecution] Available actions: ${plugin.actions?.map((a: any) => a.id).join(", ")}`);
// Find action definition in plugin // Find action definition in plugin
const actionDefinition = plugin.actions?.find( const actionDefinition = plugin.actions?.find(
(a: any) => a.id === actionId, (a: any) => a.id === actionId,
@@ -582,14 +587,27 @@ export class TrialExecutionEngine {
} }
try { try {
// Check if pluginName is a UUID
const isUuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
pluginName,
);
const query = isUuid
? eq(plugins.id, pluginName)
: eq(plugins.name, pluginName);
const [plugin] = await this.db const [plugin] = await this.db
.select() .select()
.from(plugins) .from(plugins)
.where(eq(plugins.name, pluginName)) .where(query)
.limit(1); .limit(1);
if (plugin) { if (plugin) {
// Cache the plugin definition // Cache the plugin definition
// Use the actual name for cache key if we looked up by ID
const cacheKey = isUuid ? plugin.name : pluginName;
const pluginData = { const pluginData = {
...plugin, ...plugin,
actions: plugin.actionDefinitions, actions: plugin.actionDefinitions,
@@ -597,7 +615,12 @@ export class TrialExecutionEngine {
ros2Config: (plugin.metadata as any)?.ros2Config, ros2Config: (plugin.metadata as any)?.ros2Config,
}; };
this.pluginCache.set(pluginName, pluginData); this.pluginCache.set(cacheKey, pluginData);
// Also cache by ID if accessible
if (plugin.id) {
this.pluginCache.set(plugin.id, pluginData);
}
return pluginData; return pluginData;
} }