diff --git a/ecosystem-explorer/DESIGN.md b/ecosystem-explorer/DESIGN.md index 4a942c4..0ffabfc 100644 --- a/ecosystem-explorer/DESIGN.md +++ b/ecosystem-explorer/DESIGN.md @@ -68,6 +68,7 @@ All colors are defined using HSL values in `src/themes.ts`. They are applied via --color-foreground: 210 45% 99%; /* Bright white with blue hint */ --color-card: 232 35% 19%; /* Card background */ --color-card-secondary: 232 32% 23%; /* Card hover state */ +--color-muted: 232 30% 17%; /* Darker background for code/badges */ --color-muted-foreground: 220 22% 65%; /* Muted text */ --color-border: 232 28% 26%; /* Borders */ ``` @@ -93,6 +94,7 @@ All colors are defined using HSL values in `src/themes.ts`. They are applied via * `background` - Page base * `card` - Elevated surfaces (cards, panels) * `card-secondary` - Hover states and nested cards +* `muted` - Darker backgrounds for inline code, type badges, and subtle UI elements * `border` - Dividers and outlines #### Text Hierarchy @@ -120,8 +122,8 @@ Shadows establish elevation and focus: Glows create ambient lighting and highlight interactive elements: ```css ---glow-primary: 0 0 40px hsl(var(--color-primary) / 0.15); ---glow-secondary: 0 0 40px hsl(var(--color-secondary) / 0.15); +--glow-primary: 0 0 40px hsl(var(--primary-hsl) / 0.15); +--glow-secondary: 0 0 40px hsl(var(--secondary-hsl) / 0.15); ``` **Usage patterns:** @@ -170,7 +172,7 @@ Standard card pattern for elevated content: **Hover states:** * Transition to `bg-card-secondary` -* Add `shadow-[0_0_20px_hsl(var(--color-primary)/0.1)]` +* Add `shadow-[0_0_20px_hsl(var(--primary-hsl)/0.1)]` * Slight scale animation: `hover:scale-[1.02]` ### Buttons @@ -202,6 +204,110 @@ Three button variants: ``` +### Type Badges + +Small badges used to display types, categories, or status indicators: + +```tsx + + {type} + +``` + +**Usage:** +* Attribute types in tables (string, int, boolean, etc.) +* Metric types +* Span kinds +* Other categorical indicators + +**Styling:** +* `bg-muted/50` - Semi-transparent dark background for subtle contrast +* `text-foreground/70` - Slightly dimmed text for readability on dark background +* `text-xs font-bold` - Small, bold text for compact display +* `rounded px-2 py-1` - Consistent padding and border radius + +**Color variants:** +For semantic meaning with glowing effects, use GlowBadge component: +* `variant="success"` - Green for metrics +* `variant="info"` - Blue for spans +* `variant="warning"` - Yellow for warnings +* `variant="error"` - Red for errors + +### Inline Code Elements + +Code snippets and technical values displayed inline: + +```tsx + + {value} + +``` + +**Usage:** +* Unit values (ms, bytes, etc.) +* Configuration keys +* API endpoints +* Version numbers +* Short code snippets + +**Styling:** +* `bg-muted` - Full opacity dark background for strong contrast +* `text-foreground/80` - Slightly dimmed bright text for comfortable reading +* `text-sm` - Readable size for technical content +* `rounded px-2 py-1` - Consistent padding matching type badges + +**Guidelines:** +* Add `font-mono` class for actual code readability when needed +* Use `break-all` for long technical strings that need to wrap +* Maintain consistent padding (`px-2 py-1`) across all inline code elements + +### Striped Tables + +Alternating row backgrounds for improved readability in data tables: + +```tsx + + + {items.map((item, index) => ( + + + + ))} + +
{item.content}
+``` + +**Pattern:** +* Apply `bg-muted/40` to odd rows (index % 2 === 1) +* Keep even rows with default transparent background +* Use 40% opacity for visible striping that improves readability +* Combine with borders for clear table structure + +**Complete table example:** +```tsx +
+ + + + + + + + {items.map((item, index) => ( + + + + ))} + +
+ Column +
{item.content}
+
+``` + ### Sections Page sections with consistent spacing: @@ -324,8 +430,8 @@ Decorative grid patterns for visual texture: ```css background-image: - linear-gradient(hsl(var(--color-border)) 1px, transparent 1px), - linear-gradient(90deg, hsl(var(--color-border)) 1px, transparent 1px); + linear-gradient(hsl(var(--border-hsl)) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--border-hsl)) 1px, transparent 1px); background-size: 20px 20px; ``` @@ -333,8 +439,8 @@ background-size: 20px 20px; ```css background-image: - linear-gradient(hsl(var(--color-border)) 1px, transparent 1px), - linear-gradient(90deg, hsl(var(--color-border)) 1px, transparent 1px); + linear-gradient(hsl(var(--border-hsl)) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--border-hsl)) 1px, transparent 1px); background-size: 40px 40px; ``` diff --git a/ecosystem-explorer/src/components/icons/compass.tsx b/ecosystem-explorer/src/components/icons/compass.tsx index f1d2137..6ba358a 100644 --- a/ecosystem-explorer/src/components/icons/compass.tsx +++ b/ecosystem-explorer/src/components/icons/compass.tsx @@ -86,7 +86,7 @@ export function Compass({ className }: { className?: string }) { className="w-full h-full" style={{ filter: - "drop-shadow(0 0 8px hsl(var(--color-primary) / 0.4)) drop-shadow(0 0 16px hsl(var(--color-primary) / 0.2))", + "drop-shadow(0 0 8px hsl(var(--primary-hsl) / 0.4)) drop-shadow(0 0 16px hsl(var(--primary-hsl) / 0.2))", }} > {/* Outer ring */} @@ -95,7 +95,7 @@ export function Compass({ className }: { className?: string }) { cy="100" r="95" fill="none" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="1.5" opacity="0.4" /> @@ -104,7 +104,7 @@ export function Compass({ className }: { className?: string }) { cy="100" r="90" fill="none" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="0.5" opacity="0.2" /> @@ -122,7 +122,7 @@ export function Compass({ className }: { className?: string }) { y1={100 - innerR * Math.cos(angle)} x2={100 + outerR * Math.sin(angle)} y2={100 - outerR * Math.cos(angle)} - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth={isMajor ? 1.5 : 0.5} opacity={isMajor ? 0.7 : 0.3} /> @@ -140,7 +140,7 @@ export function Compass({ className }: { className?: string }) { y={100 - r * Math.cos(angle)} textAnchor="middle" dominantBaseline="middle" - fill="hsl(var(--color-primary))" + fill="hsl(var(--primary-hsl))" className="font-mono text-sm font-bold" > {dir} @@ -151,18 +151,18 @@ export function Compass({ className }: { className?: string }) { {/* Rotating needle group */} {/* North needle */} - + {/* South needle */} {/* Center circle */} - - + + {/* Inner decorative circles */} @@ -50,7 +50,7 @@ export function ConfigurationIcon({ className }: { className?: string }) { y1="70" x2="135" y2="70" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" opacity="0.3" /> @@ -60,12 +60,12 @@ export function ConfigurationIcon({ className }: { className?: string }) { y1="70" x2="95" y2="70" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" opacity="0.8" /> {/* Knob */} - + @@ -76,7 +76,7 @@ export function ConfigurationIcon({ className }: { className?: string }) { y1="100" x2="135" y2="100" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" opacity="0.3" /> @@ -85,11 +85,11 @@ export function ConfigurationIcon({ className }: { className?: string }) { y1="100" x2="120" y2="100" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" opacity="0.8" /> - + @@ -100,7 +100,7 @@ export function ConfigurationIcon({ className }: { className?: string }) { y1="130" x2="135" y2="130" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" opacity="0.3" /> @@ -109,30 +109,23 @@ export function ConfigurationIcon({ className }: { className?: string }) { y1="130" x2="80" y2="130" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" opacity="0.8" /> - + {/* Gear icon accent in top-right */} - - + + {/* Gear teeth */} - - - - + + + + ); diff --git a/ecosystem-explorer/src/components/icons/java-icon.tsx b/ecosystem-explorer/src/components/icons/java-icon.tsx index b1b2470..dc2d2ad 100644 --- a/ecosystem-explorer/src/components/icons/java-icon.tsx +++ b/ecosystem-explorer/src/components/icons/java-icon.tsx @@ -25,7 +25,7 @@ export function JavaIcon({ className }: { className?: string }) { @@ -39,7 +39,7 @@ export function JavaIcon({ className }: { className?: string }) { {/* faint liquid tint behind the bars */} @@ -51,7 +51,7 @@ export function JavaIcon({ className }: { className?: string }) { y1="158" x2="138" y2="158" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="2" opacity="0.25" /> @@ -67,7 +67,7 @@ export function JavaIcon({ className }: { className?: string }) { y="132" width="10" height="26" - fill="hsl(var(--color-primary))" + fill="hsl(var(--primary-hsl))" opacity="0.35" rx="2" /> @@ -76,7 +76,7 @@ export function JavaIcon({ className }: { className?: string }) { y="120" width="10" height="38" - fill="hsl(var(--color-primary))" + fill="hsl(var(--primary-hsl))" opacity="0.45" rx="2" /> @@ -85,7 +85,7 @@ export function JavaIcon({ className }: { className?: string }) { y="140" width="10" height="18" - fill="hsl(var(--color-primary))" + fill="hsl(var(--primary-hsl))" opacity="0.30" rx="2" /> @@ -94,7 +94,7 @@ export function JavaIcon({ className }: { className?: string }) { y="112" width="10" height="46" - fill="hsl(var(--color-primary))" + fill="hsl(var(--primary-hsl))" opacity="0.55" rx="2" /> @@ -103,7 +103,7 @@ export function JavaIcon({ className }: { className?: string }) { y="126" width="10" height="32" - fill="hsl(var(--color-primary))" + fill="hsl(var(--primary-hsl))" opacity="0.40" rx="2" /> @@ -116,7 +116,7 @@ export function JavaIcon({ className }: { className?: string }) { @@ -124,7 +124,7 @@ export function JavaIcon({ className }: { className?: string }) { {/* Central hub node */} - + - + {/* Top node */} - + @@ -59,16 +59,16 @@ export function JavaInstrumentationIcon({ className }: { className?: string }) { cy="65" r="15" fill="none" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" /> - + @@ -79,16 +79,16 @@ export function JavaInstrumentationIcon({ className }: { className?: string }) { cy="100" r="15" fill="none" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" /> - + @@ -99,16 +99,16 @@ export function JavaInstrumentationIcon({ className }: { className?: string }) { cy="135" r="15" fill="none" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" /> - + @@ -119,16 +119,16 @@ export function JavaInstrumentationIcon({ className }: { className?: string }) { cy="160" r="15" fill="none" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" /> - + @@ -139,16 +139,16 @@ export function JavaInstrumentationIcon({ className }: { className?: string }) { cy="135" r="15" fill="none" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" /> - + @@ -159,36 +159,29 @@ export function JavaInstrumentationIcon({ className }: { className?: string }) { cy="100" r="15" fill="none" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" /> - + {/* Top-left node */} - - + + diff --git a/ecosystem-explorer/src/components/icons/otel-logo.tsx b/ecosystem-explorer/src/components/icons/otel-logo.tsx index 3385ba1..c7a51c9 100644 --- a/ecosystem-explorer/src/components/icons/otel-logo.tsx +++ b/ecosystem-explorer/src/components/icons/otel-logo.tsx @@ -24,35 +24,35 @@ export function OtelLogo({ className }: { className?: string }) { > {/* Top-right circle with inner circle */} {/* Top-right diagonal section */} {/* Bottom-left small element */} {/* Top-left angular shape */} {/* Bottom angular shape */} diff --git a/ecosystem-explorer/src/components/icons/pipeline-icon.tsx b/ecosystem-explorer/src/components/icons/pipeline-icon.tsx index 7f45dba..5ba9427 100644 --- a/ecosystem-explorer/src/components/icons/pipeline-icon.tsx +++ b/ecosystem-explorer/src/components/icons/pipeline-icon.tsx @@ -22,38 +22,31 @@ export function PipelineIcon({ className }: { className?: string }) { cy="100" r="15" fill="none" - stroke="hsl(var(--color-primary))" + stroke="hsl(var(--primary-hsl))" strokeWidth="3" /> - + {/* Pipeline from input to processor */} - + {/* Arrow 1 */} - + {/* Processor node (hexagon) */} - + {/* Pipeline from processor to output */} - + {/* Arrow 2 */} - + {/* Output node (square) */} - + ); } diff --git a/ecosystem-explorer/src/components/ui/detail-card.tsx b/ecosystem-explorer/src/components/ui/detail-card.tsx index 964855b..b82ecce 100644 --- a/ecosystem-explorer/src/components/ui/detail-card.tsx +++ b/ecosystem-explorer/src/components/ui/detail-card.tsx @@ -66,7 +66,7 @@ export function DetailCard({ diff --git a/ecosystem-explorer/src/components/ui/navigation-card.tsx b/ecosystem-explorer/src/components/ui/navigation-card.tsx index 30aff19..ca2ee22 100644 --- a/ecosystem-explorer/src/components/ui/navigation-card.tsx +++ b/ecosystem-explorer/src/components/ui/navigation-card.tsx @@ -60,7 +60,7 @@ export function NavigationCard({ title, description, href, icon }: NavigationCar {/* Content on the right */}
-

+

{title}

@@ -74,7 +74,7 @@ export function NavigationCard({ title, description, href, icon }: NavigationCar
diff --git a/ecosystem-explorer/src/components/ui/section-divider.tsx b/ecosystem-explorer/src/components/ui/section-divider.tsx new file mode 100644 index 0000000..9e77d14 --- /dev/null +++ b/ecosystem-explorer/src/components/ui/section-divider.tsx @@ -0,0 +1,32 @@ +/* + * 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 type { ReactNode } from "react"; + +interface SectionDividerProps { + children: ReactNode; +} + +export function SectionDivider({ children }: SectionDividerProps) { + return ( +
+
+ + {children} + +
+
+ ); +} diff --git a/ecosystem-explorer/src/components/ui/section-header.tsx b/ecosystem-explorer/src/components/ui/section-header.tsx index adcbd21..eb13188 100644 --- a/ecosystem-explorer/src/components/ui/section-header.tsx +++ b/ecosystem-explorer/src/components/ui/section-header.tsx @@ -26,7 +26,7 @@ export function SectionHeader({ children, className = "" }: SectionHeaderProps)

@@ -35,7 +35,7 @@ export function SectionHeader({ children, className = "" }: SectionHeaderProps)
diff --git a/ecosystem-explorer/src/components/ui/tabs.tsx b/ecosystem-explorer/src/components/ui/tabs.tsx index 33bc795..0d1d466 100644 --- a/ecosystem-explorer/src/components/ui/tabs.tsx +++ b/ecosystem-explorer/src/components/ui/tabs.tsx @@ -25,7 +25,7 @@ const TabsList = forwardRef< >(({ className = "", ...props }, ref) => ( )); @@ -37,7 +37,7 @@ const TabsTrigger = forwardRef< >(({ className = "", ...props }, ref) => ( )); diff --git a/ecosystem-explorer/src/features/home/components/explore-section.tsx b/ecosystem-explorer/src/features/home/components/explore-section.tsx index bbe1580..eef4ad9 100644 --- a/ecosystem-explorer/src/features/home/components/explore-section.tsx +++ b/ecosystem-explorer/src/features/home/components/explore-section.tsx @@ -25,7 +25,7 @@ export function ExploreSection() { className="pointer-events-none absolute left-1/2 top-0 h-64 w-full max-w-3xl -translate-x-1/2 -translate-y-16" style={{ background: - "radial-gradient(ellipse at center, hsl(var(--color-primary) / 0.04) 0%, transparent 80%)", + "radial-gradient(ellipse at center, hsl(var(--primary-hsl) / 0.04) 0%, transparent 80%)", }} /> diff --git a/ecosystem-explorer/src/features/home/components/hero-section.tsx b/ecosystem-explorer/src/features/home/components/hero-section.tsx index e9a8bda..088e33e 100644 --- a/ecosystem-explorer/src/features/home/components/hero-section.tsx +++ b/ecosystem-explorer/src/features/home/components/hero-section.tsx @@ -23,7 +23,7 @@ export function HeroSection() { className="absolute inset-0" style={{ background: - "radial-gradient(circle at center, hsl(var(--color-primary) / 0.08) 0%, hsl(var(--color-secondary) / 0.04) 30%, transparent 70%)", + "radial-gradient(circle at center, hsl(var(--primary-hsl) / 0.08) 0%, hsl(var(--secondary-hsl) / 0.04) 30%, transparent 70%)", }} /> @@ -33,7 +33,7 @@ export function HeroSection() { className="h-full w-full" style={{ backgroundImage: - "linear-gradient(hsl(var(--color-border)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--color-border)) 1px, transparent 1px)", + "linear-gradient(hsl(var(--border-hsl)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--border-hsl)) 1px, transparent 1px)", backgroundSize: "40px 40px", }} /> @@ -45,7 +45,7 @@ export function HeroSection() {
@@ -55,7 +55,7 @@ export function HeroSection() {

OpenTelemetry
- + Ecosystem Explorer

@@ -71,7 +71,7 @@ export function HeroSection() { className="pointer-events-none absolute bottom-0 left-0 right-0 h-64" style={{ background: - "linear-gradient(to top, hsl(var(--color-background)) 0%, hsl(var(--color-background) / 0.6) 30%, transparent 100%)", + "linear-gradient(to top, hsl(var(--background-hsl)) 0%, hsl(var(--background-hsl) / 0.6) 30%, transparent 100%)", }} /> diff --git a/ecosystem-explorer/src/features/java-agent/components/agent-explore-landing.tsx b/ecosystem-explorer/src/features/java-agent/components/agent-explore-landing.tsx index ecdba0d..72ee072 100644 --- a/ecosystem-explorer/src/features/java-agent/components/agent-explore-landing.tsx +++ b/ecosystem-explorer/src/features/java-agent/components/agent-explore-landing.tsx @@ -20,9 +20,6 @@ import { NavigationCard } from "@/components/ui/navigation-card"; export function AgentExploreLanding() { return (
- {/* Section divider */} -
-
{ + const mockAttributes: Attribute[] = [ + { name: "http.method", type: "STRING" }, + { name: "http.status_code", type: "LONG" }, + { name: "http.response_time", type: "DOUBLE" }, + ]; + + it("renders attribute table with correct structure", () => { + render(); + + const table = screen.getByRole("table", { name: "Attributes" }); + expect(table).toBeInTheDocument(); + }); + + it("renders table headers correctly", () => { + render(); + + expect(screen.getByRole("columnheader", { name: "Key" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Type" })).toBeInTheDocument(); + }); + + it("renders all attributes with correct names and types", () => { + render(); + + const rows = screen.getAllByRole("row"); + // Should have header row + 3 data rows + expect(rows).toHaveLength(4); + + expect(screen.getByText("http.method")).toBeInTheDocument(); + expect(screen.getByText("STRING")).toBeInTheDocument(); + + expect(screen.getByText("http.status_code")).toBeInTheDocument(); + expect(screen.getByText("LONG")).toBeInTheDocument(); + + expect(screen.getByText("http.response_time")).toBeInTheDocument(); + expect(screen.getByText("DOUBLE")).toBeInTheDocument(); + }); + + it("returns null when attributes array is empty", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("renders single attribute correctly", () => { + const singleAttribute: Attribute[] = [{ name: "db.system", type: "STRING" }]; + + render(); + + expect(screen.getByText("db.system")).toBeInTheDocument(); + expect(screen.getByText("STRING")).toBeInTheDocument(); + }); + + it("renders all attribute types correctly", () => { + const allTypes: Attribute[] = [ + { name: "attr.string", type: "STRING" }, + { name: "attr.long", type: "LONG" }, + { name: "attr.double", type: "DOUBLE" }, + { name: "attr.boolean", type: "BOOLEAN" }, + { name: "attr.string_array", type: "STRING_ARRAY" }, + { name: "attr.long_array", type: "LONG_ARRAY" }, + { name: "attr.double_array", type: "DOUBLE_ARRAY" }, + { name: "attr.boolean_array", type: "BOOLEAN_ARRAY" }, + ]; + + render(); + + expect(screen.getByText("STRING")).toBeInTheDocument(); + expect(screen.getByText("LONG")).toBeInTheDocument(); + expect(screen.getByText("DOUBLE")).toBeInTheDocument(); + expect(screen.getByText("BOOLEAN")).toBeInTheDocument(); + expect(screen.getByText("STRING_ARRAY")).toBeInTheDocument(); + expect(screen.getByText("LONG_ARRAY")).toBeInTheDocument(); + expect(screen.getByText("DOUBLE_ARRAY")).toBeInTheDocument(); + expect(screen.getByText("BOOLEAN_ARRAY")).toBeInTheDocument(); + }); +}); diff --git a/ecosystem-explorer/src/features/java-agent/components/attribute-table.tsx b/ecosystem-explorer/src/features/java-agent/components/attribute-table.tsx new file mode 100644 index 0000000..db08a9d --- /dev/null +++ b/ecosystem-explorer/src/features/java-agent/components/attribute-table.tsx @@ -0,0 +1,64 @@ +/* + * 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 type { Attribute } from "@/types/javaagent"; + +interface AttributeTableProps { + attributes: Attribute[]; +} + +export function AttributeTable({ attributes }: AttributeTableProps) { + if (attributes.length === 0) { + return null; + } + + return ( +
+ + + + + + + + + {attributes.map((attr, index) => ( + + + + + ))} + +
+ Key + + Type +
{attr.name} + + {attr.type} + +
+
+ ); +} diff --git a/ecosystem-explorer/src/features/java-agent/components/configuration-selector.test.tsx b/ecosystem-explorer/src/features/java-agent/components/configuration-selector.test.tsx new file mode 100644 index 0000000..ae92d8c --- /dev/null +++ b/ecosystem-explorer/src/features/java-agent/components/configuration-selector.test.tsx @@ -0,0 +1,183 @@ +/* + * 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, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ConfigurationSelector } from "./configuration-selector"; +import type { Telemetry } from "@/types/javaagent"; + +describe("ConfigurationSelector", () => { + const mockTelemetry: Telemetry[] = [ + { + when: "default", + metrics: [ + { + name: "test.metric", + description: "Test metric", + type: "COUNTER", + unit: "1", + }, + ], + }, + { + when: "otel.instrumentation.http.enabled=true", + metrics: [ + { + name: "http.metric", + description: "HTTP metric", + type: "GAUGE", + unit: "ms", + }, + ], + }, + { + when: "otel.instrumentation.http.enabled=false", + spans: [{ span_kind: "CLIENT" }], + }, + ]; + + it("renders info banner", () => { + const onWhenChange = vi.fn(); + + render( + + ); + + expect(screen.getByText("Telemetry varies by configuration")).toBeInTheDocument(); + }); + + it("renders select element with label", () => { + const onWhenChange = vi.fn(); + + render( + + ); + + expect(screen.getByLabelText(/configuration/i)).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("renders all telemetry configuration options", () => { + const onWhenChange = vi.fn(); + + render( + + ); + + const select = screen.getByRole("combobox"); + const options = within(select).getAllByRole("option"); + + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent("Default"); + expect(options[1]).toHaveTextContent("otel.instrumentation.http.enabled=true"); + expect(options[2]).toHaveTextContent("otel.instrumentation.http.enabled=false"); + }); + + it("displays correct selected value", () => { + const onWhenChange = vi.fn(); + + render( + + ); + + const select = screen.getByRole("combobox") as HTMLSelectElement; + expect(select.value).toBe("default"); + }); + + it("calls onWhenChange when selection changes", async () => { + const user = userEvent.setup(); + const onWhenChange = vi.fn(); + + render( + + ); + + const select = screen.getByRole("combobox"); + await user.selectOptions(select, "otel.instrumentation.http.enabled=true"); + + expect(onWhenChange).toHaveBeenCalledTimes(1); + expect(onWhenChange).toHaveBeenCalledWith("otel.instrumentation.http.enabled=true"); + }); + + it("renders with single configuration option", () => { + const onWhenChange = vi.fn(); + const singleTelemetry: Telemetry[] = [ + { + when: "always", + metrics: [], + }, + ]; + + render( + + ); + + const select = screen.getByRole("combobox"); + const options = within(select).getAllByRole("option"); + + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent("always"); + }); + + it("updates selected value when selectedWhen prop changes", () => { + const onWhenChange = vi.fn(); + const { rerender } = render( + + ); + + let select = screen.getByRole("combobox") as HTMLSelectElement; + expect(select.value).toBe("default"); + + rerender( + + ); + + select = screen.getByRole("combobox") as HTMLSelectElement; + expect(select.value).toBe("otel.instrumentation.http.enabled=true"); + }); +}); diff --git a/ecosystem-explorer/src/features/java-agent/components/configuration-selector.tsx b/ecosystem-explorer/src/features/java-agent/components/configuration-selector.tsx new file mode 100644 index 0000000..8358af4 --- /dev/null +++ b/ecosystem-explorer/src/features/java-agent/components/configuration-selector.tsx @@ -0,0 +1,72 @@ +/* + * 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 { Info } from "lucide-react"; +import type { Telemetry } from "@/types/javaagent"; + +interface ConfigurationSelectorProps { + telemetry: Telemetry[]; + selectedWhen: string; + onWhenChange: (when: string) => void; +} + +function getConfigLabel(when: string): string { + if (when === "default") return "Default"; + return when; +} + +export function ConfigurationSelector({ + telemetry, + selectedWhen, + onWhenChange, +}: ConfigurationSelectorProps) { + return ( +
+
+ {/* Left: Info banner */} +
+
+
+
+ + {/* Right: Label + Select */} +
+ + +
+
+
+ ); +} 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 cffef11..2dc4926 100644 --- a/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.tsx +++ b/ecosystem-explorer/src/features/java-agent/components/instrumentation-card.tsx @@ -38,7 +38,7 @@ export function InstrumentationCard({ return ( {/* Grid pattern background */} @@ -47,7 +47,7 @@ export function InstrumentationCard({ className="h-full w-full" style={{ backgroundImage: - "linear-gradient(hsl(var(--color-border)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--color-border)) 1px, transparent 1px)", + "linear-gradient(hsl(var(--border-hsl)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--border-hsl)) 1px, transparent 1px)", backgroundSize: "20px 20px", }} /> @@ -72,9 +72,6 @@ export function InstrumentationCard({
- - {/* Corner accent */} -
); } 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 deb8211..b2f2569 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 @@ -57,7 +57,7 @@ export function InstrumentationFilterBar({ className="absolute inset-0" style={{ background: - "radial-gradient(circle at top left, hsl(var(--color-secondary) / 0.08) 0%, hsl(var(--color-primary) / 0.04) 40%, transparent 70%)", + "radial-gradient(circle at top left, hsl(var(--secondary-hsl) / 0.08) 0%, hsl(var(--primary-hsl) / 0.04) 40%, transparent 70%)", }} /> @@ -67,7 +67,7 @@ export function InstrumentationFilterBar({ className="h-full w-full" style={{ backgroundImage: - "linear-gradient(hsl(var(--color-border)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--color-border)) 1px, transparent 1px)", + "linear-gradient(hsl(var(--border-hsl)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--border-hsl)) 1px, transparent 1px)", backgroundSize: "24px 24px", }} /> @@ -151,7 +151,7 @@ export function InstrumentationFilterBar({
diff --git a/ecosystem-explorer/src/features/java-agent/components/multi-instrumentation-group-card.tsx b/ecosystem-explorer/src/features/java-agent/components/multi-instrumentation-group-card.tsx index d46c078..808a2ea 100644 --- a/ecosystem-explorer/src/features/java-agent/components/multi-instrumentation-group-card.tsx +++ b/ecosystem-explorer/src/features/java-agent/components/multi-instrumentation-group-card.tsx @@ -43,14 +43,14 @@ export function MultiInstrumentationGroupCard({ const description = group.instrumentations.find((i) => i.description)?.description; return ( -
+
{/* Grid pattern background */}
@@ -114,9 +114,6 @@ export function MultiInstrumentationGroupCard({ )}
- - {/* Corner accent */} -
); } diff --git a/ecosystem-explorer/src/features/java-agent/components/telemetry-section.test.tsx b/ecosystem-explorer/src/features/java-agent/components/telemetry-section.test.tsx new file mode 100644 index 0000000..b935661 --- /dev/null +++ b/ecosystem-explorer/src/features/java-agent/components/telemetry-section.test.tsx @@ -0,0 +1,420 @@ +/* + * 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 { TelemetrySection } from "./telemetry-section"; +import type { Telemetry, Metric, Span, Attribute } from "@/types/javaagent"; +import type { ReactNode } from "react"; + +vi.mock("./configuration-selector", () => ({ + ConfigurationSelector: ({ + telemetry, + selectedWhen, + onWhenChange, + }: { + telemetry: Telemetry[]; + selectedWhen: string; + onWhenChange: (when: string) => void; + }) => ( +
+ +
+ ), +})); + +vi.mock("./attribute-table", () => ({ + AttributeTable: ({ attributes }: { attributes: Attribute[] }) => ( +
+ {attributes.map((attr) => ( +
+ {attr.name}: {attr.type} +
+ ))} +
+ ), +})); + +vi.mock("@/components/ui/section-divider", () => ({ + SectionDivider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/ui/glow-badge", () => ({ + GlowBadge: ({ children }: { children: ReactNode }) => {children}, +})); + +describe("TelemetrySection", () => { + const mockMetric: Metric = { + name: "http.server.duration", + description: "Duration of HTTP server requests", + type: "HISTOGRAM", + unit: "ms", + attributes: [ + { name: "http.method", type: "STRING" }, + { name: "http.status_code", type: "LONG" }, + ], + }; + + const mockSpan: Span = { + span_kind: "CLIENT", + attributes: [ + { name: "http.url", type: "STRING" }, + { name: "http.target", type: "STRING" }, + ], + }; + + describe("Configuration Selector", () => { + it("does not render configuration selector when single configuration", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [mockMetric], + }, + ]; + + render(); + + expect(screen.queryByTestId("config-selector")).not.toBeInTheDocument(); + }); + + it("renders configuration selector when multiple configurations", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [mockMetric], + }, + { + when: "otel.instrumentation.http.enabled=true", + metrics: [mockMetric], + }, + ]; + + render(); + + expect(screen.getByTestId("config-selector")).toBeInTheDocument(); + }); + + it("initializes with first configuration selected", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [mockMetric], + }, + { + when: "custom", + spans: [mockSpan], + }, + ]; + + render(); + + const select = screen.getByTestId("config-select") as HTMLSelectElement; + expect(select.value).toBe("default"); + }); + + it("switches between configurations when selection changes", async () => { + const user = userEvent.setup(); + const defaultMetric: Metric = { + name: "default.metric", + description: "Default metric", + type: "COUNTER", + unit: "1", + }; + + const customMetric: Metric = { + name: "custom.metric", + description: "Custom metric", + type: "GAUGE", + unit: "ms", + }; + + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [defaultMetric], + }, + { + when: "custom", + metrics: [customMetric], + }, + ]; + + render(); + + // Initially should show default metric + expect(screen.getByText("default.metric")).toBeInTheDocument(); + expect(screen.queryByText("custom.metric")).not.toBeInTheDocument(); + + // Switch to custom configuration + const select = screen.getByTestId("config-select"); + await user.selectOptions(select, "custom"); + + // Now should show custom metric + expect(screen.queryByText("default.metric")).not.toBeInTheDocument(); + expect(screen.getByText("custom.metric")).toBeInTheDocument(); + }); + }); + + describe("Metrics Rendering", () => { + it("renders metrics section when metrics exist", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [mockMetric], + }, + ]; + + render(); + + expect(screen.getByTestId("section-divider")).toHaveTextContent("Metrics"); + expect(screen.getByText("http.server.duration")).toBeInTheDocument(); + }); + + it("renders metric attributes when present", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [mockMetric], + }, + ]; + + render(); + + expect(screen.getByTestId("attribute-table")).toBeInTheDocument(); + expect(screen.getByText(/http\.method/)).toBeInTheDocument(); + expect(screen.getByText(/http\.status_code/)).toBeInTheDocument(); + }); + + it("does not render attributes section when metric has no attributes", () => { + const metricWithoutAttrs: Metric = { + name: "simple.metric", + description: "Simple metric", + type: "COUNTER", + unit: "1", + }; + + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [metricWithoutAttrs], + }, + ]; + + render(); + + expect(screen.queryByTestId("attribute-table")).not.toBeInTheDocument(); + }); + + it("does not render metrics section when no metrics", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + spans: [mockSpan], + }, + ]; + + render(); + + const dividers = screen.getAllByTestId("section-divider"); + const metricsSection = dividers.find((div) => div.textContent === "Metrics"); + + expect(metricsSection).toBeUndefined(); + }); + }); + + describe("Spans Rendering", () => { + it("renders spans section when spans exist", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + spans: [mockSpan], + }, + ]; + + render(); + + expect(screen.getByTestId("section-divider")).toHaveTextContent("Spans"); + }); + + it("renders span kind", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + spans: [mockSpan], + }, + ]; + + render(); + + expect(screen.getByText("CLIENT Span")).toBeInTheDocument(); + }); + + it("renders span attributes when present", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + spans: [mockSpan], + }, + ]; + + render(); + + expect(screen.getByTestId("attribute-table")).toBeInTheDocument(); + expect(screen.getByText(/http\.url/)).toBeInTheDocument(); + expect(screen.getByText(/http\.target/)).toBeInTheDocument(); + }); + + it("does not render attributes section when span has no attributes", () => { + const spanWithoutAttrs: Span = { + span_kind: "INTERNAL", + }; + + const telemetry: Telemetry[] = [ + { + when: "default", + spans: [spanWithoutAttrs], + }, + ]; + + render(); + + expect(screen.queryByTestId("attribute-table")).not.toBeInTheDocument(); + }); + + it("does not render spans section when no spans", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [mockMetric], + }, + ]; + + render(); + + const dividers = screen.getAllByTestId("section-divider"); + const spansSection = dividers.find((div) => div.textContent === "Spans"); + + expect(spansSection).toBeUndefined(); + }); + }); + + describe("Both Metrics and Spans", () => { + it("renders both metrics and spans sections when both exist", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [mockMetric], + spans: [mockSpan], + }, + ]; + + render(); + + const dividers = screen.getAllByTestId("section-divider"); + expect(dividers).toHaveLength(2); + + expect(dividers[0]).toHaveTextContent("Metrics"); + expect(dividers[1]).toHaveTextContent("Spans"); + + expect(screen.getByText("http.server.duration")).toBeInTheDocument(); + expect(screen.getByText("CLIENT Span")).toBeInTheDocument(); + }); + }); + + describe("Empty State", () => { + it("shows empty state when no metrics and no spans", () => { + const telemetry: Telemetry[] = [ + { + when: "default", + }, + ]; + + render(); + + expect( + screen.getByText("No metrics or spans defined for this configuration.") + ).toBeInTheDocument(); + }); + + it("does not show empty state when telemetry exists", () => { + const withMetrics: Telemetry[] = [{ when: "default", metrics: [mockMetric] }]; + const { unmount } = render(); + expect( + screen.queryByText("No metrics or spans defined for this configuration.") + ).not.toBeInTheDocument(); + unmount(); + + const withSpans: Telemetry[] = [{ when: "default", spans: [mockSpan] }]; + render(); + expect( + screen.queryByText("No metrics or spans defined for this configuration.") + ).not.toBeInTheDocument(); + }); + + it("shows empty state for selected configuration with no telemetry", async () => { + const user = userEvent.setup(); + const telemetry: Telemetry[] = [ + { + when: "default", + metrics: [mockMetric], + }, + { + when: "empty", + metrics: [], + spans: [], + }, + ]; + + render(); + + // Initially shows default metric + expect(screen.queryByText("No metrics or spans defined")).not.toBeInTheDocument(); + + // Switch to empty configuration + const select = screen.getByTestId("config-select"); + await user.selectOptions(select, "empty"); + + // Now shows empty state + expect( + screen.getByText("No metrics or spans defined for this configuration.") + ).toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("handles empty telemetry array gracefully", () => { + const telemetry: Telemetry[] = []; + + render(); + + expect( + screen.getByText("No metrics or spans defined for this configuration.") + ).toBeInTheDocument(); + }); + }); +}); diff --git a/ecosystem-explorer/src/features/java-agent/components/telemetry-section.tsx b/ecosystem-explorer/src/features/java-agent/components/telemetry-section.tsx new file mode 100644 index 0000000..8ab2ffe --- /dev/null +++ b/ecosystem-explorer/src/features/java-agent/components/telemetry-section.tsx @@ -0,0 +1,156 @@ +/* + * 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 } from "react"; +import { SectionDivider } from "@/components/ui/section-divider"; +import { GlowBadge } from "@/components/ui/glow-badge"; +import { AttributeTable } from "./attribute-table"; +import { ConfigurationSelector } from "./configuration-selector"; +import type { Telemetry } from "@/types/javaagent"; + +interface TelemetrySectionProps { + telemetry: Telemetry[]; +} + +export function TelemetrySection({ telemetry }: TelemetrySectionProps) { + const [selectedWhen, setSelectedWhen] = useState(telemetry[0]?.when ?? "default"); + + // Validate selected value and fall back to first option if invalid + const isCurrentSelectionValid = telemetry.some((t) => t.when === selectedWhen); + const effectiveSelectedWhen = isCurrentSelectionValid + ? selectedWhen + : (telemetry[0]?.when ?? "default"); + + const currentTelemetry = telemetry.find((t) => t.when === effectiveSelectedWhen) ?? telemetry[0]; + + const hasMetrics = currentTelemetry?.metrics && currentTelemetry.metrics.length > 0; + const hasSpans = currentTelemetry?.spans && currentTelemetry.spans.length > 0; + const hasBothMetricsAndSpans = hasMetrics && hasSpans; + + return ( +
+ {/* Configuration Selector - only show if multiple conditions exist */} + {telemetry.length > 1 && ( + + )} + +
+ {/* Metrics Section */} + {hasMetrics && ( +
+ Metrics +
+ {currentTelemetry.metrics && + currentTelemetry.metrics.map((metric) => ( +
+
+ {/* Metric name and type badge */} +
+ + {metric.name} + + + {metric.type} + +
+ + {/* Description */} +

+ {metric.description} +

+ + {/* Unit section with border */} +
+ + Unit + + + {metric.unit} + +
+ + {/* Attributes section */} + {metric.attributes && metric.attributes.length > 0 && ( +
+

+ Attributes +

+ +
+ )} +
+
+ ))} +
+
+ )} + + {/* Spans Section */} + {hasSpans && ( +
+ Spans +
+ {currentTelemetry.spans && + currentTelemetry.spans.map((span, index) => ( +
+
+ {/* Span kind badge */} +
+

+ {span.span_kind} Span +

+ + {span.span_kind} + +
+ + {/* Attributes section */} + {span.attributes && span.attributes.length > 0 && ( +
+

+ Attributes +

+ +
+ )} +
+
+ ))} +
+
+ )} +
+ + {/* Empty state */} + {!hasMetrics && !hasSpans && ( +
+

+ No metrics or spans defined for this configuration. +

+
+ )} +
+ ); +} diff --git a/ecosystem-explorer/src/features/java-agent/instrumentation-detail-page.tsx b/ecosystem-explorer/src/features/java-agent/instrumentation-detail-page.tsx index b7d6355..a4a52f5 100644 --- a/ecosystem-explorer/src/features/java-agent/instrumentation-detail-page.tsx +++ b/ecosystem-explorer/src/features/java-agent/instrumentation-detail-page.tsx @@ -32,6 +32,7 @@ import { DetailCard } from "@/components/ui/detail-card"; import { SectionHeader } from "@/components/ui/section-header"; import { useVersions, useInstrumentation } from "@/hooks/use-javaagent-data"; import { getInstrumentationDisplayName } from "./utils/format"; +import { TelemetrySection } from "./components/telemetry-section"; function buildSourceUrl(sourcePath: string): string { try { @@ -79,7 +80,7 @@ export function InstrumentationDetailPage() {