+ {/if}
- import {
- AlertDialog,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- } from "@rilldata/web-common/components/alert-dialog/index.js";
- import { Button } from "@rilldata/web-common/components/button/index.js";
-
- export let open = false;
- export let onRefresh: () => void;
-
- function handleRefresh() {
- try {
- onRefresh();
- open = false;
- } catch (error) {
- console.error("Failed to refresh resource:", error);
- }
- }
-
-
-
-
-
- Refresh all sources and models?
-
-
- This will refresh all project sources and models.
-
-
- Note: To refresh a single resource, scroll
- to the source or model, click the '...' button, and select the refresh
- option.
-
diff --git a/web-common/src/features/resource-graph/graph-canvas/graph-builder.spec.ts b/web-common/src/features/resource-graph/graph-canvas/graph-builder.spec.ts
index d329d9a4896..6fd03d30b9a 100644
--- a/web-common/src/features/resource-graph/graph-canvas/graph-builder.spec.ts
+++ b/web-common/src/features/resource-graph/graph-canvas/graph-builder.spec.ts
@@ -3,6 +3,8 @@ import {
buildResourceGraph,
partitionResourcesBySeeds,
partitionResourcesByMetrics,
+ partitionResourcesByDashboardTrees,
+ buildMultiTreeLayout,
} from "./graph-builder";
import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors";
import type { V1Resource } from "@rilldata/web-common/runtime-client";
@@ -250,8 +252,8 @@ describe("build-resource-graph", () => {
const { nodes } = buildResourceGraph(resources);
- expect(nodes[0].sourcePosition).toBe("bottom");
- expect(nodes[0].targetPosition).toBe("top");
+ expect(nodes[0].sourcePosition).toBe("right");
+ expect(nodes[0].targetPosition).toBe("left");
});
it("should use different positions for different namespaces", () => {
@@ -356,7 +358,8 @@ describe("build-resource-graph", () => {
const { nodes } = buildResourceGraph(resources);
expect(nodes[0].data.resource).toBeDefined();
- expect(nodes[0].data.kind).toBe(ResourceKind.Model);
+ // Root models with no dependencies are coerced to Source (Source Models)
+ expect(nodes[0].data.kind).toBe(ResourceKind.Source);
expect(nodes[0].data.label).toBe("model1");
});
@@ -404,6 +407,53 @@ describe("build-resource-graph", () => {
expect(nodes[0].id).toBe("rill.runtime.v1.Model:valid");
});
+ it("should synthesize Canvas → MetricsView edges through Component refs", () => {
+ // Components don't render as graph nodes, so a Canvas's Component refs
+ // would otherwise leave the Canvas appearing disconnected from the
+ // MetricsViews it actually depends on. The graph builder expands
+ // Canvas → Component refs into the Component's downstream refs.
+ const resources: V1Resource[] = [
+ {
+ meta: {
+ name: { kind: ResourceKind.MetricsView, name: "mv1" },
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: {
+ kind: ResourceKind.Component,
+ name: "canvas1--component-0-0",
+ },
+ refs: [{ kind: ResourceKind.MetricsView, name: "mv1" }],
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.Canvas, name: "canvas1" },
+ refs: [
+ {
+ kind: ResourceKind.Component,
+ name: "canvas1--component-0-0",
+ },
+ ],
+ hidden: false,
+ },
+ },
+ ];
+
+ const { nodes, edges } = buildResourceGraph(resources);
+
+ expect(nodes.map((n) => n.id).sort()).toEqual([
+ "rill.runtime.v1.Canvas:canvas1",
+ "rill.runtime.v1.MetricsView:mv1",
+ ]);
+ expect(edges).toHaveLength(1);
+ expect(edges[0].source).toBe("rill.runtime.v1.MetricsView:mv1");
+ expect(edges[0].target).toBe("rill.runtime.v1.Canvas:canvas1");
+ });
+
it("should coerce model defined-as-source to Source kind", () => {
const resources: V1Resource[] = [
{
@@ -901,6 +951,274 @@ describe("build-resource-graph", () => {
});
});
+ describe("partitionResourcesByDashboardTrees", () => {
+ it("should create groups from Explore dashboard resources", () => {
+ const resources: V1Resource[] = [
+ {
+ meta: {
+ name: { kind: ResourceKind.Source, name: "source1" },
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.Model, name: "model1" },
+ refs: [{ kind: ResourceKind.Source, name: "source1" }],
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.MetricsView, name: "metrics1" },
+ refs: [{ kind: ResourceKind.Model, name: "model1" }],
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.Explore, name: "explore1" },
+ refs: [{ kind: ResourceKind.MetricsView, name: "metrics1" }],
+ hidden: false,
+ },
+ },
+ ];
+
+ const groups = partitionResourcesByDashboardTrees(resources);
+
+ const dashboardGroup = groups.find((g) => g.label === "explore1");
+ expect(dashboardGroup).toBeDefined();
+ expect(dashboardGroup!.resources.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("should create groups from Canvas dashboard resources", () => {
+ const resources: V1Resource[] = [
+ {
+ meta: {
+ name: { kind: ResourceKind.Model, name: "model1" },
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.Canvas, name: "canvas1" },
+ refs: [{ kind: ResourceKind.Model, name: "model1" }],
+ hidden: false,
+ },
+ },
+ ];
+
+ const groups = partitionResourcesByDashboardTrees(resources);
+
+ const canvasGroup = groups.find((g) => g.label === "canvas1");
+ expect(canvasGroup).toBeDefined();
+ });
+
+ it("should put unconnected resources in 'Other resources' group", () => {
+ const resources: V1Resource[] = [
+ {
+ meta: {
+ name: { kind: ResourceKind.Explore, name: "explore1" },
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.Model, name: "orphan" },
+ hidden: false,
+ },
+ },
+ ];
+
+ const groups = partitionResourcesByDashboardTrees(resources);
+
+ const otherGroup = groups.find((g) => g.label === "Other resources");
+ expect(otherGroup).toBeDefined();
+ });
+
+ it("should exclude connector nodes from trees", () => {
+ const resources: V1Resource[] = [
+ {
+ meta: {
+ name: { kind: ResourceKind.Connector, name: "duckdb" },
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.Model, name: "model1" },
+ refs: [{ kind: ResourceKind.Connector, name: "duckdb" }],
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.Explore, name: "explore1" },
+ refs: [{ kind: ResourceKind.Model, name: "model1" }],
+ hidden: false,
+ },
+ },
+ ];
+
+ const groups = partitionResourcesByDashboardTrees(resources);
+
+ const dashboardGroup = groups.find((g) => g.label === "explore1");
+ if (dashboardGroup) {
+ const connectorInGroup = dashboardGroup.resources.find(
+ (r) => r.meta?.name?.kind === ResourceKind.Connector,
+ );
+ expect(connectorInGroup).toBeUndefined();
+ }
+ });
+
+ it("should handle empty resource list", () => {
+ const groups = partitionResourcesByDashboardTrees([]);
+ expect(groups).toHaveLength(0);
+ });
+
+ it("should sort groups alphabetically", () => {
+ const resources: V1Resource[] = [
+ {
+ meta: {
+ name: { kind: ResourceKind.Explore, name: "zebra" },
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.Explore, name: "apple" },
+ hidden: false,
+ },
+ },
+ ];
+
+ const groups = partitionResourcesByDashboardTrees(resources);
+ const dashboardLabels = groups
+ .filter((g) => g.label !== "Other resources")
+ .map((g) => g.label);
+ expect(dashboardLabels).toEqual(["apple", "zebra"]);
+ });
+ });
+
+ describe("buildMultiTreeLayout", () => {
+ it("should produce nodes and edges from multiple groups", () => {
+ const resources1: V1Resource[] = [
+ {
+ meta: {
+ name: { kind: ResourceKind.Source, name: "source1" },
+ hidden: false,
+ },
+ },
+ {
+ meta: {
+ name: { kind: ResourceKind.Model, name: "model1" },
+ refs: [{ kind: ResourceKind.Source, name: "source1" }],
+ hidden: false,
+ },
+ },
+ ];
+ const resources2: V1Resource[] = [
+ {
+ meta: {
+ name: { kind: ResourceKind.Source, name: "source2" },
+ hidden: false,
+ },
+ },
+ ];
+
+ const groups = [
+ { id: "group1", resources: resources1, label: "Group 1" },
+ { id: "group2", resources: resources2, label: "Group 2" },
+ ];
+
+ const { nodes, edges } = buildMultiTreeLayout(groups);
+
+ expect(nodes.length).toBeGreaterThanOrEqual(3);
+ expect(edges.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("should handle empty groups", () => {
+ const { nodes, edges } = buildMultiTreeLayout([]);
+ expect(nodes).toHaveLength(0);
+ expect(edges).toHaveLength(0);
+ });
+
+ it("should handle groups with no resources", () => {
+ const groups = [{ id: "empty", resources: [], label: "Empty" }];
+ const { nodes, edges } = buildMultiTreeLayout(groups);
+ expect(nodes).toHaveLength(0);
+ expect(edges).toHaveLength(0);
+ });
+
+ it("should handle shared resources across groups by prefixing IDs", () => {
+ const sharedResource: V1Resource = {
+ meta: {
+ name: { kind: ResourceKind.Model, name: "shared_model" },
+ hidden: false,
+ },
+ };
+
+ const groups = [
+ {
+ id: "group1",
+ resources: [
+ sharedResource,
+ {
+ meta: {
+ name: { kind: ResourceKind.Explore, name: "explore1" },
+ refs: [{ kind: ResourceKind.Model, name: "shared_model" }],
+ hidden: false,
+ },
+ },
+ ],
+ label: "Group 1",
+ },
+ {
+ id: "group2",
+ resources: [
+ sharedResource,
+ {
+ meta: {
+ name: { kind: ResourceKind.Explore, name: "explore2" },
+ refs: [{ kind: ResourceKind.Model, name: "shared_model" }],
+ hidden: false,
+ },
+ },
+ ],
+ label: "Group 2",
+ },
+ ];
+
+ const { nodes } = buildMultiTreeLayout(groups);
+
+ // The second group's nodes should be prefixed to avoid ID collisions
+ const prefixedNodes = nodes.filter((n) => n.id.includes("group2__"));
+ expect(prefixedNodes.length).toBeGreaterThan(0);
+ });
+
+ it("should respect container width for row wrapping", () => {
+ const makeGroup = (name: string) => ({
+ id: name,
+ resources: [
+ {
+ meta: {
+ name: { kind: ResourceKind.Model, name },
+ hidden: false,
+ },
+ },
+ ] as V1Resource[],
+ label: name,
+ });
+
+ const groups = Array.from({ length: 10 }, (_, i) =>
+ makeGroup(`model_${i}`),
+ );
+
+ // Should not throw with a narrow container
+ const result = buildMultiTreeLayout(groups, 500);
+ expect(result.nodes.length).toBeGreaterThan(0);
+ });
+ });
+
describe("edge cases and performance", () => {
it("should handle circular dependencies gracefully", () => {
const resources: V1Resource[] = [
diff --git a/web-common/src/features/resource-graph/graph-canvas/graph-builder.ts b/web-common/src/features/resource-graph/graph-canvas/graph-builder.ts
index 1ce5db7f455..29eca5ff9c5 100644
--- a/web-common/src/features/resource-graph/graph-canvas/graph-builder.ts
+++ b/web-common/src/features/resource-graph/graph-canvas/graph-builder.ts
@@ -13,17 +13,16 @@ import type {
V1Resource,
V1ResourceName,
} from "@rilldata/web-common/runtime-client";
-import type { ResourceNodeData } from "../shared/types";
+import type { ResourceNodeData, ResourceMetadata } from "../shared/types";
import { graphCache } from "../shared/cache/position-cache";
import { NODE_CONFIG, DAGRE_CONFIG, EDGE_CONFIG } from "../shared/config";
+import { deriveConnectorType } from "@rilldata/web-common/features/connectors/connector-type-detector";
// Use centralized configuration
const MIN_NODE_WIDTH = NODE_CONFIG.MIN_WIDTH;
const MAX_NODE_WIDTH = NODE_CONFIG.MAX_WIDTH;
const DEFAULT_NODE_HEIGHT = NODE_CONFIG.DEFAULT_HEIGHT;
const AVERAGE_CHAR_WIDTH = NODE_CONFIG.AVERAGE_CHAR_WIDTH;
-const CONTENT_PADDING = NODE_CONFIG.CONTENT_PADDING;
-
// Dagre configuration from centralized config
const DAGRE_NODESEP = DAGRE_CONFIG.NODESEP;
const DAGRE_RANKSEP = DAGRE_CONFIG.RANKSEP;
@@ -31,14 +30,29 @@ const DAGRE_EDGESEP = DAGRE_CONFIG.EDGESEP;
// Edge styling from centralized config
const DEFAULT_EDGE_STYLE = EDGE_CONFIG.DEFAULT_STYLE;
+const ERROR_EDGE_STYLE = EDGE_CONFIG.ERROR_STYLE;
-// Resource kinds that should be displayed in the graph
+// Resource kinds that participate in the project graph (used for grouping
+// and adjacency).
const ALLOWED_KINDS = new Set([
ResourceKind.Connector,
ResourceKind.Source,
ResourceKind.Model,
ResourceKind.MetricsView,
ResourceKind.Explore,
+ ResourceKind.Canvas,
+]);
+
+// Resource kinds that render as nodes on the canvas. Connectors are excluded
+// — their info surfaces as a badge on Source/Model nodes — but they remain
+// in `ALLOWED_KINDS` so partition/adjacency logic can still group resources
+// by their connector and walk connector-rooted refs.
+const RENDERABLE_KINDS = new Set([
+ ResourceKind.Source,
+ ResourceKind.Model,
+ ResourceKind.MetricsView,
+ ResourceKind.Explore,
+ ResourceKind.Canvas,
]);
function toResourceKind(name?: V1ResourceName): ResourceKind | undefined {
@@ -46,6 +60,97 @@ function toResourceKind(name?: V1ResourceName): ResourceKind | undefined {
return name.kind as ResourceKind;
}
+// Icon-only badge (~20px) + node padding (2*8px) + gap (~6px)
+const CONTENT_PADDING = 42;
+
+/**
+ * Build a name → id index for fallback ref resolution. The parser intentionally
+ * leaves `Kind` blank on some refs (e.g., a metrics view's `model:` field) so
+ * the runtime can infer whether the target is a Source, Model, or external
+ * table. Until that inference completes, refs may surface to the frontend with
+ * `kind === undefined`. This index lets us still resolve them by name when the
+ * name is unambiguous within the visible resource set.
+ *
+ * Ambiguous names (more than one resource with the same name) map to `null`
+ * and are not resolved — kind is required to disambiguate.
+ */
+function buildNameIndex(
+ resourceMap: Map,
+): Map {
+ const byName = new Map();
+ for (const [id, res] of resourceMap) {
+ const name = res.meta?.name?.name;
+ if (!name) continue;
+ byName.set(name, byName.has(name) ? null : id);
+ }
+ return byName;
+}
+
+/**
+ * Resolve a ref to a full resource id. Prefers `kind:name` when both are set
+ * and the resource is in the visible set; otherwise falls back to a name-only
+ * lookup via `byName`.
+ */
+function resolveRefId(
+ ref: V1ResourceName | undefined,
+ resourceMap: Map,
+ byName: Map,
+): string | undefined {
+ if (!ref?.name) return undefined;
+ const id = resourceNameToId(ref);
+ if (id && resourceMap.has(id)) return id;
+ const fallback = byName.get(ref.name);
+ return fallback ?? undefined;
+}
+
+/**
+ * Synthesize Canvas → MetricsView edges in the rendered graph.
+ *
+ * The runtime DAG models Canvas → Component → MetricsView, but Components
+ * aren't surfaced as nodes in the graph (only Sources, Models, MetricsViews,
+ * Explores, Canvases, and Connectors render). Without intervention, the
+ * Canvas would appear orphaned from the metrics views it actually uses.
+ *
+ * This walks each Canvas's Component refs, pulls the Components' refs out of
+ * the full resource set, and appends them to the Canvas's refs in a copied
+ * resource. The transformation is purely visual — the underlying parser /
+ * runtime DAG is untouched, so reconcile scheduling and AI graph reporting
+ * see the original Canvas → Component → MetricsView shape.
+ */
+function expandCanvasRefs(resources: V1Resource[]): V1Resource[] {
+ const components = new Map();
+ for (const res of resources) {
+ if (coerceResourceKind(res) !== ResourceKind.Component) continue;
+ const id = createResourceId(res.meta);
+ if (id) components.set(id, res);
+ }
+ if (!components.size) return resources;
+
+ return resources.map((res) => {
+ if (coerceResourceKind(res) !== ResourceKind.Canvas) return res;
+ const refs = res.meta?.refs ?? [];
+ const seen = new Set();
+ const expanded: V1ResourceName[] = [];
+ const push = (ref: V1ResourceName) => {
+ const key = `${ref.kind ?? ""}:${ref.name ?? ""}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ expanded.push(ref);
+ };
+ for (const ref of refs) {
+ push(ref);
+ if (ref.kind !== ResourceKind.Component) continue;
+ const componentId = resourceNameToId(ref);
+ const component = componentId ? components.get(componentId) : undefined;
+ for (const sub of component?.meta?.refs ?? []) push(sub);
+ }
+ return {
+ ...res,
+ meta: { ...res.meta, refs: expanded },
+ };
+ });
+}
+
function estimateNodeWidth(label?: string | null) {
const text = label?.trim() ?? "";
if (!text.length) return MIN_NODE_WIDTH;
@@ -62,6 +167,284 @@ function estimateNodeWidth(label?: string | null) {
);
}
+/**
+ * Format a V1Schedule into a short human-readable description for the header.
+ */
+function formatScheduleDescription(
+ schedule:
+ | { cron?: string; tickerSeconds?: number; timeZone?: string }
+ | undefined,
+): string | undefined {
+ if (!schedule) return undefined;
+ if (schedule.cron) {
+ // Just show the cron expression - timezone is in the YAML
+ return schedule.cron;
+ }
+ if (schedule.tickerSeconds) {
+ const seconds = schedule.tickerSeconds;
+ if (seconds >= 3600 && seconds % 3600 === 0) {
+ const hours = seconds / 3600;
+ return `every ${hours}h`;
+ }
+ if (seconds >= 60 && seconds % 60 === 0) {
+ const minutes = seconds / 60;
+ return `every ${minutes}m`;
+ }
+ return `every ${seconds}s`;
+ }
+ return undefined;
+}
+
+/**
+ * Build a reverse-reference map: resourceId -> { alerts, apis } counts.
+ * Built once in O(M) and queried in O(1) per node, avoiding O(N*M) per-node scanning.
+ */
+function buildReverseRefCounts(
+ resources: V1Resource[],
+): Map {
+ const counts = new Map();
+ for (const res of resources) {
+ const resKind = res.meta?.name?.kind;
+ if (resKind !== ResourceKind.Alert && resKind !== ResourceKind.API)
+ continue;
+ for (const ref of res.meta?.refs ?? []) {
+ const refId = resourceNameToId(ref);
+ if (!refId) continue;
+ const entry = counts.get(refId) ?? { alerts: 0, apis: 0 };
+ if (resKind === ResourceKind.Alert) entry.alerts++;
+ else entry.apis++;
+ counts.set(refId, entry);
+ }
+ }
+ return counts;
+}
+
+/**
+ * Extract rich metadata from a resource for badge display.
+ */
+function extractResourceMetadata(
+ resource: V1Resource,
+ kind: ResourceKind | undefined,
+ reverseRefCounts: Map,
+): ResourceMetadata {
+ const metadata: ResourceMetadata = {};
+
+ // Model/Source metadata
+ const model = resource.model;
+ const source = resource.source;
+
+ if (model?.spec) {
+ const spec = model.spec;
+ const inputProps = spec.inputProperties as
+ | { path?: string; sql?: string }
+ | undefined;
+
+ // Connector info
+ metadata.connector = deriveConnectorType({
+ partitionsResolverProperties: spec.partitionsResolverProperties as
+ | Record
+ | undefined,
+ sourcePath: inputProps?.path,
+ sqlContent: inputProps?.sql,
+ inputConnector: spec.inputConnector,
+ });
+ if (spec.inputConnector) metadata.inputConnector = spec.inputConnector;
+
+ // Source path for file-based sources
+ if (inputProps?.path) {
+ metadata.sourcePath = inputProps.path;
+ }
+
+ // Processing configuration
+ metadata.incremental = spec.incremental;
+ metadata.partitioned = Boolean(spec.partitionsResolver);
+
+ // Schedule configuration
+ metadata.hasSchedule = Boolean(
+ spec.refreshSchedule?.cron || spec.refreshSchedule?.tickerSeconds,
+ );
+ metadata.scheduleDescription = formatScheduleDescription(
+ spec.refreshSchedule,
+ );
+ metadata.refUpdate = spec.refreshSchedule?.refUpdate ?? false;
+
+ // Timeout
+ if (spec.timeoutSeconds) metadata.timeoutSeconds = spec.timeoutSeconds;
+
+ // Last refreshed
+ if (model.state?.refreshedOn) {
+ metadata.lastRefreshedOn = model.state.refreshedOn;
+ }
+
+ // Execution duration
+ if (model.state?.latestExecutionDurationMs) {
+ metadata.executionDurationMs = model.state.latestExecutionDurationMs;
+ }
+
+ // Result table
+ if (model.state?.resultTable) {
+ metadata.resultTable = model.state.resultTable;
+ }
+
+ // Materialization
+ const outputProps = spec.outputProperties as
+ | { materialize?: boolean }
+ | undefined;
+ metadata.materialize = outputProps?.materialize ?? false;
+ metadata.isMaterialized =
+ outputProps?.materialize === true || Boolean(spec.stageConnector);
+ if (spec.outputConnector) metadata.outputConnector = spec.outputConnector;
+ if (spec.stageConnector) metadata.stageConnector = spec.stageConnector;
+
+ // Change mode
+ if (spec.changeMode) metadata.changeMode = spec.changeMode;
+
+ // Retry configuration
+ if (spec.retryAttempts) metadata.retryAttempts = spec.retryAttempts;
+ if (spec.retryDelaySeconds)
+ metadata.retryDelaySeconds = spec.retryDelaySeconds;
+ if (spec.retryExponentialBackoff) metadata.retryExponentialBackoff = true;
+ if (spec.retryIfErrorMatches?.length)
+ metadata.retryIfErrorMatches = spec.retryIfErrorMatches;
+
+ // Tests
+ metadata.testCount = spec.tests?.length ?? 0;
+ if (model.state?.testErrors) {
+ metadata.testErrors = model.state.testErrors;
+ }
+
+ // Extract SQL query
+ if (inputProps?.sql) {
+ metadata.sqlQuery = inputProps.sql;
+ }
+
+ // Check if model is defined via SQL file
+ const filePaths = resource.meta?.filePaths ?? [];
+ metadata.isSqlModel = filePaths.some((fp) => fp.endsWith(".sql"));
+ }
+
+ if (source?.spec) {
+ const spec = source.spec;
+ const props = spec.properties as
+ | { path?: string; sql?: string }
+ | undefined;
+
+ metadata.connector = deriveConnectorType({
+ sourcePath: props?.path,
+ sqlContent: props?.sql,
+ inputConnector: spec.sourceConnector,
+ });
+ metadata.hasSchedule = Boolean(
+ spec.refreshSchedule?.cron || spec.refreshSchedule?.tickerSeconds,
+ );
+ metadata.scheduleDescription = formatScheduleDescription(
+ spec.refreshSchedule,
+ );
+
+ // Last refreshed
+ if (source.state?.refreshedOn) {
+ metadata.lastRefreshedOn = source.state.refreshedOn;
+ }
+
+ // Mark as YAML-based (sources are always YAML)
+ metadata.isSqlModel = false;
+ }
+
+ // MetricsView metadata
+ const metricsView = resource.metricsView;
+ if (metricsView?.spec) {
+ const mvSpec = metricsView.spec;
+ metadata.metricsTable = mvSpec.table;
+ metadata.metricsModel = mvSpec.model;
+ metadata.timeDimension = mvSpec.timeDimension;
+ metadata.hasSecurityRules = (mvSpec.securityRules?.length ?? 0) > 0;
+
+ // Extract dimensions
+ if (mvSpec.dimensions && mvSpec.dimensions.length > 0) {
+ metadata.dimensions = mvSpec.dimensions.map((d) => ({
+ name: d.name ?? "",
+ displayName: d.displayName,
+ description: d.description,
+ type: d.type?.replace("DIMENSION_TYPE_", ""),
+ column: d.column,
+ expression: d.expression,
+ }));
+ }
+
+ // Extract measures
+ if (mvSpec.measures && mvSpec.measures.length > 0) {
+ metadata.measures = mvSpec.measures.map((m) => ({
+ name: m.name ?? "",
+ displayName: m.displayName,
+ description: m.description,
+ expression: m.expression,
+ type: m.type?.replace("MEASURE_TYPE_", ""),
+ }));
+ }
+ }
+
+ // Dashboard (Explore/Canvas) theme metadata
+ const explore = resource.explore;
+ const canvas = resource.canvas;
+
+ if (explore?.spec) {
+ if (explore.spec.theme && !explore.spec.embeddedTheme) {
+ metadata.theme = explore.spec.theme;
+ }
+ metadata.metricsViewName = explore.spec.metricsView;
+ metadata.hasSecurityRules = (explore.spec.securityRules?.length ?? 0) > 0;
+ metadata.exploreMeasuresAll = explore.spec.measuresSelector?.all === true;
+ metadata.exploreDimensionsAll =
+ explore.spec.dimensionsSelector?.all === true;
+ metadata.exploreMeasuresCount = explore.spec.measures?.length ?? 0;
+ metadata.exploreDimensionsCount = explore.spec.dimensions?.length ?? 0;
+ }
+
+ if (canvas?.spec) {
+ if (canvas.spec.theme && !canvas.spec.embeddedTheme) {
+ metadata.theme = canvas.spec.theme;
+ }
+ // Count components across all rows
+ const rows = canvas.spec.rows ?? [];
+ metadata.rowCount = rows.length;
+ let componentCount = 0;
+ for (const row of rows) {
+ componentCount += row.items?.length ?? 0;
+ }
+ if (componentCount > 0) {
+ metadata.componentCount = componentCount;
+ }
+ metadata.hasSecurityRules = (canvas.spec.securityRules?.length ?? 0) > 0;
+ }
+
+ // Connector metadata
+ const connectorRes = resource.connector;
+ if (connectorRes?.spec) {
+ if (connectorRes.spec.driver) {
+ metadata.connectorDriver = connectorRes.spec.driver;
+ }
+ if (connectorRes.spec.properties) {
+ metadata.connectorProperties = connectorRes.spec.properties;
+ }
+ if (connectorRes.spec.templatedProperties?.length) {
+ metadata.connectorTemplatedProperties =
+ connectorRes.spec.templatedProperties;
+ }
+ }
+
+ // Look up alert/API counts from pre-built reverse-reference map (O(1))
+ const resourceId = createResourceId(resource.meta);
+ if (resourceId) {
+ const counts = reverseRefCounts.get(resourceId);
+ if (counts) {
+ if (counts.alerts > 0) metadata.alertCount = counts.alerts;
+ if (counts.apis > 0) metadata.apiCount = counts.apis;
+ }
+ }
+
+ return metadata;
+}
+
type BuildGraphOptions = {
// Namespace for caching node positions. Using a per-graph id avoids reusing
// coordinates from unrelated graphs, which can place nodes far away.
@@ -76,12 +459,13 @@ type BuildGraphOptions = {
* Uses Dagre for automatic layout and maintains cached node positions for stability.
*
* This function:
- * - Filters resources to only allowed kinds (Source, Model, MetricsView, Explore)
+ * - Filters resources to only allowed kinds (Source, Model, MetricsView, Explore, Canvas)
+ * Note: Sources and Models are merged in the UI (Source is deprecated)
* - Creates nodes with dynamic widths based on label length
* - Generates edges based on resource references
* - Applies Dagre layout with configurable spacing
* - Caches node positions per namespace for consistent placement
- * - Enforces rank constraints (Sources at top, Explores/Canvas at bottom)
+ * - Enforces rank constraints (Explores/Canvas at bottom, Sources/Models flow naturally)
*
* @param resources - Array of V1Resource objects to visualize
* @param opts - Optional configuration for layout and caching
@@ -99,28 +483,31 @@ export function buildResourceGraph(
resources: V1Resource[],
opts?: BuildGraphOptions,
) {
+ resources = expandCanvasRefs(resources);
const positionNs = opts?.positionNs?.trim() || "global";
const dagreGraph = new graphlib.Graph();
dagreGraph.setGraph({
- rankdir: "TB",
- // Extreme compactness; overlaps allowed
+ rankdir: DAGRE_CONFIG.RANKDIR,
nodesep: DAGRE_NODESEP,
ranksep: DAGRE_RANKSEP,
edgesep: DAGRE_EDGESEP,
- ranker: "tight-tree",
- acyclicer: "greedy",
+ ranker: DAGRE_CONFIG.RANKER,
+ acyclicer: DAGRE_CONFIG.ACYCLICER,
});
dagreGraph.setDefaultEdgeLabel(() => ({}));
const resourceMap = new Map();
const nodeDefinitions = new Map>();
+ // Build reverse-reference counts once (O(M)) for O(1) per-node lookups
+ const reverseRefCounts = buildReverseRefCounts(resources);
+
for (const resource of resources) {
const id = createResourceId(resource.meta);
if (!id) continue;
const kind = coerceResourceKind(resource);
- if (!kind || !ALLOWED_KINDS.has(kind)) continue;
+ if (!kind || !RENDERABLE_KINDS.has(kind)) continue;
if (resource.meta?.hidden) continue;
resourceMap.set(id, resource);
@@ -128,9 +515,10 @@ export function buildResourceGraph(
const nodeWidth = estimateNodeWidth(label);
let rankConstraint: "min" | "max" | undefined;
switch (kind) {
- case ResourceKind.Connector:
case ResourceKind.Source:
- rankConstraint = "min";
+ case ResourceKind.Model:
+ // No special rank constraint - let them flow naturally in the graph
+ rankConstraint = undefined;
break;
case ResourceKind.Explore:
case ResourceKind.Canvas:
@@ -146,6 +534,8 @@ export function buildResourceGraph(
rank: rankConstraint,
});
+ const metadata = extractResourceMetadata(resource, kind, reverseRefCounts);
+
const nodeDef: Node = {
id,
width: nodeWidth,
@@ -154,27 +544,42 @@ export function buildResourceGraph(
resource,
kind,
label,
+ metadata,
},
type: "resource-node",
position: { x: 0, y: 0 },
- targetPosition: Position.Top,
- sourcePosition: Position.Bottom,
+ targetPosition: Position.Left,
+ sourcePosition: Position.Right,
};
nodeDefinitions.set(id, nodeDef);
}
+ // Build adjacency map for edges
+ // dependentsMap: sourceId -> Set of dependentIds (outgoing edges from source)
const dependentsMap = new Map>();
+ const byName = buildNameIndex(resourceMap);
+
+ // First pass: collect all refs per dependent, tracking which have non-connector parents
+ const nonConnectorParents = new Set();
for (const resource of resourceMap.values()) {
const dependentId = createResourceId(resource.meta);
if (!dependentId) continue;
for (const ref of resource.meta?.refs ?? []) {
- const sourceId = resourceNameToId(ref);
+ const sourceId = resolveRefId(ref, resourceMap, byName);
if (!sourceId) continue;
- if (!resourceMap.has(sourceId)) continue;
if (sourceId === dependentId) continue;
+ const sourceResource = resourceMap.get(sourceId);
+ const sourceKind = sourceResource
+ ? coerceResourceKind(sourceResource)
+ : undefined;
+ if (sourceKind !== ResourceKind.Connector) {
+ nonConnectorParents.add(dependentId);
+ }
+
+ // Track outgoing edges (source -> dependent)
if (!dependentsMap.has(sourceId)) dependentsMap.set(sourceId, new Set());
dependentsMap.get(sourceId)!.add(dependentId);
}
@@ -185,19 +590,34 @@ export function buildResourceGraph(
for (const [sourceId, dependents] of dependentsMap) {
if (!dependents?.size) continue;
+ const sourceResource = resourceMap.get(sourceId);
+ const sourceKind = sourceResource
+ ? coerceResourceKind(sourceResource)
+ : undefined;
+ const isConnectorSource = sourceKind === ResourceKind.Connector;
+
for (const dependentId of dependents) {
if (!resourceMap.has(sourceId) || !resourceMap.has(dependentId)) continue;
+
+ // Only connect connector to root nodes (those with no other non-connector parents)
+ if (isConnectorSource && nonConnectorParents.has(dependentId)) continue;
+
const edgeId = `${sourceId}->${dependentId}`;
if (edgeIds.has(edgeId)) continue;
edgeIds.add(edgeId);
dagreGraph.setEdge(sourceId, dependentId);
+ // Color edge red if the target has a reconcile error
+ const targetResource = resourceMap.get(dependentId);
+ const targetError = targetResource?.meta?.reconcileError ?? "";
+ const hasTargetError = !!targetError;
+
const edge: Edge = {
id: edgeId,
source: sourceId,
target: dependentId,
animated: false,
- type: "smoothstep",
- style: DEFAULT_EDGE_STYLE,
+ type: "default",
+ style: hasTargetError ? ERROR_EDGE_STYLE : DEFAULT_EDGE_STYLE,
} as Edge;
edges.push(edge);
}
@@ -220,8 +640,8 @@ export function buildResourceGraph(
? undefined
: graphCache.getPosition(posKey);
node.position = cached ?? computed;
- node.targetPosition = Position.Top;
- node.sourcePosition = Position.Bottom;
+ node.targetPosition = Position.Left;
+ node.sourcePosition = Position.Right;
graphCache.setPosition(posKey, node.position);
}
@@ -245,13 +665,13 @@ function buildDirectedAdjacency(resources: Map) {
if (!incoming.has(id)) incoming.set(id, new Set());
if (!outgoing.has(id)) outgoing.set(id, new Set());
}
+ const byName = buildNameIndex(resources);
for (const resource of resources.values()) {
const dependentId = createResourceId(resource.meta);
if (!dependentId) continue;
for (const ref of resource.meta?.refs ?? []) {
- const sourceId = createResourceId({ name: ref });
+ const sourceId = resolveRefId(ref, resources, byName);
if (!sourceId) continue;
- if (!resources.has(sourceId)) continue;
if (!incoming.has(dependentId)) incoming.set(dependentId, new Set());
if (!outgoing.has(sourceId)) outgoing.set(sourceId, new Set());
incoming.get(dependentId)!.add(sourceId);
@@ -327,7 +747,8 @@ function buildVisibleResourceMap(
if (!id) continue;
const kind = toResourceKind(resource.meta?.name);
if (!kind || !ALLOWED_KINDS.has(kind)) continue;
- if (resource.meta?.hidden) continue;
+ // Allow connectors even if hidden; GraphContainer pre-filters to OLAP only
+ if (resource.meta?.hidden && kind !== ResourceKind.Connector) continue;
resourceMap.set(id, resource);
}
return resourceMap;
@@ -338,13 +759,17 @@ function buildVisibleResourceMap(
* Converts V1ResourceName objects to string IDs and removes duplicates.
*/
function normalizeSeeds(seeds: (string | V1ResourceName)[]): string[] {
- const toSeedId = (seed: string | V1ResourceName) =>
- typeof seed === "string" ? seed : createResourceId({ name: seed });
-
- return seeds
- .map((s) => toSeedId(s))
- .filter((id): id is string => !!id)
- .filter((id, idx, arr) => arr.indexOf(id) === idx);
+ const seen = new Set();
+ const result: string[] = [];
+ for (const seed of seeds) {
+ const id =
+ typeof seed === "string" ? seed : createResourceId({ name: seed });
+ if (id && !seen.has(id)) {
+ seen.add(id);
+ result.push(id);
+ }
+ }
+ return result;
}
/**
@@ -371,9 +796,20 @@ function createSeedBasedGroups(
const downIds = traverseDownstream(seedId, outgoing);
const componentIds = new Set([...upIds, ...downIds]);
+ const seedKind = toResourceKind(resourceMap.get(seedId)?.meta?.name);
const componentResources = Array.from(componentIds)
.map((resourceId) => resourceMap.get(resourceId))
- .filter((res): res is V1Resource => !!res);
+ .filter((res): res is V1Resource => !!res)
+ .filter((res) => {
+ // Exclude non-seed connectors; connector info is shown as badges
+ const k = toResourceKind(res.meta?.name);
+ if (k !== ResourceKind.Connector) return true;
+ // Keep the connector if it's the seed itself
+ return (
+ seedKind === ResourceKind.Connector &&
+ createResourceId(res.meta) === seedId
+ );
+ });
if (!componentResources.length) continue;
const label = resourceMap.get(seedId)?.meta?.name?.name ?? seedId;
@@ -511,8 +947,9 @@ function updateGroupingCaches(groups: ResourceGraphGrouping[]): void {
export function partitionResourcesBySeeds(
resources: V1Resource[],
seeds: (string | V1ResourceName)[],
- filterKind?: ResourceKind,
+ filterKind?: ResourceKind | "dashboards",
): ResourceGraphGrouping[] {
+ resources = expandCanvasRefs(resources);
const resourceMap = buildVisibleResourceMap(resources);
const { incoming, outgoing } = buildDirectedAdjacency(resourceMap);
const normalizedSeeds = normalizeSeeds(seeds);
@@ -535,10 +972,14 @@ export function partitionResourcesBySeeds(
// If filtering by a specific kind, remove groups that don't contain any resource of that kind
// Use coerceResourceKind to handle "defined-as-source" models correctly
+ // Special case: "dashboards" includes both Explore and Canvas
if (filterKind) {
return groups.filter((group) =>
group.resources.some((r) => {
const kind = coerceResourceKind(r);
+ if (filterKind === "dashboards") {
+ return kind === ResourceKind.Explore || kind === ResourceKind.Canvas;
+ }
return kind === filterKind;
}),
);
@@ -578,6 +1019,7 @@ export function partitionResourcesBySeeds(
export function partitionResourcesByMetrics(
resources: V1Resource[],
): ResourceGraphGrouping[] {
+ resources = expandCanvasRefs(resources);
// NETWORK approach: build undirected adjacency and group by connected
// components rooted at metrics views.
const resourceMap = new Map();
@@ -588,18 +1030,20 @@ export function partitionResourcesByMetrics(
if (!id) continue;
const kind = toResourceKind(res.meta?.name);
if (!kind || !ALLOWED_KINDS.has(kind)) continue;
- if (res.meta?.hidden) continue;
+ // Allow connectors even if hidden; GraphContainer pre-filters to OLAP only
+ if (res.meta?.hidden && kind !== ResourceKind.Connector) continue;
resourceMap.set(id, res);
if (!adjacency.has(id)) adjacency.set(id, new Set());
}
+ // Build adjacency from resource refs.
+ const byName = buildNameIndex(resourceMap);
for (const res of resourceMap.values()) {
const dependentId = createResourceId(res.meta);
if (!dependentId) continue;
for (const ref of res.meta?.refs ?? []) {
- const sourceId = createResourceId({ name: ref });
+ const sourceId = resolveRefId(ref, resourceMap, byName);
if (!sourceId) continue;
- if (!resourceMap.has(sourceId)) continue;
if (!adjacency.has(sourceId)) adjacency.set(sourceId, new Set());
if (!adjacency.has(dependentId)) adjacency.set(dependentId, new Set());
adjacency.get(dependentId)!.add(sourceId);
@@ -634,6 +1078,8 @@ export function partitionResourcesByMetrics(
const assigned = new Set();
for (const m of metricSeeds) {
+ // Skip metrics views already covered by a previous group's connected component
+ if (assigned.has(m.id)) continue;
const ids = traverseConnected(m.id);
if (!ids.size) continue;
const resourcesInGroup = Array.from(ids)
@@ -645,7 +1091,24 @@ export function partitionResourcesByMetrics(
graphCache.setLabel(m.id, m.label);
}
- // If there are resources not connected to any metrics view, group remaining components.
+ // Create connector groups. Connectors are the conceptual root of the
+ // project, so selecting one displays the full DAG (all visible resources).
+ // Insert at the beginning so they appear first in the sidebar tree.
+ const connectorGroups: ResourceGraphGrouping[] = [];
+ for (const [id, res] of resourceMap) {
+ const kind = toResourceKind(res.meta?.name);
+ if (kind !== ResourceKind.Connector) continue;
+ const label = res.meta?.name?.name ?? id;
+ connectorGroups.push({
+ id,
+ resources: Array.from(resourceMap.values()),
+ label,
+ });
+ graphCache.setLabel(id, label);
+ assigned.add(id);
+ }
+
+ // Group remaining resources not connected to any metrics view.
const remaining = Array.from(resourceMap.keys()).filter(
(id) => !assigned.has(id),
);
@@ -663,11 +1126,13 @@ export function partitionResourcesByMetrics(
resources: resourcesInGroup,
label: "Other resources",
});
+ for (const rid of ids) assigned.add(rid);
}
}
// Persist grouping assignments
- for (const g of groups) {
+ const finalGroups = [...connectorGroups, ...groups];
+ for (const g of finalGroups) {
if (g.label) graphCache.setLabel(g.id, g.label);
for (const r of g.resources) {
const rid = createResourceId(r.meta);
@@ -676,5 +1141,193 @@ export function partitionResourcesByMetrics(
}
graphCache.persist();
+ return finalGroups;
+}
+
+/**
+ * Partition resources into independent trees rooted at dashboard resources.
+ * Each dashboard (Explore or Canvas) anchors a tree containing all its
+ * upstream dependencies (MetricsView → Models → Sources).
+ *
+ * Connector nodes are excluded from trees; their info is shown as badges
+ * on the source model nodes instead.
+ *
+ * Resources not reachable from any dashboard go into an "Other resources" group.
+ */
+export function partitionResourcesByDashboardTrees(
+ resources: V1Resource[],
+): ResourceGraphGrouping[] {
+ resources = expandCanvasRefs(resources);
+ const resourceMap = buildVisibleResourceMap(resources);
+ const { incoming } = buildDirectedAdjacency(resourceMap);
+
+ // Identify dashboard resources (Explore/Canvas) as tree roots
+ const dashboardSeeds: { id: string; label: string }[] = [];
+ for (const [id, res] of resourceMap) {
+ const kind = coerceResourceKind(res);
+ if (kind === ResourceKind.Explore || kind === ResourceKind.Canvas) {
+ dashboardSeeds.push({ id, label: res.meta?.name?.name ?? id });
+ }
+ }
+ dashboardSeeds.sort((a, b) => a.label.localeCompare(b.label));
+
+ const groups: ResourceGraphGrouping[] = [];
+ const assigned = new Set();
+
+ for (const seed of dashboardSeeds) {
+ const upIds = traverseUpstream(seed.id, incoming);
+ // Exclude connector nodes from the tree.
+ // Allow resources to appear in multiple trees (shared upstream models)
+ // since buildMultiTreeLayout runs separate Dagre layouts per tree,
+ // and duplicate node IDs across trees are handled there.
+ const treeIds = new Set();
+ for (const rid of upIds) {
+ const res = resourceMap.get(rid);
+ if (!res) continue;
+ const kind = coerceResourceKind(res);
+ if (kind === ResourceKind.Connector) continue;
+ treeIds.add(rid);
+ }
+ if (!treeIds.size) continue;
+
+ const treeResources = Array.from(treeIds)
+ .map((rid) => resourceMap.get(rid))
+ .filter((r): r is V1Resource => !!r);
+ groups.push({ id: seed.id, resources: treeResources, label: seed.label });
+ for (const rid of treeIds) assigned.add(rid);
+ }
+
+ // Collect orphans (not connectors, not assigned to any tree)
+ const orphans: V1Resource[] = [];
+ for (const [id, res] of resourceMap) {
+ if (assigned.has(id)) continue;
+ const kind = coerceResourceKind(res);
+ if (kind === ResourceKind.Connector) continue;
+ orphans.push(res);
+ }
+ if (orphans.length) {
+ groups.push({
+ id: "__orphans__",
+ resources: orphans,
+ label: "Other resources",
+ });
+ }
+
return groups;
}
+
+/**
+ * Build a combined multi-tree layout for rendering all source trees
+ * on a single SvelteFlow canvas.
+ *
+ * Runs Dagre independently per tree, then offsets each tree horizontally
+ * so they appear side-by-side with padding between them.
+ */
+export function buildMultiTreeLayout(
+ groups: ResourceGraphGrouping[],
+ containerWidth?: number,
+): {
+ nodes: Node[];
+ edges: Edge[];
+} {
+ const TREE_GAP_X = 100; // horizontal gap between trees
+ const TREE_GAP_Y = 80; // vertical gap between rows
+ // Use actual container width so trees wrap into rows that fit the viewport.
+ // fitView will scale the result to fill the canvas afterward.
+ const MAX_ROW_WIDTH = containerWidth || 2500;
+ const allNodes: Node[] = [];
+ const allEdges: Edge[] = [];
+
+ // Track which node IDs have been seen globally to detect shared resources
+ const seenNodeIds = new Set();
+
+ // First pass: build each tree and compute bounding boxes
+ type TreeResult = {
+ nodes: Node[];
+ edges: Edge[];
+ width: number;
+ height: number;
+ minX: number;
+ minY: number;
+ prefix: string;
+ };
+ const trees: TreeResult[] = [];
+
+ for (const group of groups) {
+ const { nodes, edges } = buildResourceGraph(group.resources, {
+ positionNs: `sprawl|${group.id}`,
+ ignoreCache: true,
+ });
+ if (!nodes.length) continue;
+
+ const hasConflicts = nodes.some((n) => seenNodeIds.has(n.id));
+ const prefix = hasConflicts ? `${group.id}__` : "";
+ for (const node of nodes) seenNodeIds.add(node.id);
+
+ let minX = Infinity;
+ let maxX = -Infinity;
+ let minY = Infinity;
+ let maxY = -Infinity;
+ for (const node of nodes) {
+ const nx = node.position.x;
+ const ny = node.position.y;
+ const nw = node.width ?? NODE_CONFIG.MIN_WIDTH;
+ const nh = node.height ?? NODE_CONFIG.DEFAULT_HEIGHT;
+ if (nx < minX) minX = nx;
+ if (nx + nw > maxX) maxX = nx + nw;
+ if (ny < minY) minY = ny;
+ if (ny + nh > maxY) maxY = ny + nh;
+ }
+
+ trees.push({
+ nodes,
+ edges,
+ width: maxX - minX,
+ height: maxY - minY,
+ minX,
+ minY,
+ prefix,
+ });
+ }
+
+ // Second pass: arrange trees in rows, wrapping when exceeding MAX_ROW_WIDTH
+ let xOffset = 0;
+ let yOffset = 0;
+ let rowMaxHeight = 0;
+
+ for (const tree of trees) {
+ // Wrap to next row if this tree would exceed max width
+ if (xOffset > 0 && xOffset + tree.width > MAX_ROW_WIDTH) {
+ xOffset = 0;
+ yOffset += rowMaxHeight + TREE_GAP_Y;
+ rowMaxHeight = 0;
+ }
+
+ const shiftX = xOffset - tree.minX;
+ const shiftY = yOffset - tree.minY;
+
+ for (const node of tree.nodes) {
+ allNodes.push({
+ ...node,
+ id: tree.prefix + node.id,
+ position: {
+ x: node.position.x + shiftX,
+ y: node.position.y + shiftY,
+ },
+ });
+ }
+ for (const edge of tree.edges) {
+ allEdges.push({
+ ...edge,
+ id: tree.prefix + edge.id,
+ source: tree.prefix + edge.source,
+ target: tree.prefix + edge.target,
+ });
+ }
+
+ if (tree.height > rowMaxHeight) rowMaxHeight = tree.height;
+ xOffset += tree.width + TREE_GAP_X;
+ }
+
+ return { nodes: allNodes, edges: allEdges };
+}
diff --git a/web-common/src/features/resource-graph/graph-canvas/inspect-store.ts b/web-common/src/features/resource-graph/graph-canvas/inspect-store.ts
new file mode 100644
index 00000000000..6b5ebc573e9
--- /dev/null
+++ b/web-common/src/features/resource-graph/graph-canvas/inspect-store.ts
@@ -0,0 +1,49 @@
+import { getContext, setContext } from "svelte";
+import { writable, type Writable } from "svelte/store";
+import type { ResourceNodeData } from "../shared/types";
+
+export interface InspectState {
+ data: ResourceNodeData;
+ /** Position of the node relative to the graph container */
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+const INSPECT_CONTEXT_KEY = Symbol("inspect-store");
+
+/**
+ * Create a new inspect store and set it in the component's Svelte context.
+ * Call this in each GraphCanvas instance so each canvas gets its own store.
+ */
+export function initInspectStore(): Writable {
+ const store = writable(null);
+ setContext(INSPECT_CONTEXT_KEY, store);
+ return store;
+}
+
+/**
+ * Retrieve the inspect store from Svelte context.
+ * Falls back to a standalone store if no context is available (e.g., tests).
+ */
+export function getInspectStore(): Writable {
+ try {
+ return getContext>(INSPECT_CONTEXT_KEY);
+ } catch {
+ // Outside a component lifecycle (tests); return a fresh store.
+ return writable(null);
+ }
+}
+
+export function openInspect(
+ store: Writable,
+ data: ResourceNodeData,
+ rect: { x: number; y: number; width: number; height: number },
+) {
+ store.set({ data, ...rect });
+}
+
+export function closeInspect(store: Writable) {
+ store.set(null);
+}
diff --git a/web-common/src/features/resource-graph/navigation/GraphContainer.svelte b/web-common/src/features/resource-graph/navigation/GraphContainer.svelte
index c18c26877d4..50ad98b2d75 100644
--- a/web-common/src/features/resource-graph/navigation/GraphContainer.svelte
+++ b/web-common/src/features/resource-graph/navigation/GraphContainer.svelte
@@ -1,13 +1,56 @@
+
+
+
+
+ Full Refresh {resourceName}?
+
+
+ A full refresh will re-ingest ALL data from scratch. This operation
+ can take a significant amount of time and will update all dependent
+ resources. Only proceed if you're certain this is necessary.
+
+
+
+
+
+
+
+
+
diff --git a/web-common/src/features/resource-graph/shared/RefreshConfirmDialog.svelte b/web-common/src/features/resource-graph/shared/RefreshConfirmDialog.svelte
new file mode 100644
index 00000000000..e7d286d0069
--- /dev/null
+++ b/web-common/src/features/resource-graph/shared/RefreshConfirmDialog.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+ Refresh all sources and models?
+
+
+
This will refresh all project sources and models.
+
+ Note: To refresh a single resource,
+ click the '...' button on a node and select the refresh option.
+