Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions js-packages/common-ui/src/lib/TabsPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
panel: Component<T>
keepAlive: boolean
tabBarEnd?: Snippet
/** When true, the trigger is rendered but cannot be selected. Use for tabs whose data the
* loaded profile does not carry. */
disabled?: boolean
}

/** Visual style of the tab strip:
Expand Down Expand Up @@ -53,7 +56,9 @@
// Segment items only need a `value`; the visual label is rendered via the `segmentLabel`
// snippet, which dispatches back to each TabSpec's own `label` snippet so a tab definition
// stays the single source of truth regardless of the chosen variant.
const segmentItems = $derived<SegmentedItem<string>[]>(tabs.map((t) => ({ value: t.id })))
const segmentItems = $derived<SegmentedItem<string>[]>(
tabs.map((t) => ({ value: t.id, disabled: t.disabled }))
)
</script>

{#snippet defaultTabContainer(tab: Snippet, hidden: boolean)}
Expand Down Expand Up @@ -113,12 +118,15 @@
minimum width on the surrounding Pane. `min-w-0` on the row + a row gap keeps the
wrapped rows visually grouped. -->
<Tabs.List class="flex w-full min-w-0 flex-wrap items-center gap-0 pb-0 mb-0 {headerClass}">
{#each tabs as { id, label }}
{#each tabs as { id, label, disabled }}
<Tabs.Trigger
value={id}
{disabled}
class="btn h-9 font-medium whitespace-nowrap {id === currentTab
? 'border-surface-950-50 '
: 'rounded hover:bg-surface-100-900/50'}"
: 'rounded hover:bg-surface-100-900/50'} {disabled
? 'cursor-not-allowed opacity-40'
: ''}"
>
{@render label()}
</Tabs.Trigger>
Expand Down
9 changes: 8 additions & 1 deletion js-packages/profiler-app/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {
createLoadGuard,
getSuitableProfiles,
type GlobalMetrics,
processProfileFiles,
SupportBundleViewerLayout,
type ZipItem
Expand All @@ -23,6 +24,8 @@
dataflow: Dataflow | undefined
sources: string[] | undefined
logText: string | undefined
globalMetrics: GlobalMetrics | undefined
runtimeConfig: unknown
} | null = $state(null)

let fileInput: HTMLInputElement | null = $state(null)
Expand All @@ -44,7 +47,9 @@
profile: data.profile,
dataflow: data.dataflow,
sources: data.sources,
logText: data.logText
logText: data.logText,
globalMetrics: data.globalMetrics,
runtimeConfig: data.runtimeConfig
}
}

Expand Down Expand Up @@ -111,6 +116,8 @@
dataflowData={profileData.dataflow}
programCode={profileData.sources}
logText={profileData.logText}
globalMetrics={profileData.globalMetrics}
runtimeConfig={profileData.runtimeConfig}
triageResults={new TriageResults()}
{profileFiles}
{selectedTimestamp}
Expand Down
23 changes: 23 additions & 0 deletions js-packages/profiler-layout/src/lib/components/ConfigView.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
/** Pipeline runtime configuration, as parsed from `pipeline_config.json`. */
runtimeConfig: unknown
}

let { runtimeConfig }: Props = $props()

// Pretty-print the config for a read-only view.
const formatted = $derived(
runtimeConfig === undefined ? '' : JSON.stringify(runtimeConfig, null, 2)
)
</script>

