@@ -28,15 +28,6 @@ function tryParseJson(value: unknown): string | null {
2828 return null ;
2929}
3030
31- function formatDuration ( ms : number ) : string {
32- if ( ms < 1 ) return `${ ( ms * 1000 ) . toFixed ( 0 ) } us` ;
33- if ( ms < 1000 ) return `${ ms . toFixed ( 2 ) } ms` ;
34- if ( ms < 60000 ) return `${ ( ms / 1000 ) . toFixed ( 2 ) } s` ;
35- const mins = Math . floor ( ms / 60000 ) ;
36- const secs = ( ( ms % 60000 ) / 1000 ) . toFixed ( 1 ) ;
37- return `${ mins } m ${ secs } s` ;
38- }
39-
4031const TRUNCATE_LIMIT = 200 ;
4132
4233function stringifyValue ( value : unknown ) : string {
@@ -112,12 +103,16 @@ function AttributeValue({ value }: { value: unknown }) {
112103export default function SpanDetails ( { span } : Props ) {
113104 const [ attrsOpen , setAttrsOpen ] = useState ( true ) ;
114105 const [ idsOpen , setIdsOpen ] = useState ( false ) ;
106+ const [ detailMode , setDetailMode ] = useState < "table" | "json" > ( "table" ) ;
107+ const [ copied , setCopied ] = useState ( false ) ;
115108 const status = STATUS_CONFIG [ span . status . toLowerCase ( ) ] ?? { ...DEFAULT_STATUS , label : span . status } ;
116-
117- const time = new Date ( span . timestamp ) . toLocaleTimeString ( undefined , {
118- hour12 : false ,
119- fractionalSecondDigits : 3 ,
120- } as Intl . DateTimeFormatOptions ) ;
109+ const spanJson = useMemo ( ( ) => JSON . stringify ( span , null , 2 ) , [ span ] ) ;
110+ const copySpanJson = useCallback ( ( ) => {
111+ navigator . clipboard . writeText ( spanJson ) . then ( ( ) => {
112+ setCopied ( true ) ;
113+ setTimeout ( ( ) => setCopied ( false ) , 1500 ) ;
114+ } ) ;
115+ } , [ spanJson ] ) ;
121116
122117 const attrEntries = Object . entries ( span . attributes ) ;
123118 const ids : { label : string ; value : string } [ ] = [
@@ -128,17 +123,38 @@ export default function SpanDetails({ span }: Props) {
128123 ] ;
129124
130125 return (
131- < div className = "overflow-y-auto h-full text-xs leading-normal" >
132- { /* Header: name + pills */ }
126+ < div className = "flex flex-col h-full text-xs leading-normal" >
127+ { /* Header: tabs + status pill — fixed */ }
133128 < div
134- className = "px-2 py-1.5 border-b flex items-center gap-2 flex-wrap "
135- style = { { borderColor : "var(--border)" , background : "var(--bg-secondary)" } }
129+ className = "px-2 border-b flex items-center gap-1 shrink-0 "
130+ style = { { borderColor : "var(--border)" , background : "var(--bg-secondary)" , height : "28px" } }
136131 >
137- < span className = "text-xs font-semibold mr-auto" style = { { color : "var(--text-primary)" } } >
138- { span . span_name }
139- </ span >
132+ < button
133+ onClick = { ( ) => setDetailMode ( "table" ) }
134+ className = "px-2 h-[18px] text-[10px] uppercase tracking-wider font-semibold rounded transition-colors cursor-pointer inline-flex items-center"
135+ style = { {
136+ color : detailMode === "table" ? "var(--accent)" : "var(--text-muted)" ,
137+ background : detailMode === "table" ? "color-mix(in srgb, var(--accent) 10%, transparent)" : "transparent" ,
138+ } }
139+ onMouseEnter = { ( e ) => { if ( detailMode !== "table" ) e . currentTarget . style . color = "var(--text-primary)" ; } }
140+ onMouseLeave = { ( e ) => { if ( detailMode !== "table" ) e . currentTarget . style . color = "var(--text-muted)" ; } }
141+ >
142+ Table
143+ </ button >
144+ < button
145+ onClick = { ( ) => setDetailMode ( "json" ) }
146+ className = "px-2 h-[18px] text-[10px] uppercase tracking-wider font-semibold rounded transition-colors cursor-pointer inline-flex items-center"
147+ style = { {
148+ color : detailMode === "json" ? "var(--accent)" : "var(--text-muted)" ,
149+ background : detailMode === "json" ? "color-mix(in srgb, var(--accent) 10%, transparent)" : "transparent" ,
150+ } }
151+ onMouseEnter = { ( e ) => { if ( detailMode !== "json" ) e . currentTarget . style . color = "var(--text-primary)" ; } }
152+ onMouseLeave = { ( e ) => { if ( detailMode !== "json" ) e . currentTarget . style . color = "var(--text-muted)" ; } }
153+ >
154+ JSON
155+ </ button >
140156 < span
141- className = "shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider"
157+ className = "ml-auto shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider"
142158 style = { {
143159 background : `color-mix(in srgb, ${ status . color } 15%, var(--bg-secondary))` ,
144160 color : status . color ,
@@ -147,87 +163,103 @@ export default function SpanDetails({ span }: Props) {
147163 < span className = "inline-block w-1.5 h-1.5 rounded-full" style = { { background : status . color } } />
148164 { status . label }
149165 </ span >
150- { span . duration_ms != null && (
151- < span className = "shrink-0 font-mono text-[11px] font-semibold" style = { { color : "var(--warning)" } } >
152- { formatDuration ( span . duration_ms ) }
153- </ span >
154- ) }
155- < span className = "shrink-0 font-mono text-[11px]" style = { { color : "var(--text-muted)" } } >
156- { time }
157- </ span >
158166 </ div >
159167
160- { /* Attributes — collapsible */ }
161- { attrEntries . length > 0 && (
168+ { /* Scrollable content */ }
169+ < div className = "overflow-y-auto flex-1 p-0.5 pr-0 pt-0 mr-0.5 mt-0.5" >
170+ { detailMode === "table" ? (
162171 < >
163- < div
164- className = "px-2 py-1 text-[10px] uppercase font-bold tracking-wider border-b cursor-pointer flex items-center"
165- style = { { color : "var(--accent)" , borderColor : "var(--border)" , background : "var(--bg-secondary)" } }
166- onClick = { ( ) => setAttrsOpen ( ( o ) => ! o ) }
167- >
168- < span className = "flex-1" > Attributes ({ attrEntries . length } )</ span >
169- < span style = { { color : "var(--text-muted)" , transform : attrsOpen ? "rotate(0deg)" : "rotate(-90deg)" } } >
170- ▾
171- </ span >
172- </ div >
173- { attrsOpen && attrEntries . map ( ( [ key , value ] , idx ) => (
172+ { /* Attributes — collapsible */ }
173+ { attrEntries . length > 0 && (
174+ < >
174175 < div
175- key = { key }
176- className = "flex gap-2 px-2 py-1 items-start border-b"
177- style = { {
178- borderColor : "var(--border)" ,
179- background : idx % 2 === 0 ? "var(--bg-primary)" : "var(--bg-secondary)" ,
180- } }
176+ className = "px-2 py-1 text-[10px] uppercase font-bold tracking-wider border-b cursor-pointer flex items-center"
177+ style = { { color : "var(--accent)" , borderColor : "var(--border)" , background : "var(--bg-secondary)" } }
178+ onClick = { ( ) => setAttrsOpen ( ( o ) => ! o ) }
181179 >
182- < span
183- className = "font-mono font-semibold shrink-0 pt-px truncate text-[11px]"
184- style = { { color : "var(--info)" , width : "35%" } }
185- title = { key }
186- >
187- { key }
188- </ span >
189- < span className = "flex-1 min-w-0" >
190- < AttributeValue value = { value } />
180+ < span className = "flex-1" > Attributes ({ attrEntries . length } )</ span >
181+ < span style = { { color : "var(--text-muted)" , transform : attrsOpen ? "rotate(0deg)" : "rotate(-90deg)" } } >
182+ ▾
191183 </ span >
192184 </ div >
193- ) ) }
194- </ >
195- ) }
185+ { attrsOpen && attrEntries . map ( ( [ key , value ] , idx ) => (
186+ < div
187+ key = { key }
188+ className = "flex gap-2 px-2 py-1 items-start border-b"
189+ style = { {
190+ borderColor : "var(--border)" ,
191+ background : idx % 2 === 0 ? "var(--bg-primary)" : "var(--bg-secondary)" ,
192+ } }
193+ >
194+ < span
195+ className = "font-mono font-semibold shrink-0 pt-px truncate text-[11px]"
196+ style = { { color : "var(--info)" , width : "35%" } }
197+ title = { key }
198+ >
199+ { key }
200+ </ span >
201+ < span className = "flex-1 min-w-0" >
202+ < AttributeValue value = { value } />
203+ </ span >
204+ </ div >
205+ ) ) }
206+ </ >
207+ ) }
196208
197- { /* Identifiers — collapsible */ }
198- < div
199- className = "px-2 py-1 text-[10px] uppercase font-bold tracking-wider border-b cursor-pointer flex items-center"
200- style = { { color : "var(--accent)" , borderColor : "var(--border)" , background : "var(--bg-secondary)" } }
201- onClick = { ( ) => setIdsOpen ( ( o ) => ! o ) }
202- >
203- < span className = "flex-1" > Identifiers ({ ids . length } )</ span >
204- < span style = { { color : "var(--text-muted)" , transform : idsOpen ? "rotate(0deg)" : "rotate(-90deg)" } } >
205- ▾
206- </ span >
207- </ div >
208- { idsOpen && ids . map ( ( id , idx ) => (
209+ { /* Identifiers — collapsible */ }
209210 < div
210- key = { id . label }
211- className = "flex gap-2 px-2 py-1 items-start border-b"
212- style = { {
213- borderColor : "var(--border)" ,
214- background : idx % 2 === 0 ? "var(--bg-primary)" : "var(--bg-secondary)" ,
215- } }
211+ className = "px-2 py-1 text-[10px] uppercase font-bold tracking-wider border-b cursor-pointer flex items-center"
212+ style = { { color : "var(--accent)" , borderColor : "var(--border)" , background : "var(--bg-secondary)" } }
213+ onClick = { ( ) => setIdsOpen ( ( o ) => ! o ) }
216214 >
217- < span
218- className = "font-mono font-semibold shrink-0 pt-px truncate text-[11px]"
219- style = { { color : "var(--info)" , width : "35%" } }
220- title = { id . label }
221- >
222- { id . label }
215+ < span className = "flex-1" > Identifiers ({ ids . length } )</ span >
216+ < span style = { { color : "var(--text-muted)" , transform : idsOpen ? "rotate(0deg)" : "rotate(-90deg)" } } >
217+ ▾
223218 </ span >
224- < span className = "flex-1 min-w-0" >
225- < span className = "font-mono text-[11px] break-all" style = { { color : "var(--text-primary)" } } >
226- { id . value }
219+ </ div >
220+ { idsOpen && ids . map ( ( id , idx ) => (
221+ < div
222+ key = { id . label }
223+ className = "flex gap-2 px-2 py-1 items-start border-b"
224+ style = { {
225+ borderColor : "var(--border)" ,
226+ background : idx % 2 === 0 ? "var(--bg-primary)" : "var(--bg-secondary)" ,
227+ } }
228+ >
229+ < span
230+ className = "font-mono font-semibold shrink-0 pt-px truncate text-[11px]"
231+ style = { { color : "var(--info)" , width : "35%" } }
232+ title = { id . label }
233+ >
234+ { id . label }
227235 </ span >
228- </ span >
236+ < span className = "flex-1 min-w-0" >
237+ < span className = "font-mono text-[11px] break-all" style = { { color : "var(--text-primary)" } } >
238+ { id . value }
239+ </ span >
240+ </ span >
241+ </ div >
242+ ) ) }
243+ </ >
244+ ) : (
245+ < div className = "relative" >
246+ < button
247+ onClick = { copySpanJson }
248+ className = "absolute top-1 right-1 z-10 text-[10px] cursor-pointer px-1.5 py-0.5 rounded transition-colors"
249+ style = { {
250+ color : copied ? "var(--success)" : "var(--text-muted)" ,
251+ background : "var(--bg-secondary)" ,
252+ border : "1px solid var(--border)" ,
253+ } }
254+ onMouseEnter = { ( e ) => { if ( ! copied ) e . currentTarget . style . color = "var(--text-primary)" ; } }
255+ onMouseLeave = { ( e ) => { e . currentTarget . style . color = copied ? "var(--success)" : "var(--text-muted)" ; } }
256+ >
257+ { copied ? "Copied!" : "Copy" }
258+ </ button >
259+ < JsonHighlight json = { spanJson } className = "font-mono text-[11px] whitespace-pre-wrap p-2" style = { { } } />
229260 </ div >
230- ) ) }
261+ ) }
262+ </ div >
231263 </ div >
232264 ) ;
233265}
0 commit comments