diff --git a/ecosystem-explorer/bun.lock b/ecosystem-explorer/bun.lock
index f2ff5931..0ebb5615 100644
--- a/ecosystem-explorer/bun.lock
+++ b/ecosystem-explorer/bun.lock
@@ -8,8 +8,10 @@
"@grafana/faro-react": "^2.4.0",
"@grafana/faro-web-tracing": "^2.4.0",
"@radix-ui/react-hover-card": "^1.1.15",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
+ "cmdk": "^1.1.1",
"idb": "^8.0.3",
"js-yaml": "^4.1.1",
"lucide-react": "^1.0.0",
@@ -221,14 +223,22 @@
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
+
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
+
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
+
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
+
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@@ -463,6 +473,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -499,6 +511,8 @@
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
+ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
+
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -535,6 +549,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="],
@@ -613,6 +629,8 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
+
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -895,10 +913,16 @@
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+ "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
+
+ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
"react-router": ["react-router@7.14.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw=="],
"react-router-dom": ["react-router-dom@7.14.2", "", { "dependencies": { "react-router": "7.14.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ=="],
+ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
@@ -1015,6 +1039,10 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+
+ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
+
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
diff --git a/ecosystem-explorer/package.json b/ecosystem-explorer/package.json
index e71a4218..6ef79e35 100644
--- a/ecosystem-explorer/package.json
+++ b/ecosystem-explorer/package.json
@@ -28,8 +28,10 @@
"@grafana/faro-react": "^2.4.0",
"@grafana/faro-web-tracing": "^2.4.0",
"@radix-ui/react-hover-card": "^1.1.15",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
+ "cmdk": "^1.1.1",
"idb": "^8.0.3",
"js-yaml": "^4.1.1",
"lucide-react": "^1.0.0",
diff --git a/ecosystem-explorer/src/components/ui/searchable-multi-select.test.tsx b/ecosystem-explorer/src/components/ui/searchable-multi-select.test.tsx
new file mode 100644
index 00000000..5d24fd93
--- /dev/null
+++ b/ecosystem-explorer/src/components/ui/searchable-multi-select.test.tsx
@@ -0,0 +1,116 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { SearchableMultiSelect, SelectedChips } from "./searchable-multi-select";
+
+describe("SearchableMultiSelect", () => {
+ const options = ["Apple", "Banana", "Cherry", "Date", "Elderberry"];
+ const defaultProps = {
+ label: "Fruits",
+ placeholder: "Select fruits...",
+ options,
+ selected: [],
+ onChange: vi.fn(),
+ };
+
+ it("renders correctly with placeholder", () => {
+ render();
+ expect(screen.getByText("Fruits")).toBeInTheDocument();
+ expect(screen.getByText("Select fruits...")).toBeInTheDocument();
+ });
+
+ it("shows number of selected items when selection exists", () => {
+ render();
+ expect(screen.getByText("2 selected")).toBeInTheDocument();
+ });
+
+ it("opens popover on click and displays options", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const trigger = screen.getByRole("button", { name: "Fruits" });
+ await user.click(trigger);
+
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+
+ for (const option of options) {
+ expect(screen.getByRole("option", { name: option })).toBeInTheDocument();
+ }
+ });
+
+ it("filters options when typing in search input", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Fruits" }));
+
+ const searchInput = screen.getByPlaceholderText("Search...");
+ await user.type(searchInput, "ba");
+
+ expect(screen.getByRole("option", { name: "Banana" })).toBeInTheDocument();
+ expect(screen.queryByRole("option", { name: "Apple" })).not.toBeInTheDocument();
+ });
+
+ it("calls onChange when an option is toggled", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Fruits" }));
+ await user.click(screen.getByRole("option", { name: "Banana" }));
+
+ expect(onChange).toHaveBeenCalledWith(["Banana"]);
+ });
+
+ it("removes option if already selected", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Fruits" }));
+ await user.click(screen.getByRole("option", { name: "Banana" }));
+
+ expect(onChange).toHaveBeenCalledWith(["Apple"]);
+ });
+});
+
+describe("SelectedChips", () => {
+ it("renders nothing if selected is empty", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders chips for selected items", () => {
+ render();
+ expect(screen.getByText("Apple")).toBeInTheDocument();
+ expect(screen.getByText("Banana")).toBeInTheDocument();
+ });
+
+ it("calls onRemove when a chip's remove button is clicked", async () => {
+ const user = userEvent.setup();
+ const onRemove = vi.fn();
+ render();
+
+ const removeAppleButton = screen.getByRole("button", { name: "Remove Apple" });
+ await user.click(removeAppleButton);
+
+ expect(onRemove).toHaveBeenCalledWith("Apple");
+ });
+});
diff --git a/ecosystem-explorer/src/components/ui/searchable-multi-select.tsx b/ecosystem-explorer/src/components/ui/searchable-multi-select.tsx
new file mode 100644
index 00000000..438b2904
--- /dev/null
+++ b/ecosystem-explorer/src/components/ui/searchable-multi-select.tsx
@@ -0,0 +1,157 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { useState, useId, type ReactNode } from "react";
+import { Search, ChevronDown, Check, X } from "lucide-react";
+import * as Popover from "@radix-ui/react-popover";
+import { Command } from "cmdk";
+
+interface SearchableMultiSelectProps {
+ label: string;
+ placeholder: string;
+ options: string[];
+ selected: string[];
+ onChange: (selected: string[]) => void;
+ renderOption?: (option: string) => ReactNode;
+ className?: string;
+}
+
+export function SearchableMultiSelect({
+ label,
+ placeholder,
+ options,
+ selected,
+ onChange,
+ renderOption,
+ className = "",
+}: SearchableMultiSelectProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const triggerId = useId();
+
+ const toggleOption = (option: string) => {
+ const newSelected = selected.includes(option)
+ ? selected.filter((item) => item !== option)
+ : [...selected, option];
+ onChange(newSelected);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No options found
+
+
+ {options.map((option) => (
+ toggleOption(option)}
+ className={`hover:bg-primary/10 data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors outline-none ${
+ selected.includes(option)
+ ? "bg-primary/5 text-primary font-medium"
+ : "text-foreground"
+ }`}
+ >
+ {renderOption ? renderOption(option) : option}
+ {selected.includes(option) && }
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+export function SelectedChips({
+ selected,
+ onRemove,
+ renderItem,
+ className = "",
+}: {
+ selected: string[];
+ onRemove: (item: string) => void;
+ renderItem?: (item: string) => ReactNode;
+ className?: string;
+}) {
+ if (selected.length === 0) return null;
+
+ return (
+
+ {selected.map((item) => (
+
+ {renderItem ? renderItem(item) : item}
+
+
+ ))}
+
+ );
+}
diff --git a/ecosystem-explorer/src/features/java-agent/components/instrumentation-badges.test.tsx b/ecosystem-explorer/src/features/java-agent/components/instrumentation-badges.test.tsx
index 6775ca1b..2e17a881 100644
--- a/ecosystem-explorer/src/features/java-agent/components/instrumentation-badges.test.tsx
+++ b/ecosystem-explorer/src/features/java-agent/components/instrumentation-badges.test.tsx
@@ -38,6 +38,8 @@ const defaultFilters: FilterState = {
search: "",
telemetry: new Set(),
target: new Set(),
+ semantic: [],
+ features: [],
};
describe("TargetBadges", () => {
@@ -73,6 +75,8 @@ describe("TargetBadges", () => {
search: "",
telemetry: new Set(),
target: new Set(["javaagent"]),
+ semantic: [],
+ features: [],
};
render(
@@ -81,7 +85,13 @@ describe("TargetBadges", () => {
});
it("highlights Library badge when library filter is active", () => {
- const filters: FilterState = { search: "", telemetry: new Set(), target: new Set(["library"]) };
+ const filters: FilterState = {
+ search: "",
+ telemetry: new Set(),
+ target: new Set(["library"]),
+ semantic: [],
+ features: [],
+ };
render(
);
@@ -121,13 +131,25 @@ describe("TelemetryBadges", () => {
});
it("highlights Spans badge when spans filter is active", () => {
- const filters: FilterState = { search: "", telemetry: new Set(["spans"]), target: new Set() };
+ const filters: FilterState = {
+ search: "",
+ telemetry: new Set(["spans"]),
+ target: new Set(),
+ semantic: [],
+ features: [],
+ };
render();
expect(screen.getByText("Spans").className).toContain(FILTER_STYLES.telemetry.spans.active);
});
it("highlights Metrics badge when metrics filter is active", () => {
- const filters: FilterState = { search: "", telemetry: new Set(["metrics"]), target: new Set() };
+ const filters: FilterState = {
+ search: "",
+ telemetry: new Set(["metrics"]),
+ target: new Set(),
+ semantic: [],
+ features: [],
+ };
render();
expect(screen.getByText("Metrics").className).toContain(FILTER_STYLES.telemetry.metrics.active);
});
diff --git a/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.test.tsx b/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.test.tsx
index f07b6058..fec8cee6 100644
--- a/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.test.tsx
+++ b/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.test.tsx
@@ -164,6 +164,8 @@ describe("InstrumentationCard", () => {
search: "",
telemetry: new Set(),
target: new Set(["javaagent"]),
+ semantic: [],
+ features: [],
};
renderCard(instrumentation, activeFilters);
@@ -181,6 +183,8 @@ describe("InstrumentationCard", () => {
search: "",
telemetry: new Set(),
target: new Set(["library"]),
+ semantic: [],
+ features: [],
};
renderCard(instrumentation, activeFilters);
@@ -198,6 +202,8 @@ describe("InstrumentationCard", () => {
search: "",
telemetry: new Set(["spans"]),
target: new Set(),
+ semantic: [],
+ features: [],
};
renderCard(instrumentation, activeFilters);
@@ -228,6 +234,8 @@ describe("InstrumentationCard", () => {
search: "",
telemetry: new Set(["metrics"]),
target: new Set(),
+ semantic: [],
+ features: [],
};
renderCard(instrumentation, activeFilters);
diff --git a/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.tsx b/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.tsx
index fc58eae4..4d78fa47 100644
--- a/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.tsx
+++ b/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.tsx
@@ -18,8 +18,13 @@ import type { InstrumentationData } from "@/types/javaagent";
import type { FilterState } from "./instrumentation-filter-bar";
import { getBadgeInfo } from "../utils/badge-info";
import { TargetBadges, TelemetryBadges } from "./instrumentation-badges";
-import { getInstrumentationDisplayName } from "../utils/format";
+import {
+ getInstrumentationDisplayName,
+ getSemanticConventionInfo,
+ getFeatureInfo,
+} from "../utils/format";
import { renderWithInlineCode } from "@/lib/render-inline-code";
+import { GlowBadge } from "@/components/ui/glow-badge";
interface InstrumentationCardProps {
instrumentation: InstrumentationData;
@@ -60,6 +65,7 @@ export function InstrumentationCard({
{displayName}
+
@@ -69,8 +75,49 @@ export function InstrumentationCard({
)}
-
-
+
+ {instrumentation.semantic_conventions &&
+ instrumentation.semantic_conventions.length > 0 && (
+
+ {instrumentation.semantic_conventions.map((s) => {
+ const isActive =
+ !activeFilters ||
+ activeFilters.semantic.length === 0 ||
+ activeFilters.semantic.includes(s);
+ const info = getSemanticConventionInfo(s);
+ return (
+
+ {info?.label ?? s}
+
+ );
+ })}
+
+ )}
+
+ {instrumentation.features && instrumentation.features.length > 0 && (
+
+ {instrumentation.features.map((f) => {
+ const isActive =
+ !activeFilters ||
+ activeFilters.features.length === 0 ||
+ activeFilters.features.includes(f);
+ const info = getFeatureInfo(f);
+ return (
+
+ {info?.label ?? f}
+
+ );
+ })}
+
+ )}
diff --git a/ecosystem-explorer/src/features/java-agent/components/instrumentation-filter-bar.test.tsx b/ecosystem-explorer/src/features/java-agent/components/instrumentation-filter-bar.test.tsx
index 5614a89c..3769ac36 100644
--- a/ecosystem-explorer/src/features/java-agent/components/instrumentation-filter-bar.test.tsx
+++ b/ecosystem-explorer/src/features/java-agent/components/instrumentation-filter-bar.test.tsx
@@ -24,18 +24,32 @@ describe("InstrumentationFilterBar", () => {
search: "",
telemetry: new Set(),
target: new Set(),
+ semantic: [],
+ features: [],
};
it("renders search input with correct placeholder", () => {
const onFiltersChange = vi.fn();
- render();
+ render(
+
+ );
expect(screen.getByPlaceholderText("Search instrumentations...")).toBeInTheDocument();
});
it("calls onFiltersChange when search input changes", () => {
const onFiltersChange = vi.fn();
- render();
+ render(
+
+ );
const input = screen.getByPlaceholderText("Search instrumentations...");
fireEvent.change(input, { target: { value: "http" } });
@@ -49,7 +63,13 @@ describe("InstrumentationFilterBar", () => {
it("toggles spans telemetry filter on button click", async () => {
const user = userEvent.setup();
const onFiltersChange = vi.fn();
- render();
+ render(
+
+ );
const spansButton = screen.getByRole("button", { name: "Spans" });
await user.click(spansButton);
@@ -63,7 +83,13 @@ describe("InstrumentationFilterBar", () => {
it("toggles metrics telemetry filter on button click", async () => {
const user = userEvent.setup();
const onFiltersChange = vi.fn();
- render();
+ render(
+
+ );
const metricsButton = screen.getByRole("button", { name: "Metrics" });
await user.click(metricsButton);
@@ -82,7 +108,13 @@ describe("InstrumentationFilterBar", () => {
telemetry: new Set(["spans"]),
};
- render();
+ render(
+
+ );
const spansButton = screen.getByRole("button", { name: "Spans" });
await user.click(spansButton);
@@ -96,7 +128,13 @@ describe("InstrumentationFilterBar", () => {
it("toggles java agent target filter on button click", async () => {
const user = userEvent.setup();
const onFiltersChange = vi.fn();
- render();
+ render(
+
+ );
const javaAgentButton = screen.getByRole("button", { name: "Java Agent" });
await user.click(javaAgentButton);
@@ -110,7 +148,13 @@ describe("InstrumentationFilterBar", () => {
it("toggles standalone target filter on button click", async () => {
const user = userEvent.setup();
const onFiltersChange = vi.fn();
- render();
+ render(
+
+ );
const standaloneButton = screen.getByRole("button", { name: "Standalone" });
await user.click(standaloneButton);
@@ -126,9 +170,17 @@ describe("InstrumentationFilterBar", () => {
search: "",
telemetry: new Set(["spans", "metrics"]),
target: new Set(["javaagent"]),
+ semantic: [],
+ features: [],
};
- render();
+ render(
+
+ );
const spansButton = screen.getByRole("button", { name: "Spans" });
const metricsButton = screen.getByRole("button", { name: "Metrics" });
@@ -146,9 +198,17 @@ describe("InstrumentationFilterBar", () => {
search: "",
telemetry: new Set(["spans"]),
target: new Set(["library"]),
+ semantic: [],
+ features: [],
};
- render();
+ render(
+
+ );
const spansButton = screen.getByRole("button", { name: "Spans" });
const metricsButton = screen.getByRole("button", { name: "Metrics" });
diff --git a/ecosystem-explorer/src/features/java-agent/components/instrumentation-filter-bar.tsx b/ecosystem-explorer/src/features/java-agent/components/instrumentation-filter-bar.tsx
index e52dab52..0cc1d5be 100644
--- a/ecosystem-explorer/src/features/java-agent/components/instrumentation-filter-bar.tsx
+++ b/ecosystem-explorer/src/features/java-agent/components/instrumentation-filter-bar.tsx
@@ -13,23 +13,33 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { useMemo } from "react";
import { getTelemetryFilterClasses, getTargetFilterClasses } from "../styles/filter-styles";
import { Tooltip } from "@/components/ui/tooltip";
+import { SearchableMultiSelect, SelectedChips } from "@/components/ui/searchable-multi-select";
+
+import { getSemanticConventionInfo, getFeatureInfo } from "../utils/format";
+
+import type { InstrumentationData } from "@/types/javaagent";
export interface FilterState {
search: string;
telemetry: Set<"spans" | "metrics">;
target: Set<"javaagent" | "library">;
+ semantic: string[];
+ features: string[];
}
interface InstrumentationFilterBarProps {
filters: FilterState;
onFiltersChange: (filters: FilterState) => void;
+ instrumentations: InstrumentationData[];
}
export function InstrumentationFilterBar({
filters,
onFiltersChange,
+ instrumentations,
}: InstrumentationFilterBarProps) {
const toggleTelemetry = (type: "spans" | "metrics") => {
const newTelemetry = new Set(filters.telemetry);
@@ -51,43 +61,81 @@ export function InstrumentationFilterBar({
onFiltersChange({ ...filters, target: newTarget });
};
+ const semanticOptions = useMemo(() => {
+ const options = new Set();
+ instrumentations.forEach((instr) => {
+ instr.semantic_conventions?.forEach((s) => options.add(s));
+ });
+ return Array.from(options).sort();
+ }, [instrumentations]);
+
+ const featureOptions = useMemo(() => {
+ const options = new Set();
+ instrumentations.forEach((instr) => {
+ instr.features?.forEach((f) => options.add(f));
+ });
+ return Array.from(options).sort();
+ }, [instrumentations]);
+
return (
-
+
{/* Ambient radial gradient background */}
-
-
-
+
-
-
-
-
-
onFiltersChange({ ...filters, search: e.target.value })}
- className="border-border/60 bg-background/80 placeholder:text-muted-foreground/50 focus:border-primary/50 focus:ring-primary/20 w-full rounded-lg border px-4 py-2.5 text-sm backdrop-blur-sm transition-all duration-200 focus:ring-2 focus:outline-none"
- />
+
+
+
+
+
+ onFiltersChange({ ...filters, search: e.target.value })}
+ className="border-border/60 bg-background/80 placeholder:text-muted-foreground/50 focus:border-primary/50 focus:ring-primary/20 flex h-[42px] w-full rounded-lg border px-4 py-2 text-sm backdrop-blur-sm transition-all duration-200 focus:ring-2 focus:outline-none"
+ />
+
+
+
onFiltersChange({ ...filters, semantic: selected })}
+ renderOption={(s) => getSemanticConventionInfo(s)?.label ?? s}
+ />
+
+ onFiltersChange({ ...filters, features: selected })}
+ renderOption={(f) => getFeatureInfo(f)?.label ?? f}
+ />
@@ -147,6 +195,29 @@ export function InstrumentationFilterBar({
+
+
+
+ onFiltersChange({
+ ...filters,
+ semantic: filters.semantic.filter((s) => s !== item),
+ })
+ }
+ renderItem={(s) => getSemanticConventionInfo(s)?.label ?? s}
+ />
+
+ onFiltersChange({
+ ...filters,
+ features: filters.features.filter((f) => f !== item),
+ })
+ }
+ renderItem={(f) => getFeatureInfo(f)?.label ?? f}
+ />
+
{/* Corner accent */}
diff --git a/ecosystem-explorer/src/features/java-agent/components/instrumentation-group-card.test.tsx b/ecosystem-explorer/src/features/java-agent/components/instrumentation-group-card.test.tsx
index c2874c01..410991e1 100644
--- a/ecosystem-explorer/src/features/java-agent/components/instrumentation-group-card.test.tsx
+++ b/ecosystem-explorer/src/features/java-agent/components/instrumentation-group-card.test.tsx
@@ -37,6 +37,8 @@ const defaultFilters: FilterState = {
search: "",
telemetry: new Set(),
target: new Set(),
+ semantic: [],
+ features: [],
};
describe("InstrumentationGroupCard", () => {
diff --git a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx
index 26ab8eec..ed4ce011 100644
--- a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx
+++ b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx
@@ -55,6 +55,8 @@ describe("JavaInstrumentationListPage - Filtering", () => {
has_javaagent: true,
javaagent_target_versions: ["1.0.0"],
telemetry: [{ when: "always", spans: [{ span_kind: "CLIENT" }] }],
+ semantic_conventions: ["http"],
+ features: ["stable"],
},
{
name: "jdbc",
@@ -76,6 +78,7 @@ describe("JavaInstrumentationListPage - Filtering", () => {
],
},
],
+ semantic_conventions: ["db"],
},
{
name: "kafka-client",
@@ -100,6 +103,8 @@ describe("JavaInstrumentationListPage - Filtering", () => {
],
},
],
+ semantic_conventions: ["messaging"],
+ features: ["stable"],
},
{
name: "spring-web",
@@ -108,6 +113,7 @@ describe("JavaInstrumentationListPage - Filtering", () => {
scope: { name: "spring" },
has_javaagent: true,
javaagent_target_versions: ["1.0.0"],
+ features: ["experimental"],
},
];
@@ -355,4 +361,62 @@ describe("JavaInstrumentationListPage - Filtering", () => {
expect(screen.getByText("Error loading instrumentations")).toBeInTheDocument();
expect(screen.getByText("Failed to load instrumentations")).toBeInTheDocument();
});
+
+ it("filters by semantic conventions (OR logic)", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await waitFor(() => expect(screen.getByText("HTTP Client")).toBeInTheDocument());
+
+ const semanticButton = screen.getByRole("button", { name: /Semantic Conventions/i });
+ await user.click(semanticButton);
+
+ const httpOption = screen.getByRole("option", { name: "http" });
+ await user.click(httpOption);
+
+ expect(screen.getByText("HTTP Client")).toBeInTheDocument();
+ expect(screen.queryByText("JDBC")).not.toBeInTheDocument();
+
+ const dbOption = screen.getByRole("option", { name: "db" });
+ await user.click(dbOption);
+
+ expect(screen.getByText("HTTP Client")).toBeInTheDocument();
+ expect(screen.getByText("JDBC")).toBeInTheDocument();
+ expect(screen.queryByText("Kafka Client")).not.toBeInTheDocument();
+ });
+
+ it("filters by features (OR logic)", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await waitFor(() => expect(screen.getByText("HTTP Client")).toBeInTheDocument());
+
+ const featuresButton = screen.getByRole("button", { name: /Features/i });
+ await user.click(featuresButton);
+
+ const experimentalOption = screen.getByRole("option", { name: "experimental" });
+ await user.click(experimentalOption);
+
+ expect(screen.getByText("Spring Web")).toBeInTheDocument();
+ expect(screen.queryByText("HTTP Client")).not.toBeInTheDocument();
+ });
+
+ it("combines filters from different categories (AND logic)", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await waitFor(() => expect(screen.getByText("HTTP Client")).toBeInTheDocument());
+
+ const semanticButton = screen.getByRole("button", { name: /Semantic Conventions/i });
+ await user.click(semanticButton);
+ await user.click(screen.getByRole("option", { name: "http" }));
+
+ const featuresButton = screen.getByRole("button", { name: /Features/i });
+ await user.click(featuresButton);
+ await user.click(screen.getByRole("option", { name: "stable" }));
+
+ expect(screen.getByText("HTTP Client")).toBeInTheDocument();
+ expect(screen.queryByText("Kafka Client")).not.toBeInTheDocument();
+ expect(screen.queryByText("JDBC")).not.toBeInTheDocument();
+ });
});
diff --git a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx
index dc090da2..4463f275 100644
--- a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx
+++ b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx
@@ -56,6 +56,8 @@ export function JavaInstrumentationListPage() {
search: "",
telemetry: new Set(),
target: new Set(),
+ semantic: [],
+ features: [],
});
const filteredInstrumentations = useMemo(() => {
@@ -101,6 +103,18 @@ export function JavaInstrumentationListPage() {
}
}
+ if (filters.semantic.length > 0) {
+ const hasMatch = filters.semantic.some((s) => instr.semantic_conventions?.includes(s));
+ if (!hasMatch) return false;
+ }
+
+ if (filters.features.length > 0) {
+ const hasMatch = filters.features.some((f) => {
+ return instr.features?.includes(f);
+ });
+ if (!hasMatch) return false;
+ }
+
return true;
});
}, [instrumentations, filters]);
@@ -200,7 +214,11 @@ export function JavaInstrumentationListPage() {
)}
-
+
diff --git a/ecosystem-explorer/src/features/java-agent/styles/filter-styles.ts b/ecosystem-explorer/src/features/java-agent/styles/filter-styles.ts
index e339dc7d..7a13fda4 100644
--- a/ecosystem-explorer/src/features/java-agent/styles/filter-styles.ts
+++ b/ecosystem-explorer/src/features/java-agent/styles/filter-styles.ts
@@ -16,36 +16,40 @@
export const FILTER_STYLES = {
telemetry: {
spans: {
- active: "bg-blue-500/30 border-blue-500 text-blue-700 dark:text-blue-300 shadow-sm",
- inactive: "bg-blue-500/10 border-transparent text-blue-600 dark:text-blue-400",
- hover: "hover:border-blue-500/50 hover:bg-blue-500/5",
+ active:
+ "bg-blue-500/40 border-blue-400 text-blue-100 shadow-[0_0_15px_rgba(59,130,246,0.3)] ring-1 ring-blue-400/50",
+ inactive:
+ "bg-blue-500/5 border-blue-500/20 text-blue-400/70 hover:border-blue-500/50 hover:bg-blue-500/10",
},
metrics: {
- active: "bg-green-500/30 border-green-500 text-green-700 dark:text-green-300 shadow-sm",
- inactive: "bg-green-500/10 border-transparent text-green-600 dark:text-green-400",
- hover: "hover:border-green-500/50 hover:bg-green-500/5",
+ active:
+ "bg-green-500/40 border-green-400 text-green-100 shadow-[0_0_15px_rgba(34,197,94,0.3)] ring-1 ring-green-400/50",
+ inactive:
+ "bg-green-500/5 border-green-500/20 text-green-400/70 hover:border-green-500/50 hover:bg-green-500/10",
},
},
target: {
javaagent: {
- active: "bg-orange-500/30 border-orange-500 text-orange-700 dark:text-orange-300 shadow-sm",
- inactive: "bg-orange-500/10 border-transparent text-orange-600 dark:text-orange-400",
- hover: "hover:border-orange-500/50 hover:bg-orange-500/5",
+ active:
+ "bg-orange-500/40 border-orange-400 text-orange-100 shadow-[0_0_15px_rgba(249,115,22,0.3)] ring-1 ring-orange-400/50",
+ inactive:
+ "bg-orange-500/5 border-orange-500/20 text-orange-400/70 hover:border-orange-500/50 hover:bg-orange-500/10",
},
library: {
- active: "bg-purple-500/30 border-purple-500 text-purple-700 dark:text-purple-300 shadow-sm",
- inactive: "bg-purple-500/10 border-transparent text-purple-600 dark:text-purple-400",
- hover: "hover:border-purple-500/50 hover:bg-purple-500/5",
+ active:
+ "bg-purple-500/40 border-purple-400 text-purple-100 shadow-[0_0_15px_rgba(168,85,247,0.3)] ring-1 ring-purple-400/50",
+ inactive:
+ "bg-purple-500/5 border-purple-500/20 text-purple-400/70 hover:border-purple-500/50 hover:bg-purple-500/10",
},
},
} as const;
export function getTelemetryFilterClasses(type: "spans" | "metrics", isActive: boolean): string {
const styles = FILTER_STYLES.telemetry[type];
- return isActive ? styles.active : `${styles.inactive} ${styles.hover}`;
+ return isActive ? styles.active : styles.inactive;
}
export function getTargetFilterClasses(type: "javaagent" | "library", isActive: boolean): string {
const styles = FILTER_STYLES.target[type];
- return isActive ? styles.active : `${styles.inactive} ${styles.hover}`;
+ return isActive ? styles.active : styles.inactive;
}
diff --git a/ecosystem-explorer/src/index.css b/ecosystem-explorer/src/index.css
index 13c74895..6c986906 100644
--- a/ecosystem-explorer/src/index.css
+++ b/ecosystem-explorer/src/index.css
@@ -101,3 +101,28 @@ html {
.y-plain {
color: hsl(var(--foreground-hsl));
}
+.custom-scrollbar::-webkit-scrollbar {
+ width: 8px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: hsl(var(--border-hsl) / 0.8);
+ border-radius: 10px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: hsl(var(--primary-hsl) / 0.6);
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+
+.custom-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: hsl(var(--border-hsl) / 0.8) transparent;
+}
diff --git a/ecosystem-explorer/src/test/setup.ts b/ecosystem-explorer/src/test/setup.ts
index 2a98f066..e45cd17b 100644
--- a/ecosystem-explorer/src/test/setup.ts
+++ b/ecosystem-explorer/src/test/setup.ts
@@ -13,4 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import { vi } from "vitest";
import "@testing-library/jest-dom";
+
+class ResizeObserverMock {
+ constructor() {}
+
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
+HTMLElement.prototype.scrollIntoView = vi.fn();
+HTMLElement.prototype.hasPointerCapture = vi.fn();
+HTMLElement.prototype.releasePointerCapture = vi.fn();