<div class="absolute inset-0 overflow-auto scrollbar bg-white-dark rounded">
{#if formatted === ''}
<div class="flex h-full items-center justify-center font-mono text-sm text-surface-600-400">
No runtime configuration available in this bundle
</div>
{:else}
<pre class="p-4 font-mono text-sm text-surface-900-100">{formatted}</pre>
{/if}
</div>
25 changes: 22 additions & 3 deletions js-packages/profiler-layout/src/lib/components/MetricsView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,19 @@

<script lang="ts">
import type { TooltipData } from './ProfilerTooltip.svelte'
import KeyValueBlock from './metrics/blocks/KeyValueBlock.svelte'
import MetricsDistributionBlock from './metrics/blocks/MetricsDistributionBlock.svelte'
import { buildBlocks, type RenderableBlock } from './metrics/dispatch'
import { buildGlobalMetrics, type GlobalMetrics } from '../functions/globalMetrics'
import type { LookupCoordinator } from '../functions/lookup'

interface Props {
mode: MetricsMode
tooltipData: TooltipData | null
/** The loaded profile's toplevel node id, used to recognise overview data. */
rootNodeId: string | undefined
/** Cumulative pipeline-wide metrics from `stats.json`; shown as a tile atop the overview. */
globalMetrics?: GlobalMetrics
/** When true, metrics flagged `advanced` in the profile metadata are included. */
showAdvanced: boolean
/** Lookup coordinator; the view registers an imperative handler so each Enter on the
Expand All @@ -47,7 +51,14 @@
onSearchNode?: (query: string) => void
}

const { mode, tooltipData, rootNodeId, showAdvanced, lookup, onSearchNode }: Props = $props()
const { mode, tooltipData, rootNodeId, globalMetrics, showAdvanced, lookup, onSearchNode }: Props =
$props()

// Pipeline-wide totals for the overview's "Global stats" tile. Empty (so the tile is hidden) on
// any non-overview view or when the bundle carried no stats.
const globalMetricEntries = $derived(
mode === 'overview' ? buildGlobalMetrics(globalMetrics) : []
)

const nodeAttributes = $derived(
tooltipData && 'nodeAttributes' in tooltipData ? tooltipData.nodeAttributes : null
Expand Down Expand Up @@ -131,7 +142,7 @@
</script>

{#snippet attributesView()}
{#if !nodeAttributes}
{#if !nodeAttributes && globalMetricEntries.length === 0}
<div class="flex flex-1 items-center justify-center text-sm text-surface-600-400">
{#if mode === 'node'}
Click a node in the graph to see its metrics.
Expand All @@ -140,7 +151,7 @@
{/if}
</div>
{:else}
{#if isNodeView}
{#if nodeAttributes && isNodeView}
<div class="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-base">
<button
type="button"
Expand All @@ -160,6 +171,14 @@
otherwise one column. CSS multi-column flow auto-distributes blocks; the column
count is driven by the ResizeObserver on the scroll container. -->
<div class="gap-3" style="column-count: {useTwoColumns ? 2 : 1};">
<!-- Overview-only cumulative pipeline metrics from `stats.json`, tiled alongside the node
blocks. Driven by the bundle's global metrics, not the selected node, so it shows in the
overview even when the root node carries no attributes and no node tooltip is produced. -->
{#if globalMetricEntries.length > 0}
<div class="mb-3 break-inside-avoid">
<KeyValueBlock id="global-metrics" title="Global stats" entries={globalMetricEntries} />
</div>
{/if}
{#each blocks as b (b.id)}
<div class="mb-3 break-inside-avoid">
<MetricsDistributionBlock id={b.id} title={b.title} entries={b.entries} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,33 @@
} from 'profiler-lib'
import { type Snippet, untrack } from 'svelte'
import type { TriageResults } from 'triage-types'
import type { GlobalMetrics } from '../functions/globalMetrics'
import { createLookupCoordinator } from '../functions/lookup'
import { type AnalysisView, isNodeView, metricsModeOf } from '../functions/metricsMode'
import { severityLabel, uniqueCategories, uniqueSeverities } from '../functions/triage'
import type { MetricsMode } from './MetricsView.svelte'
import ProfilerDiagram from './ProfilerDiagram.svelte'
import type { TooltipData } from './ProfilerTooltip.svelte'
import ProfileTimestampSelector from './ProfileTimestampSelector.svelte'
import ConfigTab from './tabs/ConfigTab.svelte'
import IssuesTab from './tabs/IssuesTab.svelte'
import LogsTab from './tabs/LogsTab.svelte'
import MetricsTab, { type AnalysisTabProps } from './tabs/MetricsTab.svelte'
import SqlTab, { type SqlTabProps } from './tabs/SqlTab.svelte'

const TABS = ['Metrics', 'Logs', 'Issues'] as const
const TABS = ['Metrics', 'Logs', 'Config', 'Issues'] as const
type AnalysisTab = (typeof TABS)[number]

interface Props {
profileData: JsonProfiles
dataflowData: Dataflow | undefined
programCode: string[] | undefined
logText?: string
/** Cumulative pipeline-wide metrics from the bundle's `stats.json`; shown in the overview. */
globalMetrics?: GlobalMetrics
/** Pipeline runtime configuration from the bundle's `pipeline_config.json`; shown in the Config
* tab. Absent when the bundle carried no config. */
runtimeConfig?: unknown
triageResults: TriageResults
profileFiles: [Date, ZipItem[]][]
selectedTimestamp: Date | null
Expand All @@ -61,6 +68,8 @@
dataflowData,
programCode,
logText,
globalMetrics,
runtimeConfig,
triageResults,
profileFiles,
selectedTimestamp,
Expand Down Expand Up @@ -261,6 +270,8 @@
metricsMode,
tooltipData,
rootNodeId: diagramRootNodeId,
globalMetrics,
runtimeConfig,
showAdvancedMetrics,
lookup,
logText,
Expand All @@ -285,6 +296,14 @@
keepAlive: true,
tabBarEnd: commonTabBarEnd
},
{
id: 'Config',
label: configLabel,
panel: ConfigTab,
keepAlive: true,
// No runtime config in the bundle → nothing to show, so the tab is present but unselectable.
disabled: runtimeConfig === undefined
},
{
id: 'Issues',
label: issuesLabel,
Expand Down Expand Up @@ -355,6 +374,7 @@
<!-- ── Analysis panel labels and tab-bar-end snippets ────────────────────────── -->
{#snippet metricsLabel()}Metrics{/snippet}
{#snippet logsLabel()}Logs{/snippet}
{#snippet configLabel()}Config{/snippet}
{#snippet issuesLabel()}
Issues &amp; Suggestions
{#if triageResults.results.length > 0}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script lang="ts">
import type { GlobalMetricEntry } from '../../../functions/globalMetrics'

interface Props {
id: string
title?: string
/** Rows to print, each already carrying a formatted `PropertyValue`. */
entries: GlobalMetricEntry[]
}
const { id, title, entries }: Props = $props()
</script>

<!-- A plain text tile: one "label … value" row per metric. Unlike the distribution block it
has no per-worker bars — these are single pipeline-wide figures — so the value is rendered
straight from `PropertyValue.toString()`, reusing the same unit-aware formatting. -->
<div class="rounded-container bg-white-dark px-4 py-2 shadow-sm" data-block-id={id}>
{#if title}
<h3 class="mb-2 text-base font-semibold text-surface-900-100">{title}</h3>
{/if}
<dl class="grid grid-cols-[1fr_auto] items-baseline gap-x-6 gap-y-2">
{#each entries as entry (entry.key)}
<dt class="truncate text-sm text-surface-700-300">{entry.label}</dt>
<dd class="text-right text-sm font-medium tabular-nums text-surface-900-100">
{entry.value.toString()}
</dd>
{/each}
</dl>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script lang="ts">
import ConfigView from '../ConfigView.svelte'
import type { AnalysisTabProps } from './MetricsTab.svelte'

let { runtimeConfig }: AnalysisTabProps = $props()
</script>

<div class="relative min-h-0 flex-1">
<ConfigView {runtimeConfig} />
</div>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts" module>
import type { TriageResults } from 'triage-types'
import type { GlobalMetrics } from '../../functions/globalMetrics'
import type { LookupCoordinator } from '../../functions/lookup'
import type { MetricsMode } from '../MetricsView.svelte'
import type { TooltipData } from '../ProfilerTooltip.svelte'
Expand All @@ -12,6 +13,12 @@
/** Id of the loaded profile's toplevel node, so the Metrics view can tell the overview from a
* single operator. `undefined` until a profile is loaded. */
rootNodeId: string | undefined
/** Cumulative pipeline-wide metrics from `stats.json`, shown as a tile in the overview.
* `undefined` when the bundle carried no stats. */
globalMetrics: GlobalMetrics | undefined
/** Pipeline runtime configuration shown in the Config tab. `undefined` when the bundle carried
* no `pipeline_config.json` */
runtimeConfig: unknown
/** When true, metrics flagged `advanced` in the profile metadata are shown too. */
showAdvancedMetrics: boolean
lookup: LookupCoordinator
Expand All @@ -34,6 +41,7 @@
metricsMode,
tooltipData,
rootNodeId,
globalMetrics,
showAdvancedMetrics,
lookup,
onSearchNode
Expand All @@ -44,6 +52,7 @@
mode={metricsMode}
{tooltipData}
{rootNodeId}
{globalMetrics}
showAdvanced={showAdvancedMetrics}
{lookup}
{onSearchNode}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { buildGlobalMetrics, type GlobalMetrics } from './globalMetrics.js'

/** A fully-populated stats payload, including current gauges that the tile must drop. */
function fullMetrics(): GlobalMetrics & Record<string, number> {
return {
total_input_records: 1500,
total_input_bytes: 2048,
total_processed_records: 1400,
total_processed_bytes: 1024,
total_completed_records: 1300,
total_initiated_steps: 7,
total_completed_steps: 6,
cpu_msecs: 5000,
runtime_elapsed_msecs: 12_000,
uptime_msecs: 90_000,
// Current gauges — must be excluded from the overview tile.
rss_bytes: 999_999,
storage_bytes: 888_888,
buffered_input_records: 42,
buffered_input_bytes: 64
}
}

describe('buildGlobalMetrics', () => {
it('returns an empty list when no stats are present', () => {
expect(buildGlobalMetrics(undefined)).toEqual([])
expect(buildGlobalMetrics({})).toEqual([])
})

it('excludes current gauges such as rss_bytes and storage_bytes', () => {
const keys = buildGlobalMetrics(fullMetrics()).map((e) => e.key)
expect(keys).not.toContain('rss_bytes')
expect(keys).not.toContain('storage_bytes')
expect(keys).not.toContain('buffered_input_records')
expect(keys).not.toContain('buffered_input_bytes')
})

it('emits the cumulative metrics in a stable presentation order', () => {
const keys = buildGlobalMetrics(fullMetrics()).map((e) => e.key)
expect(keys).toEqual([
'total_input_records',
'total_input_bytes',
'total_processed_records',
'total_processed_bytes',
'total_completed_records',
'total_initiated_steps',
'total_completed_steps',
'cpu_msecs',
'runtime_elapsed_msecs',
'uptime_msecs'
])
})

it('formats each value with the matching unit', () => {
const byKey = new Map(buildGlobalMetrics(fullMetrics()).map((e) => [e.key, e.value.toString()]))
expect(byKey.get('total_input_records')).toBe('1.5K') // count, base 1000
expect(byKey.get('total_input_bytes')).toBe('2KiB') // bytes, base 1024
expect(byKey.get('cpu_msecs')).toBe('5s') // milliseconds rendered as seconds
})

it('skips fields that are absent or not finite numbers', () => {
const entries = buildGlobalMetrics({
total_input_records: 10,
total_input_bytes: Number.NaN,
total_processed_records: Number.POSITIVE_INFINITY,
// A stray non-numeric value (e.g. from a hand-edited stats file) is ignored, not rendered.
cpu_msecs: '5000' as unknown as number
})
expect(entries.map((e) => e.key)).toEqual(['total_input_records'])
})
})
Loading
Loading