diff --git a/runtime/parser/parse_canvas.go b/runtime/parser/parse_canvas.go index 517bdd345fa..6288f9093b3 100644 --- a/runtime/parser/parse_canvas.go +++ b/runtime/parser/parse_canvas.go @@ -255,7 +255,62 @@ func (p *Parser) parseCanvas(node *Node) error { } } - // Track canvas + // Collect metrics view refs from components for direct Canvas -> MetricsView links in the DAG + // This must be done BEFORE calling insertResource so the refs are included + metricsViewRefs := make(map[ResourceName]bool) + // Extract from inline components + for _, def := range inlineComponentDefs { + for _, ref := range def.refs { + if ref.Kind == ResourceKindMetricsView { + metricsViewRefs[ref] = true + } + } + } + // Extract from external components + for _, row := range tmp.Rows { + for _, item := range row.Items { + if item.Component != "" { + // Check if this is an external component (not inline) + isInline := false + for _, def := range inlineComponentDefs { + if def.name == item.Component { + isInline = true + break + } + } + if !isInline { + // Look up the external component + componentName := ResourceName{Kind: ResourceKindComponent, Name: item.Component} + if component, ok := p.Resources[componentName.Normalized()]; ok { + // Extract metrics view refs from the component + // Check Refs first (processed refs), fall back to rawRefs if Refs is empty + refsToCheck := component.Refs + if len(refsToCheck) == 0 { + refsToCheck = component.rawRefs + } + for _, ref := range refsToCheck { + if ref.Kind == ResourceKindMetricsView { + metricsViewRefs[ref] = true + } + } + } + } + } + } + } + // Add metrics view refs directly to canvas node refs BEFORE insertResource + // Check for duplicates to avoid adding refs that already exist + existingRefs := make(map[ResourceName]bool) + for _, ref := range node.Refs { + existingRefs[ref] = true + } + for ref := range metricsViewRefs { + if !existingRefs[ref] { + node.Refs = append(node.Refs, ref) + } + } + + // Track canvas (now with MetricsView refs included) r, err := p.insertResource(ResourceKindCanvas, node.Name, node.Paths, node.Refs...) if err != nil { return err diff --git a/runtime/parser/parse_node.go b/runtime/parser/parse_node.go index 4e950135a35..9685bb4ef4f 100644 --- a/runtime/parser/parse_node.go +++ b/runtime/parser/parse_node.go @@ -248,26 +248,26 @@ func (p *Parser) parseStem(paths []string, ymlPath, yml, sqlPath, sql string) (* v, ok := v.(string) if !ok { err = fmt.Errorf("invalid type %T for property 'type'", v) - break - } - res.Kind, err = ParseResourceKind(v) - if err != nil { - break + } else { + res.Kind, err = ParseResourceKind(v) } case "name": v, ok := v.(string) if !ok { err = fmt.Errorf("invalid type %T for property 'name'", v) - break + } else { + res.Name = v } - res.Name = v case "connector": v, ok := v.(string) if !ok { err = fmt.Errorf("invalid type %T for property 'connector'", v) - break + } else { + res.Connector = v } - res.Connector = v + } + if err != nil { + break } } if err != nil { diff --git a/runtime/parser/parser.go b/runtime/parser/parser.go index f5f25d4fbd7..51bc9b799d0 100644 --- a/runtime/parser/parser.go +++ b/runtime/parser/parser.go @@ -634,12 +634,13 @@ func (p *Parser) parsePaths(ctx context.Context, paths []string) error { // NOTE 2: Using a map since the two-way check (necessary for reparses) may match the same resource twice. modelsWithNameErrs := make(map[ResourceName]string) for _, r := range p.insertedResources { - if r.Name.Kind == ResourceKindSource { + switch r.Name.Kind { + case ResourceKindSource: n := ResourceName{Kind: ResourceKindModel, Name: r.Name.Name}.Normalized() if _, ok := p.Resources[n]; ok { modelsWithNameErrs[n] = r.Name.Name } - } else if r.Name.Kind == ResourceKindModel { + case ResourceKindModel: n := ResourceName{Kind: ResourceKindSource, Name: r.Name.Name}.Normalized() if r2, ok := p.Resources[n]; ok { modelsWithNameErrs[r.Name.Normalized()] = r2.Name.Name diff --git a/web-common/src/app.css b/web-common/src/app.css index 0849d06cc8c..9aa956cd166 100644 --- a/web-common/src/app.css +++ b/web-common/src/app.css @@ -117,7 +117,7 @@ --explore: var(--color-indigo-700); --metrics: var(--color-violet-600); --model: var(--color-cyan-600); - --source: var(--model); + --source: var(--color-emerald-600); --api: var(--color-orange-400); --data: var(--color-fuchsia-500); --theme: var(--color-pink-500); @@ -307,7 +307,7 @@ --explore: var(--color-indigo-light-400); --metrics: var(--color-violet-light-400); --model: var(--color-cyan-light-500); - --source: var(--model); + --source: var(--color-emerald-600); --api: var(--color-orange-light-300); --data: var(--color-fuchsia-light-400); --theme: var(--color-pink-light-400); diff --git a/web-common/src/features/entity-management/resource-icon-mapping.ts b/web-common/src/features/entity-management/resource-icon-mapping.ts index 9e349f2d665..381cdc260c5 100644 --- a/web-common/src/features/entity-management/resource-icon-mapping.ts +++ b/web-common/src/features/entity-management/resource-icon-mapping.ts @@ -47,7 +47,7 @@ export const resourceShorthandMapping = { [ResourceKind.Model]: "model", [ResourceKind.MetricsView]: "metrics", [ResourceKind.Explore]: "explore", - [ResourceKind.API]: "API", + [ResourceKind.API]: "api", [ResourceKind.Component]: "component", [ResourceKind.Canvas]: "canvas", [ResourceKind.Theme]: "theme", diff --git a/web-common/src/features/entity-management/resource-selectors.spec.ts b/web-common/src/features/entity-management/resource-selectors.spec.ts index bc8bb17f92a..c20d73d9850 100644 --- a/web-common/src/features/entity-management/resource-selectors.spec.ts +++ b/web-common/src/features/entity-management/resource-selectors.spec.ts @@ -4,13 +4,14 @@ import type { V1Resource } from "@rilldata/web-common/runtime-client"; describe("resource-selectors", () => { describe("coerceResourceKind", () => { - it("should return Model kind for normal models", () => { + it("should return Model kind for models with model dependencies", () => { const resource: V1Resource = { meta: { name: { kind: ResourceKind.Model, name: "orders", }, + refs: [{ kind: ResourceKind.Model, name: "raw_orders" }], }, model: { spec: { @@ -24,6 +25,48 @@ describe("resource-selectors", () => { expect(coerceResourceKind(resource)).toBe(ResourceKind.Model); }); + it("should return Source kind for root models with no model dependencies", () => { + const resource: V1Resource = { + meta: { + name: { + kind: ResourceKind.Model, + name: "raw_orders", + }, + refs: [], // No model refs - this is a root model + }, + model: { + spec: { + definedAsSource: false, + }, + state: { + resultTable: "raw_orders", + }, + }, + }; + expect(coerceResourceKind(resource)).toBe(ResourceKind.Source); + }); + + it("should return Source kind for models with only connector refs (no model refs)", () => { + const resource: V1Resource = { + meta: { + name: { + kind: ResourceKind.Model, + name: "raw_data", + }, + refs: [{ kind: ResourceKind.Connector, name: "duckdb" }], + }, + model: { + spec: { + definedAsSource: false, + }, + state: { + resultTable: "raw_data", + }, + }, + }; + expect(coerceResourceKind(resource)).toBe(ResourceKind.Source); + }); + it("should return Source kind for models defined-as-source with matching table name", () => { const resource: V1Resource = { meta: { @@ -31,6 +74,7 @@ describe("resource-selectors", () => { kind: ResourceKind.Model, name: "raw_orders", }, + refs: [{ kind: ResourceKind.Model, name: "other_model" }], }, model: { spec: { @@ -41,6 +85,7 @@ describe("resource-selectors", () => { }, }, }; + // definedAsSource takes precedence even with model refs expect(coerceResourceKind(resource)).toBe(ResourceKind.Source); }); @@ -51,6 +96,7 @@ describe("resource-selectors", () => { kind: ResourceKind.Model, name: "raw_orders", }, + refs: [{ kind: ResourceKind.Model, name: "other_model" }], }, model: { spec: { @@ -61,6 +107,7 @@ describe("resource-selectors", () => { }, }, }; + // Has model deps and definedAsSource doesn't match table name expect(coerceResourceKind(resource)).toBe(ResourceKind.Model); }); @@ -111,13 +158,14 @@ describe("resource-selectors", () => { expect(coerceResourceKind(resource)).toBe(ResourceKind.Explore); }); - it("should handle case where definedAsSource is false explicitly", () => { + it("should return Source for models with undefined refs (treated as no model deps)", () => { const resource: V1Resource = { meta: { name: { kind: ResourceKind.Model, name: "orders", }, + // No refs property - should be treated as no model dependencies }, model: { spec: { @@ -128,8 +176,7 @@ describe("resource-selectors", () => { }, }, }; - // Even though table name matches, definedAsSource is false - expect(coerceResourceKind(resource)).toBe(ResourceKind.Model); + expect(coerceResourceKind(resource)).toBe(ResourceKind.Source); }); }); }); diff --git a/web-common/src/features/entity-management/resource-selectors.ts b/web-common/src/features/entity-management/resource-selectors.ts index 8f0f0a4b06c..655a663a7b9 100644 --- a/web-common/src/features/entity-management/resource-selectors.ts +++ b/web-common/src/features/entity-management/resource-selectors.ts @@ -45,7 +45,7 @@ export function displayResourceKind(kind: ResourceKind | undefined) { case ResourceKind.Report: return "report"; case ResourceKind.Source: - return "source"; + return "source model"; case ResourceKind.Connector: return "connector"; case ResourceKind.Model: @@ -110,10 +110,21 @@ export function prettyResourceKind(kind: string) { return kind.replace(/^rill\.runtime\.v1\./, ""); } +/** + * Check if a model has any dependencies on other models. + * A model is a "root" model (Source Model) if it has no model refs. + */ +function hasModelDependencies(res: V1Resource): boolean { + const refs = res.meta?.refs ?? []; + return refs.some((ref) => ref.kind === ResourceKind.Model); +} + /** * Coerce resource kind to match UI representation. - * Models that are defined-as-source are displayed as Sources in the sidebar and graph. - * This ensures consistent representation across the application. + * Source Models are displayed as Sources in the sidebar and graph: + * 1. Actual Source resources (legacy) + * 2. Models with definedAsSource: true (legacy converted sources) + * 3. Models with no model dependencies (root models in the DAG) * * @param res - The resource to check * @returns The coerced ResourceKind, or undefined if the resource has no kind @@ -122,19 +133,26 @@ export function prettyResourceKind(kind: string) { * // A model that is defined-as-source * coerceResourceKind(modelResource) // Returns ResourceKind.Source * - * // A normal model + * // A root model with no model dependencies + * coerceResourceKind(rootModel) // Returns ResourceKind.Source + * + * // A normal model that depends on other models * coerceResourceKind(normalModel) // Returns ResourceKind.Model */ export function coerceResourceKind(res: V1Resource): ResourceKind | undefined { const raw = res.meta?.name?.kind as ResourceKind | undefined; if (raw === ResourceKind.Model) { - // A resource is a Source if it's a model defined-as-source and its result table matches the resource name + // Legacy: model defined-as-source with matching result table const name = res.meta?.name?.name; const resultTable = res.model?.state?.resultTable; const definedAsSource = res.model?.spec?.definedAsSource; if (name && resultTable === name && definedAsSource === true) { return ResourceKind.Source; } + // New: root models with no model dependencies are Source Models + if (!hasModelDependencies(res)) { + return ResourceKind.Source; + } } return raw; } diff --git a/web-common/src/features/resource-graph/README.md b/web-common/src/features/resource-graph/README.md index 787d5b90b5b..6c1b13dcd4e 100644 --- a/web-common/src/features/resource-graph/README.md +++ b/web-common/src/features/resource-graph/README.md @@ -43,8 +43,7 @@ The graph page supports two URL parameters: Navigate to `/graph?kind=` to show all graphs of a resource kind: - `?kind=metrics` - Show all MetricsView graphs -- `?kind=models` - Show all Model graphs -- `?kind=sources` - Show all Source graphs +- `?kind=models` - Show all Model graphs (includes Sources, as Source is deprecated) - `?kind=dashboards` - Show all Dashboard/Explore graphs ### Show specific resources diff --git a/web-common/src/features/resource-graph/embedding/GraphOverlay.svelte b/web-common/src/features/resource-graph/embedding/GraphOverlay.svelte index 10643c39d86..26fad15bb4e 100644 --- a/web-common/src/features/resource-graph/embedding/GraphOverlay.svelte +++ b/web-common/src/features/resource-graph/embedding/GraphOverlay.svelte @@ -71,6 +71,7 @@ {showControls} showLock={false} enableExpand={false} + isOverlay={true} {fitViewPadding} {fitViewMinZoom} {fitViewMaxZoom} diff --git a/web-common/src/features/resource-graph/embedding/ResourceGraph.svelte b/web-common/src/features/resource-graph/embedding/ResourceGraph.svelte index d990b80d53f..b4db178b399 100644 --- a/web-common/src/features/resource-graph/embedding/ResourceGraph.svelte +++ b/web-common/src/features/resource-graph/embedding/ResourceGraph.svelte @@ -43,20 +43,21 @@ export let gridColumns: number = UI_CONFIG.DEFAULT_GRID_COLUMNS; export let expandedHeightMobile: string = UI_CONFIG.EXPANDED_HEIGHT_MOBILE; export let expandedHeightDesktop: string = UI_CONFIG.EXPANDED_HEIGHT_DESKTOP; + export let isOverlay = false; type SummaryMemo = { sources: number; - metrics: number; models: number; + metrics: number; dashboards: number; resources: V1Resource[]; - activeToken: "metrics" | "sources" | "models" | "dashboards" | null; + activeToken: "sources" | "metrics" | "models" | "dashboards" | null; }; function summaryEquals(a: SummaryMemo, b: SummaryMemo) { return ( a.sources === b.sources && - a.metrics === b.metrics && a.models === b.models && + a.metrics === b.metrics && a.dashboards === b.dashboards && a.resources === b.resources && a.activeToken === b.activeToken @@ -77,7 +78,8 @@ // Determine if we're filtering by a specific kind (e.g., ?kind=metrics) // This is used to filter out groups that don't contain any resource of the filtered kind - $: filterKind = (function (): ResourceKind | undefined { + // Special case: "dashboards" includes both Explore and Canvas + $: filterKind = (function (): ResourceKind | "dashboards" | undefined { const rawSeeds = seeds ?? []; // Only apply kind filter if all seeds are kind tokens (e.g., ["metrics"] or ["sources"]) if (rawSeeds.length === 0) return undefined; @@ -85,18 +87,33 @@ const kind = isKindToken((raw || "").toLowerCase()); if (!kind) return undefined; // Mixed seeds, no single kind filter } + // Check if it's the dashboards token (which includes both Explore and Canvas) + const firstSeed = (rawSeeds[0] || "").toLowerCase(); + if (firstSeed === "dashboards" || firstSeed === "dashboard") { + return "dashboards"; // Special token to indicate both Explore and Canvas + } // All seeds are kind tokens - return the first one's kind - return isKindToken((rawSeeds[0] || "").toLowerCase()); + return isKindToken(firstSeed); })(); // Determine which overview node should be highlighted based on current seeds + // For Canvas with MetricsView seeds, prioritize the Canvas token (dashboards) over MetricsView tokens $: overviewActiveToken = (function (): - | "metrics" | "sources" + | "metrics" | "models" | "dashboards" | null { const rawSeeds = seeds ?? []; + + // Check the first seed first - this should be the anchor resource (e.g., Canvas) + // This ensures Canvas/Explore tokens are prioritized over MetricsView tokens + if (rawSeeds.length > 0) { + const firstToken = tokenForSeedString(rawSeeds[0]); + if (firstToken) return firstToken; + } + + // Fall back to checking all seeds if first seed didn't yield a token for (const raw of rawSeeds) { const token = tokenForSeedString(raw); if (token) return token; @@ -156,7 +173,8 @@ if (k === ResourceKind.Source) sources++; else if (k === ResourceKind.Model) models++; else if (k === ResourceKind.MetricsView) metrics++; - else if (k === ResourceKind.Explore) dashboards++; + else if (k === ResourceKind.Explore || k === ResourceKind.Canvas) + dashboards++; } return { sourcesCount: sources, @@ -181,8 +199,8 @@ $: { const nextSummary: SummaryMemo = { sources: sourcesCount, - metrics: metricsCount, models: modelsCount, + metrics: metricsCount, dashboards: dashboardsCount, resources: normalizedResources, activeToken: overviewActiveToken, @@ -397,7 +415,7 @@ {#if showSummary && currentExpandedId === null} = { - [ResourceKind.Source]: "source", - [ResourceKind.Model]: "model", - [ResourceKind.MetricsView]: "metrics", - [ResourceKind.Explore]: "dashboard", - }; + | ResourceKind.Explore + | ResourceKind.Canvas; const KIND_TOKEN_BY_KIND: Record = { - [ResourceKind.Source]: "sources", [ResourceKind.Model]: "models", [ResourceKind.MetricsView]: "metrics", [ResourceKind.Explore]: "dashboards", + [ResourceKind.Canvas]: "dashboards", }; $: anchorName = anchorResource?.meta?.name?.name ?? null; - $: anchorKind = anchorResource?.meta?.name?.kind as ResourceKind | undefined; - $: supportsGraph = anchorKind ? ALLOWED_FOR_GRAPH.has(anchorKind) : false; + // Normalize Source to Model immediately (Source is deprecated) + $: rawAnchorKind = anchorResource?.meta?.name?.kind as + | ResourceKind + | undefined; + $: anchorKind = + rawAnchorKind === ResourceKind.Source ? ResourceKind.Model : rawAnchorKind; + + // Check if kind is allowed (handles both enum and string values) + $: supportsGraph = anchorKind + ? ALLOWED_FOR_GRAPH.has(String(anchorKind) as ResourceKind) + : false; // Type-safe access to graphable kind properties $: graphableKind = supportsGraph && anchorKind ? (anchorKind as GraphableKind) : null; - $: anchorSeed = - graphableKind && anchorName - ? `${NAME_SEED_ALIAS[graphableKind]}:${anchorName}` - : null; + // Use the same seed format as the project graph page + // This ensures consistent behavior between overlay and project graph + // Note: anchorKind is already normalized (Source -> Model) above + $: overlaySeeds = (function (): string[] | undefined { + if (!anchorName || !anchorKind) return undefined; + + // Use the same format that would come from URL parameters + if ( + anchorKind === ResourceKind.Canvas || + anchorKind === ResourceKind.Explore + ) { + return [`dashboard:${anchorName}`]; + } else if (anchorKind === ResourceKind.Model) { + return [`model:${anchorName}`]; + } else if (anchorKind === ResourceKind.MetricsView) { + return [`metrics:${anchorName}`]; + } + + return undefined; + })(); - $: overlaySeeds = anchorSeed ? [anchorSeed] : undefined; $: graphHref = graphableKind ? `/graph?kind=${encodeURIComponent(KIND_TOKEN_BY_KIND[graphableKind])}` : "/graph"; - $: emptyReason = !anchorSeed ? "unsupported" : null; + $: emptyReason = + !overlaySeeds || overlaySeeds.length === 0 ? "unsupported" : null; function closeOverlay() { if (onClose) { @@ -142,9 +160,10 @@ syncExpandedParam={false} showSummary={false} showCardTitles={false} - maxGroups={1} + maxGroups={null} showControls={false} enableExpansion={false} + isOverlay={true} fitViewPadding={0.08} fitViewMinZoom={0.01} fitViewMaxZoom={1.35} diff --git a/web-common/src/features/resource-graph/graph-canvas/GraphCanvas.svelte b/web-common/src/features/resource-graph/graph-canvas/GraphCanvas.svelte index eebde11b9e2..b5a2b766dac 100644 --- a/web-common/src/features/resource-graph/graph-canvas/GraphCanvas.svelte +++ b/web-common/src/features/resource-graph/graph-canvas/GraphCanvas.svelte @@ -43,6 +43,7 @@ export let fitViewMinZoom: number = FIT_VIEW_CONFIG.MIN_ZOOM; export let fitViewMaxZoom: number = FIT_VIEW_CONFIG.MAX_ZOOM; export let overlay = false; + export let isOverlay = false; export let onExpand: () => void = () => {}; let hasNodes = false; @@ -241,7 +242,7 @@ const nodesWithRoots = (graph.nodes as Node[]).map( (node) => ({ ...node, - data: { ...node.data, isRoot: rootSet.has(node.id) }, + data: { ...node.data, isRoot: rootSet.has(node.id), isOverlay }, }), ); nodesStore.set(nodesWithRoots); @@ -387,4 +388,9 @@ .expand-btn:hover { @apply bg-surface-muted text-fg-primary; } + + /* Override xyflow pane background to match app theme - scoped to this component */ + .graph-container :global(.svelte-flow .svelte-flow__pane) { + background-color: var(--surface-background, #ffffff); + } diff --git a/web-common/src/features/resource-graph/graph-canvas/ResourceNode.svelte b/web-common/src/features/resource-graph/graph-canvas/ResourceNode.svelte index 2bb8ff7e5e2..1869822b063 100644 --- a/web-common/src/features/resource-graph/graph-canvas/ResourceNode.svelte +++ b/web-common/src/features/resource-graph/graph-canvas/ResourceNode.svelte @@ -1,5 +1,5 @@ - - -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleClick(); + function handleRefresh() { + if (!isModelOrSource || !data?.resource?.meta?.name?.name || isRefreshing) + return; + + void $triggerMutation.mutateAsync({ + instanceId, + data: { + models: [{ model: data.resource.meta.name.name, full: true }], + }, + }); + } + + function handleViewGraph() { + if (!data?.resource?.meta?.name) return; + + const resourceKindName = data.resource.meta.name.kind; + const resourceNameValue = data.resource.meta.name.name; + + // Determine kind token for URL + let kindToken = "models"; + if (resourceKindName === "rill.runtime.v1.MetricsView") { + kindToken = "metrics"; + } else if ( + resourceKindName === "rill.runtime.v1.Explore" || + resourceKindName === "rill.runtime.v1.Canvas" + ) { + kindToken = "dashboards"; } - }} -> - - -
- -
-
-

{data?.label}

-

- {#if kind} - {displayResourceKind(kind)} - {:else} - Unknown + + // Build expanded ID (ResourceKind:Name format) + const expandedId = encodeURIComponent( + `${resourceKindName}:${resourceNameValue}`, + ); + + goto(`/graph?kind=${kindToken}&expanded=${expandedId}`); + } + + +{#if hasError} + + + +

+ + + + + {#if !isInOverlay} +
+ + + + + + {#if artifact?.path} + + + Edit YAML + + {/if} + {#if isModelOrSource} + + + {isRefreshing ? "Refreshing..." : "Refresh"} + + {/if} + + + View lineage + + + +
{/if} -

- {#if effectiveStatusLabel} -

- {effectiveStatusLabel} -

- {/if} -
- + +
+
+

{data?.label}

+

+ {#if kind} + {displayResourceKind(kind)} + {:else} + Unknown + {/if} +

+

{effectiveStatusLabel}

+
+
+ +
+ Error +
{data?.resource?.meta
+            ?.reconcileError}
+
+
+ +{:else} + + +
- - - - {#if showError && hasError} - +{/if}