diff --git a/ecosystem-explorer/src/App.tsx b/ecosystem-explorer/src/App.tsx
index 47e25505..96ac6e8d 100644
--- a/ecosystem-explorer/src/App.tsx
+++ b/ecosystem-explorer/src/App.tsx
@@ -13,10 +13,61 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { BrowserRouter } from "react-router-dom";
-import { LegacyApp } from "@/LegacyApp";
-import { V1App } from "@/v1";
+import { lazy, Suspense } from "react";
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+import { Header } from "@/components/layout/header";
+import { Footer } from "@/components/layout/footer";
import { isEnabled } from "@/lib/feature-flags";
+import { ErrorBoundary } from "@/components/ui/error-boundary";
+
+const HomePage = lazy(() =>
+ import("@/features/home/home-page").then((m) => ({ default: m.HomePage }))
+);
+const JavaAgentPage = lazy(() =>
+ import("@/features/java-agent/java-agent-page").then((m) => ({ default: m.JavaAgentPage }))
+);
+const CollectorPage = lazy(() => import("@/features/collector/collector-page"));
+const CollectorComponentsPage = lazy(() =>
+ import("@/features/collector/collector-components-page").then((m) => ({
+ default: m.CollectorComponentsPage,
+ }))
+);
+const CollectorDetailPage = lazy(() =>
+ import("@/features/collector/collector-detail-page").then((m) => ({
+ default: m.CollectorDetailPage,
+ }))
+);
+const NotFoundPage = lazy(() =>
+ import("@/features/not-found/not-found-page").then((m) => ({ default: m.NotFoundPage }))
+);
+const JavaInstrumentationListPage = lazy(() =>
+ import("@/features/java-agent/java-instrumentation-list-page").then((m) => ({
+ default: m.JavaInstrumentationListPage,
+ }))
+);
+const JavaConfigurationListPage = lazy(() =>
+ import("@/features/java-agent/java-configuration-list-page").then((m) => ({
+ default: m.JavaConfigurationListPage,
+ }))
+);
+const JavaReleaseComparisonPage = lazy(() =>
+ import("@/features/java-agent/java-release-comparison-page").then((m) => ({
+ default: m.JavaReleaseComparisonPage,
+ }))
+);
+const InstrumentationDetailPage = lazy(() =>
+ import("@/features/java-agent/instrumentation-detail-page").then((m) => ({
+ default: m.InstrumentationDetailPage,
+ }))
+);
+const ConfigurationBuilderPage = lazy(() =>
+ import("@/features/java-agent/configuration/configuration-builder-page").then((m) => ({
+ default: m.ConfigurationBuilderPage,
+ }))
+);
+const AboutPage = lazy(() =>
+ import("@/features/about/about-page").then((m) => ({ default: m.AboutPage }))
+);
/*
* Single V1_REDESIGN boundary read. See
@@ -25,5 +76,70 @@ import { isEnabled } from "@/lib/feature-flags";
* bundle selection is driven by netlify.toml's `feat/84-*` branch pattern.
*/
export default function App() {
- return {isEnabled("V1_REDESIGN") ? : };
+ return (
+
+
+ }
+ >
+
+ } />
+ } />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ {isEnabled("JAVA_RELEASE_COMPARISON") && (
+ }
+ />
+ )}
+ }
+ />
+ } />
+ } />
+ }
+ />
+ }
+ />
+ } />
+ } />
+
+
+
+
+
+
+
+ );
}
diff --git a/ecosystem-explorer/src/features/collector/collector-components-page.tsx b/ecosystem-explorer/src/features/collector/collector-components-page.tsx
new file mode 100644
index 00000000..07dca774
--- /dev/null
+++ b/ecosystem-explorer/src/features/collector/collector-components-page.tsx
@@ -0,0 +1,408 @@
+/*
+ * 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 { useMemo, useState } from "react";
+import { useParams, useNavigate, Link, useSearchParams } from "react-router-dom";
+import {
+ Search,
+ Loader2,
+ ChevronRight,
+ Box,
+ Layers,
+ Send,
+ Plug,
+ Workflow,
+ ChevronDown,
+ AlertCircle,
+} from "lucide-react";
+
+import { PageContainer } from "@/components/layout/page-container";
+import { GlowBadge } from "@/components/ui/glow-badge";
+import { DetailCard } from "@/components/ui/detail-card";
+import { useCollectorVersions, useCollectorComponents } from "@/hooks/use-collector-data";
+
+type ComponentTypeFilter =
+ | "all"
+ | "receiver"
+ | "processor"
+ | "exporter"
+ | "extension"
+ | "connector";
+type DistributionFilter = "all" | "core" | "contrib";
+
+function getTypeFilter(value: string | null): ComponentTypeFilter {
+ switch (value) {
+ case "receiver":
+ case "processor":
+ case "exporter":
+ case "extension":
+ case "connector":
+ return value;
+ default:
+ return "all";
+ }
+}
+
+function getDistributionFilter(value: string | null): DistributionFilter {
+ switch (value) {
+ case "core":
+ case "contrib":
+ return value;
+ default:
+ return "all";
+ }
+}
+
+const getIcon = (type: string) => {
+ switch (type) {
+ case "receiver":
+ return ;
+ case "processor":
+ return ;
+ case "exporter":
+ return ;
+ case "extension":
+ return ;
+ case "connector":
+ return ;
+ default:
+ return ;
+ }
+};
+
+function CollectorComponentsContent({ urlVersion }: { urlVersion?: string }) {
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const typeQuery = searchParams.get("type");
+ const distributionQuery = searchParams.get("distribution");
+ const [searchQuery, setSearchQuery] = useState("");
+
+ const typeFilter = useMemo(
+ () => getTypeFilter(typeQuery),
+ [typeQuery]
+ );
+
+ const distributionFilter = useMemo(
+ () => getDistributionFilter(distributionQuery),
+ [distributionQuery]
+ );
+
+ const {
+ data: versionData,
+ loading: versionsLoading,
+ error: versionsError,
+ } = useCollectorVersions();
+
+ const currentVersion = useMemo(() => {
+ if (urlVersion) return urlVersion;
+ return versionData?.versions.find((v) => v.is_latest)?.version || "";
+ }, [urlVersion, versionData]);
+
+ const {
+ data: components,
+ loading: componentsLoading,
+ error: componentsError,
+ } = useCollectorComponents(currentVersion);
+
+ const filteredComponents = useMemo(() => {
+ if (!components) return [];
+
+ return components.filter((comp) => {
+ const matchesSearch =
+ comp.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ comp.display_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ comp.description?.toLowerCase().includes(searchQuery.toLowerCase());
+ const matchesType = typeFilter === "all" || comp.type === typeFilter;
+ const matchesDistribution =
+ distributionFilter === "all" || comp.distribution === distributionFilter;
+ return matchesSearch && matchesType && matchesDistribution;
+ });
+ }, [components, distributionFilter, searchQuery, typeFilter]);
+
+ const handleVersionChange = (val: string) => {
+ navigate(`/collector/components/${val}`);
+ };
+
+ const handleTypeFilterChange = (newType: string) => {
+ const params = new URLSearchParams(searchParams);
+ if (newType === "all") {
+ params.delete("type");
+ } else {
+ params.set("type", newType);
+ }
+ setSearchParams(params);
+ };
+
+ const handleDistributionFilterChange = (newDistribution: string) => {
+ const params = new URLSearchParams(searchParams);
+ if (newDistribution === "all") {
+ params.delete("distribution");
+ } else {
+ params.set("distribution", newDistribution);
+ }
+ setSearchParams(params);
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {componentsError || versionsError ? (
+
+
+
Error loading data
+
Please try refreshing the page.
+
+ ) : componentsLoading || versionsLoading || !currentVersion ? (
+
+
+
+
+
Loading components...
+
+ ) : (
+
+
+
+ Showing {filteredComponents.length}{" "}
+ components
+
+
+
+
+ {filteredComponents.length > 0 ? (
+ filteredComponents.map((comp) => (
+
+
+
+
+
+
+ {getIcon(comp.type)}
+
+
+
+ {comp.type}
+
+
+
+
+
+
+
+
+ {comp.display_name || comp.name}
+
+
+
+ {comp.name}
+
+
+ {comp.distribution}
+
+
+
+
+
+ {comp.description ||
+ "Browse technical details and configuration options for this component."}
+
+
+
+ {comp.status?.stability &&
+ Object.keys(comp.status.stability).length > 0 && (
+
+ {Object.keys(comp.status.stability)[0]}
+
+ )}
+
+
+
+
+ ))
+ ) : (
+
+
+
+
+
No components found
+
+ We couldn't find any components matching your search criteria.
+
+
+
+ )}
+
+
+ )}
+ >
+ );
+}
+
+export function CollectorComponentsPage() {
+ const { version: urlVersion } = useParams<{ version: string }>();
+
+ return (
+
+
+
+
+ Collector{" "}
+
+ Components
+
+
+
+ Navigate the OpenTelemetry Collector ecosystem. Discover receivers, processors,
+ exporters, and extensions across different distributions.
+
+
+
+
+
+
+ );
+}
+
+export default CollectorComponentsPage;
diff --git a/ecosystem-explorer/src/features/collector/collector-page.test.tsx b/ecosystem-explorer/src/features/collector/collector-page.test.tsx
index 99d537eb..fb6f7273 100644
--- a/ecosystem-explorer/src/features/collector/collector-page.test.tsx
+++ b/ecosystem-explorer/src/features/collector/collector-page.test.tsx
@@ -14,210 +14,24 @@
* limitations under the License.
*/
import { render, screen } from "@testing-library/react";
-import { MemoryRouter, Route, Routes } from "react-router-dom";
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { MemoryRouter } from "react-router-dom";
+import { describe, it, expect, vi } from "vitest";
import { CollectorPage } from "@/features/collector/collector-page.tsx";
-vi.mock("@/hooks/use-collector-data", () => ({
- useCollectorVersions: vi.fn(),
- useCollectorComponents: vi.fn(),
+vi.mock("@/features/collector/components/collector-explore-landing.tsx", () => ({
+ CollectorExploreLanding: () => Collector explore landing
,
}));
-vi.mock("@/lib/feature-flags", () => ({
- isEnabled: vi.fn(() => true),
-}));
-
-import { useCollectorVersions, useCollectorComponents } from "@/hooks/use-collector-data";
-import type { CollectorComponent } from "@/types/collector";
-
-const mockVersionsData = {
- versions: [
- { version: "0.100.0", is_latest: true },
- { version: "0.99.0", is_latest: false },
- ],
-};
-
-const mockComponents: CollectorComponent[] = [
- {
- id: "receiver-otlp",
- name: "otlpreceiver",
- display_name: "OTLP Receiver",
- description: "Receives data via OTLP.",
- ecosystem: "collector",
- type: "receiver",
- distribution: "core",
- status: {
- class: "receiver",
- stability: { stable: ["traces", "metrics", "logs"] },
- distributions: ["core"],
- },
- },
- {
- id: "processor-batch",
- name: "batchprocessor",
- display_name: "Batch Processor",
- description: "Batches telemetry data.",
- ecosystem: "collector",
- type: "processor",
- distribution: "core",
- status: {
- class: "processor",
- stability: { stable: ["traces", "metrics", "logs"] },
- distributions: ["core"],
- },
- },
-];
-
-function renderAtRoute(path: string) {
- return render(
-
-
- } />
- } />
- } />
-
-
- );
-}
-
describe("CollectorPage", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
it("renders the page title", () => {
- vi.mocked(useCollectorVersions).mockReturnValue({
- data: mockVersionsData,
- loading: false,
- error: null,
- });
- vi.mocked(useCollectorComponents).mockReturnValue({
- data: mockComponents,
- loading: false,
- error: null,
- });
-
- renderAtRoute("/collector/components");
+ render(
+
+
+
+ );
const heading = screen.getByRole("heading", { level: 1 });
- expect(heading).toHaveTextContent(/Collector/);
- expect(heading).toHaveTextContent(/Components/);
- });
-
- it("shows loading state while versions are loading", () => {
- vi.mocked(useCollectorVersions).mockReturnValue({
- data: null,
- loading: true,
- error: null,
- });
- vi.mocked(useCollectorComponents).mockReturnValue({
- data: null,
- loading: true,
- error: null,
- });
-
- renderAtRoute("/collector/components");
-
- expect(screen.getByText("Loading components...")).toBeInTheDocument();
- });
-
- it("shows error state when versions fail to load", () => {
- vi.mocked(useCollectorVersions).mockReturnValue({
- data: null,
- loading: false,
- error: new Error("Network error"),
- });
- vi.mocked(useCollectorComponents).mockReturnValue({
- data: null,
- loading: false,
- error: null,
- });
-
- renderAtRoute("/collector/components");
-
- expect(screen.getByRole("heading", { name: "Error loading data" })).toBeInTheDocument();
- expect(screen.getByText("Please try refreshing the page.")).toBeInTheDocument();
- });
-
- it("shows error state when components fail to load", () => {
- vi.mocked(useCollectorVersions).mockReturnValue({
- data: mockVersionsData,
- loading: false,
- error: null,
- });
- vi.mocked(useCollectorComponents).mockReturnValue({
- data: null,
- loading: false,
- error: new Error("Failed to load collector-manifest-0.100.0: 404 Not Found"),
- });
-
- renderAtRoute("/collector/components");
-
- expect(screen.getByRole("heading", { name: "Error loading data" })).toBeInTheDocument();
- expect(screen.getByText("Please try refreshing the page.")).toBeInTheDocument();
- });
-
- it("shows error state for an invalid version route instead of a misleading empty list", () => {
- // Regression guard: /collector/components/9.9.9 must show an error,
- // not "Showing 0 components" which implies a valid but empty filter result.
- vi.mocked(useCollectorVersions).mockReturnValue({
- data: mockVersionsData,
- loading: false,
- error: null,
- });
- vi.mocked(useCollectorComponents).mockReturnValue({
- data: null,
- loading: false,
- error: new Error("Failed to load collector-manifest-9.9.9: 404 Not Found"),
- });
-
- renderAtRoute("/collector/components/9.9.9");
-
- expect(useCollectorComponents).toHaveBeenCalledWith("9.9.9");
- expect(screen.getByRole("heading", { name: "Error loading data" })).toBeInTheDocument();
- expect(
- screen.queryByText(
- (_content, element) =>
- element?.textContent?.replace(/\s+/g, " ").trim() === "Showing 0 components"
- )
- ).not.toBeInTheDocument();
- expect(screen.queryByText("No components found")).not.toBeInTheDocument();
- });
-
- it("renders component cards when data loads successfully", () => {
- vi.mocked(useCollectorVersions).mockReturnValue({
- data: mockVersionsData,
- loading: false,
- error: null,
- });
- vi.mocked(useCollectorComponents).mockReturnValue({
- data: mockComponents,
- loading: false,
- error: null,
- });
-
- renderAtRoute("/collector/components");
-
- expect(screen.getByText("OTLP Receiver")).toBeInTheDocument();
- expect(screen.getByText("Batch Processor")).toBeInTheDocument();
- expect(screen.getByText(/Showing/)).toHaveTextContent("Showing 2 components");
- });
-
- it("renders version selector with available versions", () => {
- vi.mocked(useCollectorVersions).mockReturnValue({
- data: mockVersionsData,
- loading: false,
- error: null,
- });
- vi.mocked(useCollectorComponents).mockReturnValue({
- data: mockComponents,
- loading: false,
- error: null,
- });
-
- renderAtRoute("/collector/components");
-
- expect(screen.getByRole("option", { name: /0\.100\.0/ })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: /0\.99\.0/ })).toBeInTheDocument();
+ expect(heading).toHaveTextContent("OpenTelemetry Collector");
+ expect(screen.getByText("Collector explore landing")).toBeInTheDocument();
});
});
diff --git a/ecosystem-explorer/src/features/collector/collector-page.tsx b/ecosystem-explorer/src/features/collector/collector-page.tsx
index 6200af82..4ba36615 100644
--- a/ecosystem-explorer/src/features/collector/collector-page.tsx
+++ b/ecosystem-explorer/src/features/collector/collector-page.tsx
@@ -13,339 +13,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {
- Box,
- ChevronDown,
- ChevronRight,
- Layers,
- Loader2,
- Plug,
- Search,
- Send,
- Workflow,
-} from "lucide-react";
-import { useMemo, useState } from "react";
-import { Link, useSearchParams, useParams } from "react-router-dom";
-
import { PageContainer } from "@/components/layout/page-container";
import { BackButton } from "@/components/ui/back-button";
-import { GlowBadge } from "@/components/ui/glow-badge";
-import { DetailCard } from "@/components/ui/detail-card";
-import { useCollectorComponents, useCollectorVersions } from "@/hooks/use-collector-data";
-import { isEnabled } from "@/lib/feature-flags";
-
-const getIcon = (type: string) => {
- switch (type) {
- case "receiver":
- return ;
- case "processor":
- return ;
- case "exporter":
- return ;
- case "extension":
- return ;
- case "connector":
- return ;
- default:
- return ;
- }
-};
-
-function CollectorPageInner() {
- const [searchParams, setSearchParams] = useSearchParams();
- const { version: versionParam } = useParams();
- const [searchQuery, setSearchQuery] = useState("");
- const [typeFilter, setTypeFilter] = useState("all");
-
- const {
- data: versionData,
- loading: versionsLoading,
- error: versionsError,
- } = useCollectorVersions();
-
- const currentVersion = useMemo(() => {
- // Priority: URL param > search param > latest version
- if (versionParam) return versionParam;
- const urlVersion = searchParams.get("version");
- if (urlVersion) return urlVersion;
- return versionData?.versions.find((v) => v.is_latest)?.version || "";
- }, [versionParam, searchParams, versionData]);
-
- const {
- data: components,
- loading: componentsLoading,
- error: componentsError,
- } = useCollectorComponents(currentVersion);
-
- // Check for errors
- const hasError = versionsError || componentsError;
-
- const filteredComponents = useMemo(() => {
- if (!components) return [];
-
- return components.filter((comp) => {
- const matchesSearch =
- comp.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- comp.display_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
- comp.description?.toLowerCase().includes(searchQuery.toLowerCase());
- const matchesType = typeFilter === "all" || comp.type === typeFilter;
- return matchesSearch && matchesType;
- });
- }, [components, searchQuery, typeFilter]);
-
- const handleVersionChange = (val: string) => {
- const latestVersion = versionData?.versions.find((v) => v.is_latest)?.version;
- if (val === latestVersion) {
- setSearchParams({});
- } else {
- setSearchParams({ version: val });
- }
- };
- return (
- <>
-
-
-
-
-
-
-
-
- setSearchQuery(e.target.value)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {componentsLoading ? (
-
-
-
-
-
Loading components...
-
- ) : hasError ? (
-
-
-
-
-
Error loading data
-
- Please try refreshing the page.
-
-
- ) : (
-
-
-
- Showing {filteredComponents.length}{" "}
- components
-
-
-
-
- {filteredComponents.length > 0 ? (
- filteredComponents.map((comp) => {
- const latestVersion = versionData?.versions.find((v) => v.is_latest)?.version;
- const detailUrl =
- currentVersion && currentVersion !== latestVersion
- ? `/collector/components/${comp.distribution}/${comp.name}?version=${currentVersion}`
- : `/collector/components/${comp.distribution}/${comp.name}`;
-
- return (
-
-
-
-
-
-
- {getIcon(comp.type)}
-
-
-
- {comp.type}
-
-
-
-
-
-
-
-
- {comp.display_name || comp.name}
-
-
-
- {comp.name}
-
-
- {comp.distribution}
-
-
-
-
-
- {comp.description ||
- "Browse technical details and configuration options for this component."}
-
-
-
- {comp.status?.stability &&
- Object.keys(comp.status.stability).length > 0 && (
-
- {Object.keys(comp.status.stability)[0]}
-
- )}
-
-
-
-
- );
- })
- ) : (
-
-
-
-
-
No components found
-
- We couldn't find any components matching your search criteria.
-
-
-
- )}
-
-
- )}
- >
- );
-}
-
+import { CollectorExploreLanding } from "@/features/collector/components/collector-explore-landing.tsx";
export function CollectorPage() {
return (
-
-
- Collector{" "}
-
- Components
+
+
+
+ OpenTelemetry Collector
-
- Navigate the OpenTelemetry Collector ecosystem. Discover receivers, processors,
- exporters, and extensions across different distributions.
+
+ A vendor-agnostic implementation for receiving, processing, and exporting telemetry
+ data.
-
-
- {!isEnabled("COLLECTOR_PAGE") ? (
-
-
-
-
-
Coming Soon
-
- We're currently building the Collector component explorer. Stay tuned for a
- comprehensive view of receivers, processors, and more!
-
-
- ) : (
-
- )}
+
+
);
}
+
+export default CollectorPage;
diff --git a/ecosystem-explorer/src/features/collector/components/collector-explore-landing.test.tsx b/ecosystem-explorer/src/features/collector/components/collector-explore-landing.test.tsx
new file mode 100644
index 00000000..b4247f36
--- /dev/null
+++ b/ecosystem-explorer/src/features/collector/components/collector-explore-landing.test.tsx
@@ -0,0 +1,152 @@
+/*
+ * 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 { render, screen, waitFor, within } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { CollectorExploreLanding } from "./collector-explore-landing";
+import { loadVersions } from "@/lib/api/collector-data";
+
+vi.mock("@/lib/api/collector-data", () => ({
+ loadVersions: vi.fn(),
+}));
+
+const collectorIndex = {
+ components: [
+ {
+ id: "core-receiver-otlpreceiver",
+ name: "otlpreceiver",
+ display_name: "OTLP Receiver",
+ description: "Receives OTLP telemetry.",
+ distribution: "core",
+ type: "receiver",
+ stability: "stable",
+ },
+ {
+ id: "contrib-receiver-prometheusreceiver",
+ name: "prometheusreceiver",
+ display_name: "Prometheus Receiver",
+ description: "Receives Prometheus metrics.",
+ distribution: "contrib",
+ type: "receiver",
+ stability: "stable",
+ },
+ {
+ id: "core-processor-batchprocessor",
+ name: "batchprocessor",
+ display_name: "Batch Processor",
+ description: "Batches telemetry.",
+ distribution: "core",
+ type: "processor",
+ stability: "stable",
+ },
+ {
+ id: "contrib-exporter-kafkaexporter",
+ name: "kafkaexporter",
+ display_name: "Kafka Exporter",
+ description: "Exports to Kafka.",
+ distribution: "contrib",
+ type: "exporter",
+ stability: "beta",
+ },
+ {
+ id: "contrib-extension-healthcheckextension",
+ name: "healthcheckextension",
+ display_name: "Health Check Extension",
+ description: "Reports collector health.",
+ distribution: "contrib",
+ type: "extension",
+ stability: "stable",
+ },
+ ],
+};
+
+function mockCollectorIndexResponse(response: Response) {
+ vi.stubGlobal("fetch", vi.fn(async () => response));
+}
+
+describe("CollectorExploreLanding", () => {
+ beforeEach(() => {
+ vi.mocked(loadVersions).mockResolvedValue({
+ versions: [
+ { version: "0.150.0", is_latest: true },
+ { version: "0.149.0", is_latest: false },
+ ],
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it("renders counts, links, and the latest version from Collector data", async () => {
+ mockCollectorIndexResponse(
+ new Response(JSON.stringify(collectorIndex), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+ );
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Loading Collector ecosystem data...")).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Component Types" })).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Latest Collector data: v0.150.0")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /Explore Components/i })).toHaveAttribute(
+ "href",
+ "/collector/components"
+ );
+ expect(screen.getByRole("link", { name: /Receiver/i })).toHaveAttribute(
+ "href",
+ "/collector/components?type=receiver"
+ );
+ expect(screen.getByRole("link", { name: /View Core Components/i })).toHaveAttribute(
+ "href",
+ "/collector/components?distribution=core"
+ );
+
+ const stats = screen.getByLabelText("Collector summary statistics");
+ expect(within(stats).getAllByText("5")).toHaveLength(2);
+ expect(within(stats).getByText("2")).toBeInTheDocument();
+
+ expect(screen.getByRole("link", { name: /Official Documentation/i })).toHaveAttribute(
+ "href",
+ "https://opentelemetry.io/docs/collector/"
+ );
+ });
+
+ it("renders an error state when the Collector index request fails", async () => {
+ mockCollectorIndexResponse(new Response("Not found", { status: 404 }));
+
+ render(
+
+
+
+ );
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Error loading Collector data");
+ expect(screen.getByText("Collector index request failed with 404.")).toBeInTheDocument();
+ });
+});
diff --git a/ecosystem-explorer/src/features/collector/components/collector-explore-landing.tsx b/ecosystem-explorer/src/features/collector/components/collector-explore-landing.tsx
new file mode 100644
index 00000000..71097d9b
--- /dev/null
+++ b/ecosystem-explorer/src/features/collector/components/collector-explore-landing.tsx
@@ -0,0 +1,440 @@
+/*
+ * 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 { useEffect, useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+import {
+ AlertCircle,
+ ArrowRight,
+ Box,
+ ExternalLink,
+ Layers,
+ Loader2,
+ Plug,
+ Send,
+ Workflow,
+} from "lucide-react";
+
+import { loadVersions } from "@/lib/api/collector-data";
+
+const COMPONENT_TYPES = [
+ {
+ type: "receiver",
+ label: "Receiver",
+ description:
+ "Collect telemetry data from various sources including OTLP, Prometheus, Jaeger, and many others",
+ icon: Box,
+ },
+ {
+ type: "processor",
+ label: "Processor",
+ description: "Transform, filter, batch, and enrich telemetry data before export",
+ icon: Layers,
+ },
+ {
+ type: "exporter",
+ label: "Exporter",
+ description: "Send telemetry data to observability backends and storage systems",
+ icon: Send,
+ },
+ {
+ type: "extension",
+ label: "Extension",
+ description: "Provide additional capabilities like health checks, profiling, and authentication",
+ icon: Plug,
+ },
+ {
+ type: "connector",
+ label: "Connector",
+ description: "Connect multiple pipelines and enable data flow between signal types",
+ icon: Workflow,
+ },
+] as const;
+
+const DISTRIBUTIONS = [
+ {
+ distribution: "core",
+ label: "Core",
+ description:
+ "Officially maintained components with stable APIs. Minimal dependencies, production-ready.",
+ buttonLabel: "View Core Components",
+ },
+ {
+ distribution: "contrib",
+ label: "Contrib",
+ description:
+ "Community-contributed components. May have varying stability levels. Broader ecosystem coverage.",
+ buttonLabel: "View Contrib Components",
+ },
+] as const;
+
+const RESOURCES = [
+ {
+ label: "Official Documentation",
+ href: "https://opentelemetry.io/docs/collector/",
+ },
+ {
+ label: "Getting Started Guide",
+ href: "https://opentelemetry.io/docs/collector/getting-started/",
+ },
+ {
+ label: "Configuration Reference",
+ href: "https://opentelemetry.io/docs/collector/configuration/",
+ },
+ {
+ label: "GitHub (Core)",
+ href: "https://github.com/open-telemetry/opentelemetry-collector",
+ },
+ {
+ label: "GitHub (Contrib)",
+ href: "https://github.com/open-telemetry/opentelemetry-collector-contrib",
+ },
+] as const;
+
+type CollectorComponentType = (typeof COMPONENT_TYPES)[number]["type"];
+type CollectorDistribution = (typeof DISTRIBUTIONS)[number]["distribution"];
+
+interface CollectorIndexComponent {
+ id: string;
+ name: string;
+ display_name?: string | null;
+ description?: string | null;
+ distribution: CollectorDistribution;
+ type: CollectorComponentType;
+ stability?: string | null;
+}
+
+interface CollectorLandingStats {
+ byDistribution: Record;
+ byType: Record;
+ latestVersion: string | null;
+ total: number;
+}
+
+type CollectorLandingState =
+ | { status: "loading" }
+ | { status: "error"; error: Error }
+ | { status: "ready"; stats: CollectorLandingStats };
+
+function emptyTypeCounts(): Record {
+ return {
+ receiver: 0,
+ processor: 0,
+ exporter: 0,
+ extension: 0,
+ connector: 0,
+ };
+}
+
+function emptyDistributionCounts(): Record {
+ return {
+ core: 0,
+ contrib: 0,
+ };
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function isComponentType(value: unknown): value is CollectorComponentType {
+ return typeof value === "string" && COMPONENT_TYPES.some(({ type }) => type === value);
+}
+
+function isDistribution(value: unknown): value is CollectorDistribution {
+ return (
+ typeof value === "string" &&
+ DISTRIBUTIONS.some(({ distribution }) => distribution === value)
+ );
+}
+
+function isCollectorIndexComponent(value: unknown): value is CollectorIndexComponent {
+ if (!isRecord(value)) {
+ return false;
+ }
+
+ return (
+ typeof value.id === "string" &&
+ typeof value.name === "string" &&
+ isComponentType(value.type) &&
+ isDistribution(value.distribution)
+ );
+}
+
+function readCollectorComponents(payload: unknown): CollectorIndexComponent[] {
+ const componentEntries = Array.isArray(payload)
+ ? payload
+ : isRecord(payload) && Array.isArray(payload.components)
+ ? payload.components
+ : null;
+
+ if (!componentEntries) {
+ throw new Error("Collector index did not include a components array.");
+ }
+
+ return componentEntries.map((entry) => {
+ if (!isCollectorIndexComponent(entry)) {
+ throw new Error("Collector index contains an invalid component entry.");
+ }
+ return entry;
+ });
+}
+
+async function loadCollectorLandingStats(): Promise {
+ const [indexResponse, versionData] = await Promise.all([
+ fetch("/data/collector/index.json"),
+ loadVersions(),
+ ]);
+
+ if (!indexResponse.ok) {
+ throw new Error(`Collector index request failed with ${indexResponse.status}.`);
+ }
+
+ const payload: unknown = await indexResponse.json();
+ const components = readCollectorComponents(payload);
+ const byType = emptyTypeCounts();
+ const byDistribution = emptyDistributionCounts();
+
+ for (const component of components) {
+ byType[component.type] += 1;
+ byDistribution[component.distribution] += 1;
+ }
+
+ return {
+ byDistribution,
+ byType,
+ latestVersion:
+ versionData.versions.find((version) => version.is_latest)?.version ??
+ versionData.versions[0]?.version ??
+ null,
+ total: components.length,
+ };
+}
+
+export function CollectorExploreLanding() {
+ const [state, setState] = useState({ status: "loading" });
+ const numberFormatter = useMemo(() => new Intl.NumberFormat(), []);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function loadStats() {
+ try {
+ const stats = await loadCollectorLandingStats();
+ if (!cancelled) {
+ setState({ status: "ready", stats });
+ }
+ } catch (error) {
+ if (!cancelled) {
+ setState({
+ status: "error",
+ error: error instanceof Error ? error : new Error(String(error)),
+ });
+ }
+ }
+ }
+
+ loadStats();
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (state.status === "loading") {
+ return (
+
+
+
+ Loading Collector ecosystem data...
+
+
+ );
+ }
+
+ if (state.status === "error") {
+ return (
+
+
+ Error loading Collector data
+ {state.error.message}
+
+ );
+ }
+
+ const { stats } = state;
+
+ return (
+
+
+
+
+
+
+ OpenTelemetry Collector
+
+
+ A vendor-agnostic implementation for receiving, processing, and exporting telemetry
+ data.
+
+ {stats.latestVersion && (
+
+ Latest Collector data: v{stats.latestVersion}
+
+ )}
+
+
+ Explore Components
+
+
+
+
+
+
+
+
+ Component Types
+
+
+
+ {COMPONENT_TYPES.map(({ type, label, description, icon: Icon }) => (
+
+
+
+
+
+
+ {label}
+
+
+ {numberFormatter.format(stats.byType[type])}
+
+
+
{description}
+
+
+
+ ))}
+
+
+
+
+
+
+ Distributions
+
+
+
+ {DISTRIBUTIONS.map(({ distribution, label, description, buttonLabel }) => (
+
+
+
+
+
{label}
+
+ {numberFormatter.format(stats.byDistribution[distribution])} components
+
+
+
+
+ {description}
+
+
+ {buttonLabel}
+
+
+
+
+ ))}
+
+
+
+
+
+
+
- Components
+ -
+ {numberFormatter.format(stats.total)}
+
+
+
+
- Types
+ - {COMPONENT_TYPES.length}
+
+
+
- Distributions
+ - {DISTRIBUTIONS.length}
+
+
+
+
+
+
+
+ );
+}