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
1 change: 1 addition & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ RUN curl -fsSL https://claude.ai/install.sh | bash

RUN \
rustup install $RUST_VERSION && \
rustup default $RUST_VERSION && \
rustup component add rustfmt && \
rustup component add clippy && \
cargo install cargo-make
Expand Down
8 changes: 4 additions & 4 deletions js-packages/profiler-app/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
let sqlPanelFullHeight = $state(false)

let profileData: {
profile: JsonProfiles
profile: JsonProfiles | undefined
dataflow: Dataflow | undefined
sources: string[] | undefined
logText: string | undefined
Expand Down Expand Up @@ -65,7 +65,7 @@
const suitableProfiles = getSuitableProfiles(zipData)
if (suitableProfiles.length === 0) {
throw new Error(
'No suitable profiles found in the uploaded support bundle. Check if it contains the circuit profile and dataflow graph (optional).'
'No readable data found in the uploaded support bundle. Check that it contains a circuit profile, config, logs or stats.'
)
}
profileFiles = suitableProfiles
Expand Down Expand Up @@ -147,8 +147,8 @@

<div class="bg-surface-50 max-w-md rounded-container p-6 shadow-lg">
<p class="mb-6 text-sm text-surface-700">
Load a Feldera support bundle (.zip file) containing circuit profile data to begin
visualization.
Load a Feldera support bundle (.zip file) to explore the circuit profile,
program code, logs and the pipeline config.
</p>

{#if isLoading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
type AnalysisTab = (typeof TABS)[number]

interface Props {
profileData: JsonProfiles
/** Parsed circuit profile */
profileData?: JsonProfiles
dataflowData: Dataflow | undefined
programCode: string[] | undefined
logText?: string
Expand Down Expand Up @@ -81,6 +82,12 @@
onRenderingChange
}: Props = $props()

const hasProfile = $derived(profileData !== undefined)

const graphPaneDefaultSize = 55
const graphPaneMinSize = 20
const belowGraphPaneDefaultSize = 100 - graphPaneDefaultSize

let profilerDiagram: ProfilerDiagram | undefined = $state()
// The loaded profile's toplevel node id, so the analysis panel can recognise overview data.
const diagramRootNodeId = $derived(profilerDiagram?.getProfile()?.rootNodeId)
Expand Down Expand Up @@ -137,7 +144,6 @@
const attrs = data.match({ some: (v) => v, none: () => null })
untrack(() => {
if (attrs) {
currentTab = 'Metrics'
tooltipData = { nodeAttributes: attrs }
// Only cache while we're on a node view, so an `'overview'` round-trip can't
// overwrite the operator the user actually inspected.
Expand Down Expand Up @@ -205,19 +211,18 @@
profilerDiagram?.selectMetric(selectedMetricId)
})

// Show the overview each time a profile loads. `analysisView` is reset first so the
// Reset to the overview whenever the loaded bundle changes. `analysisView` is reset first so the
// SegmentedControl indicator follows the new view, and so the sticky `displayNodeAttributes`
// that `showGlobalMetrics` triggers sees a non-node view and can't overwrite `lastNodeData`
// with the overview payload.
$effect(() => {
void profileData
const diagram = profilerDiagram
if (!diagram) {
return
}
queueMicrotask(() => {
analysisView = 'overview'
diagram.showGlobalMetrics(true)
tooltipData = null
lastNodeData = null
diagram?.showGlobalMetrics(true)
})
})

Expand Down Expand Up @@ -316,42 +321,54 @@

<!-- ── Graph panel (dataflow graph) ────────────────────────────────────────── -->
{#snippet graphPanel()}
<div class="flex h-full flex-col overflow-hidden rounded-container bg-surface-50-950">
<div
class="flex flex-col overflow-hidden rounded-container bg-surface-50-950 {hasProfile
? 'h-full'
: ''}"
>
<!-- Header -->
<div class="flex flex-shrink-0 flex-wrap items-center gap-2 p-4">
{@render loadProfileControl?.()}
<ProfileTimestampSelector {profileFiles} {selectedTimestamp} {onSelectTimestamp} />
<div class="ml-auto">
<input
bind:value={nodeSearchQuery}
type="text"
placeholder="Search node"
title="Search for a node by ID or persistent ID"
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
class="input h-6 w-36 text-sm"
/>
</div>
{#if hasProfile}
<div class="ml-auto">
<input
bind:value={nodeSearchQuery}
type="text"
placeholder="Search node"
title="Search for a node by ID or persistent ID"
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
class="input h-6 w-36 text-sm"
/>
</div>
{:else}
<p class="text-sm text-surface-600-400">
This bundle has no circuit profile, so the dataflow graph is unavailable.
</p>
{/if}
</div>
<!-- Diagram slot. The actual <ProfilerDiagram> is rendered once outside the layout-
toggle as a <PersistentContent> overlay so it survives the sqlPanelFullHeight
layout toggle without re-initialising the Visualizer. The `use:` action mirrors
this div's bounding rect onto the shared handle. -->
<div use:diagramRect.placeholder class="relative min-h-0 flex-1 bg-white-dark">
{#if error}
<div
class="absolute inset-x-4 top-4 z-10 rounded border border-red-300 bg-white p-3 font-mono text-sm text-red-600 shadow"
>
{error}
</div>
{/if}
{#if message}
<div
class="absolute left-2 top-2 z-10 rounded bg-white/90 px-2 py-1 font-mono text-sm shadow"
>
{message}
</div>
{/if}
</div>
{#if hasProfile}
<div use:diagramRect.placeholder class="relative min-h-0 flex-1 bg-white-dark">
{#if error}
<div
class="absolute inset-x-4 top-4 z-10 rounded border border-red-300 bg-white p-3 font-mono text-sm text-red-600 shadow"
>
{error}
</div>
{/if}
{#if message}
<div
class="absolute left-2 top-2 z-10 rounded bg-white/90 px-2 py-1 font-mono text-sm shadow"
>
{message}
</div>
{/if}
</div>
{/if}
</div>
{/snippet}

Expand Down Expand Up @@ -446,7 +463,7 @@
items={[
{ value: 'overview', label: 'Overview' },
{ value: 'node', label: 'Node', disabled: !lastNodeData },
{ value: 'top-nodes', label: 'Top nodes' }
{ value: 'top-nodes', label: 'Top nodes', disabled: !hasProfile }
]}
class="px-2"
/>
Expand Down Expand Up @@ -491,11 +508,17 @@
<!-- ══ Layout: normal (graph top, SQL + tabs bottom) ══════════════════════ -->
{#if !sqlPanelFullHeight}
<PaneGroup direction="vertical" class="!overflow-visible h-full">
<Pane defaultSize={55} minSize={20} class="!overflow-visible">
{@render graphPanel()}
</Pane>
<PaneResizer class="pane-divider-horizontal my-2" />
<Pane defaultSize={45} minSize={15} class="!overflow-visible">
{#if hasProfile}
<Pane defaultSize={graphPaneDefaultSize} minSize={graphPaneMinSize} class="!overflow-visible">
{@render graphPanel()}
</Pane>
<PaneResizer class="pane-divider-horizontal my-2" />
{:else}
<div class="mb-4">
{@render graphPanel()}
</div>
{/if}
<Pane defaultSize={belowGraphPaneDefaultSize} minSize={15} class="!overflow-visible">
<PaneGroup direction="horizontal" class="!overflow-visible h-full">
<Pane defaultSize={50} minSize={0} class="!overflow-visible">
<div use:sqlPanelRect.placeholder class="h-full"></div>
Expand All @@ -516,11 +539,21 @@
<PaneResizer class="pane-divider-vertical mx-1.5" />
<Pane minSize={20} class="!overflow-visible">
<PaneGroup direction="vertical" class="!overflow-visible h-full">
<Pane defaultSize={55} minSize={20} class="!overflow-visible">
{@render graphPanel()}
</Pane>
<PaneResizer class="pane-divider-horizontal my-2" />
<Pane defaultSize={45} minSize={15} class="!overflow-visible">
{#if hasProfile}
<Pane
defaultSize={graphPaneDefaultSize}
minSize={graphPaneMinSize}
class="!overflow-visible"
>
{@render graphPanel()}
</Pane>
<PaneResizer class="pane-divider-horizontal my-2" />
{:else}
<div class="mb-4">
{@render graphPanel()}
</div>
{/if}
<Pane defaultSize={belowGraphPaneDefaultSize} minSize={15} class="!overflow-visible">
{@render analysisPanel()}
</Pane>
</PaneGroup>
Expand All @@ -532,15 +565,17 @@
<PersistentPlaceholder> in graphPanel. Surviving the layout toggle (and any other
conditional re-render of graphPanel) is what keeps the Visualizer's internal state intact
instead of being torn down and re-created from `profileData` each time. -->
<PersistentContent persistent={diagramRect} class="overflow-hidden bg-white-dark">
<ProfilerDiagram
bind:this={profilerDiagram}
{profileData}
{dataflowData}
{programCode}
{callbacks}
/>
</PersistentContent>
{#if profileData}
<PersistentContent persistent={diagramRect} class="overflow-hidden bg-white-dark">
<ProfilerDiagram
bind:this={profilerDiagram}
{profileData}
{dataflowData}
{programCode}
{callbacks}
/>
</PersistentContent>
{/if}

<!-- Persistent SQL panel overlay. Hoisted out of the layout branches for the same reason as the
diagram: rendered once, it mirrors the placeholder in whichever {#if} branch is active, so the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ZipItem } from 'but-unzip'
import { describe, expect, it } from 'vitest'
import { processProfileFiles } from './processZipBundle.js'
import { processProfileFiles, selectProfiles } from './processZipBundle.js'

/** Build a ZipItem whose `read()` returns the UTF-8 bytes of `text`. */
function file(filename: string, text: string): ZipItem {
Expand All @@ -9,19 +9,61 @@ function file(filename: string, text: string): ZipItem {

const TIMESTAMP = '2026-05-25T12-34-56.789Z'

/** A minimal circuit profile; `processProfileFiles` requires this file to exist. */
function circuitProfile(): ZipItem {
return file(`${TIMESTAMP}/circuit_profile.json`, JSON.stringify({ nodes: [] }))
/** A minimal circuit profile. */
function circuitProfile(dir = TIMESTAMP): ZipItem {
return file(`${dir}/circuit_profile.json`, JSON.stringify({ nodes: [] }))
}

/** A `pipeline_config.json` with the given extra top-level fields alongside `program_code`. */
function pipelineConfig(extra: Record<string, unknown>): ZipItem {
function pipelineConfig(extra: Record<string, unknown>, dir = TIMESTAMP): ZipItem {
return file(
`${TIMESTAMP}/pipeline_config.json`,
`${dir}/pipeline_config.json`,
JSON.stringify({ program_code: 'SELECT 1;', name: 'p', ...extra })
)
}

function logs(dir = TIMESTAMP): ZipItem {
return file(`${dir}/logs.txt`, 'starting up\n')
}

describe('selectProfiles', () => {
it('keeps a collection that has no circuit profile but does have other files', () => {
// Regression: the old filter required a circuit_profile.json, so a config/logs-only bundle
// was dropped and refused to open.
const collections = selectProfiles([pipelineConfig({}), logs()])
expect(collections).toHaveLength(1)
expect(collections[0][1]).toHaveLength(2)
})

it('drops files that carry no recognised content', () => {
const collections = selectProfiles([
file(`${TIMESTAMP}/unknown.bin`, 'x'),
file('README.txt', 'not in a collection dir')
])
expect(collections).toEqual([])
})

it('sorts multiple collections oldest-first', () => {
const older = '2026-05-25T12-00-00.000Z'
const newer = '2026-05-25T13-00-00.000Z'
const collections = selectProfiles([circuitProfile(newer), logs(older)])
expect(collections.map(([d]) => d.getTime())).toEqual([
new Date('2026-05-25T12:00:00.000Z').getTime(),
new Date('2026-05-25T13:00:00.000Z').getTime()
])
})
})

describe('processProfileFiles without a circuit profile', () => {
it('leaves profile undefined while still extracting config and logs', async () => {
const result = await processProfileFiles([pipelineConfig({}), logs()])
expect(result.profile).toBeUndefined()
expect(result.sources).toEqual(['SELECT 1;'])
expect(result.pipelineName).toBe('p')
expect(result.logText).toBe('starting up\n')
})
})

describe('processProfileFiles runtimeConfig', () => {
it('extracts runtime_config from pipeline_config.json', async () => {
const runtime_config = { workers: 8, storage: { backend: { name: 'default' } } }
Expand Down
Loading
Loading