diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 4a201d8d802..703f0dafb78 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Lightbulb, Lock, MousePointer2, PlusCircle, Unlock } from 'lucide-react' +import { Lightbulb, Lock, MousePointer2, Network, PlusCircle, Unlock } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' @@ -43,6 +43,7 @@ import { } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { RoleImpersonationPopover } from '../RoleImpersonationSelector/RoleImpersonationPopover' +import { TableConnectionsPanel } from './TableConnections' import ViewEntityAutofixSecurityModal from './ViewEntityAutofixSecurityModal' export interface GridHeaderActionsProps { @@ -84,6 +85,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp }, }) + const [showConnections, setShowConnections] = useState(false) const [showEnableRealtime, setShowEnableRealtime] = useState(false) const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false) const [isAutofixViewSecurityModalOpen, setIsAutofixViewSecurityModalOpen] = useState(false) @@ -487,6 +489,22 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp )} + {isTable && ( + } + onClick={() => setShowConnections(true)} + tooltip={{ + content: { + side: 'bottom', + text: 'View table connections', + }, + }} + > + Connections + + )} + {doesHaveAutoGeneratedAPIDocs && ( @@ -528,6 +546,12 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp setIsAutofixViewSecurityModalOpen={setIsAutofixViewSecurityModalOpen} /> + setShowConnections(false)} + table={table} + /> + {isTable && ( + {icon} +
+

{name}

+ {description && ( +

{description}

+ )} + {badges && badges.length > 0 && ( +
+ {badges.map((badge) => ( + + {badge} + + ))} +
+ )} +
+ + + ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/TableConnections/ConnectionNodes.tsx b/apps/studio/components/interfaces/TableGridEditor/TableConnections/ConnectionNodes.tsx new file mode 100644 index 00000000000..e2889d4705d --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/TableConnections/ConnectionNodes.tsx @@ -0,0 +1,90 @@ +import { + ArrowDownLeft, + ArrowUpRight, + Radio, + Shield, + Table2, + Webhook, + Zap, +} from 'lucide-react' +import { Handle, Position } from 'reactflow' +import type { NodeProps } from 'reactflow' +import { cn } from 'ui' + +const NODE_WIDTH = 220 + +export type CenterTableNodeData = { + name: string + schema: string +} + +export type ConnectionNodeData = { + label: string + description: string + category: 'trigger' | 'hook' | 'policy' | 'fk-outgoing' | 'fk-incoming' | 'realtime' +} + +const categoryConfig = { + trigger: { icon: Zap, color: 'text-amber-500', borderColor: 'border-amber-500/30' }, + hook: { icon: Webhook, color: 'text-purple-500', borderColor: 'border-purple-500/30' }, + policy: { icon: Shield, color: 'text-blue-500', borderColor: 'border-blue-500/30' }, + 'fk-outgoing': { + icon: ArrowUpRight, + color: 'text-green-500', + borderColor: 'border-green-500/30', + }, + 'fk-incoming': { + icon: ArrowDownLeft, + color: 'text-teal-500', + borderColor: 'border-teal-500/30', + }, + realtime: { icon: Radio, color: 'text-brand', borderColor: 'border-brand/30' }, +} + +export const CenterTableNode = ({ data }: NodeProps) => { + return ( +
+
+ + {data.name} +
+
+ {data.schema} +
+ + +
+ ) +} + +export const ConnectionNode = ({ data }: NodeProps) => { + const config = categoryConfig[data.category] + const Icon = config.icon + + return ( +
+
+ + {data.label} +
+ {data.description && ( +
+ + {data.description} + +
+ )} + + +
+ ) +} + +export const DIAGRAM_NODE_WIDTH = NODE_WIDTH +export const DIAGRAM_NODE_HEIGHT = 56 diff --git a/apps/studio/components/interfaces/TableGridEditor/TableConnections/ConnectionSection.tsx b/apps/studio/components/interfaces/TableGridEditor/TableConnections/ConnectionSection.tsx new file mode 100644 index 00000000000..5536108b836 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/TableConnections/ConnectionSection.tsx @@ -0,0 +1,46 @@ +import { ReactNode } from 'react' +import { ChevronUp } from 'lucide-react' +import { Collapsible } from 'ui' + +interface ConnectionSectionProps { + title: string + icon: ReactNode + count: number + defaultOpen?: boolean + children: ReactNode +} + +export function ConnectionSection({ + title, + icon, + count, + defaultOpen = true, + children, +}: ConnectionSectionProps) { + return ( + + + + + + {count === 0 ? ( +

+ No {title.toLowerCase()} found for this table +

+ ) : ( +
{children}
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/TableConnections/TableConnectionsDiagram.tsx b/apps/studio/components/interfaces/TableGridEditor/TableConnections/TableConnectionsDiagram.tsx new file mode 100644 index 00000000000..f54eb731e42 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/TableConnections/TableConnectionsDiagram.tsx @@ -0,0 +1,235 @@ +import dagre from '@dagrejs/dagre' +import { useTheme } from 'next-themes' +import { useEffect, useMemo } from 'react' +import ReactFlow, { Background, Edge, Node, Position, ReactFlowProvider, useReactFlow } from 'reactflow' +import 'reactflow/dist/style.css' + +import { Entity } from 'data/table-editor/table-editor-types' +import { useTableConnections } from './useTableConnections' +import { + CenterTableNode, + CenterTableNodeData, + ConnectionNode, + ConnectionNodeData, + DIAGRAM_NODE_HEIGHT, + DIAGRAM_NODE_WIDTH, +} from './ConnectionNodes' + +interface TableConnectionsDiagramProps { + table: Entity +} + +const nodeTypes = { + centerTable: CenterTableNode, + connection: ConnectionNode, +} + +export function TableConnectionsDiagram({ table }: TableConnectionsDiagramProps) { + return ( + + + + ) +} + +function DiagramContent({ table }: TableConnectionsDiagramProps) { + const reactFlow = useReactFlow() + const { resolvedTheme } = useTheme() + + const { + triggers, + hooks, + policies, + outgoingForeignKeys, + incomingForeignKeys, + isRealtimeEnabled, + isLoading, + } = useTableConnections({ schema: table.schema, name: table.name, id: table.id }) + + const { nodes, edges } = useMemo(() => { + const nodes: Node[] = [] + const edges: Edge[] = [] + + const centerId = 'center' + nodes.push({ + id: centerId, + type: 'centerTable', + data: { name: table.name, schema: table.schema }, + position: { x: 0, y: 0 }, + }) + + let nodeIndex = 0 + const addConnection = ( + id: string, + label: string, + description: string, + category: ConnectionNodeData['category'], + direction: 'left' | 'right' + ) => { + const nodeId = `${category}-${id}-${nodeIndex++}` + nodes.push({ + id: nodeId, + type: 'connection', + data: { label, description, category }, + position: { x: 0, y: 0 }, + }) + if (direction === 'right') { + edges.push({ + id: `e-${nodeId}`, + source: centerId, + target: nodeId, + type: 'smoothstep', + animated: true, + }) + } else { + edges.push({ + id: `e-${nodeId}`, + source: nodeId, + target: centerId, + type: 'smoothstep', + animated: true, + }) + } + } + + // Incoming FKs on the left + incomingForeignKeys.forEach((fk) => { + addConnection( + String(fk.id), + fk.constraint_name, + `${fk.source_schema}.${fk.source_table}`, + 'fk-incoming', + 'left' + ) + }) + + // Outgoing FKs on the right + outgoingForeignKeys.forEach((fk) => { + addConnection( + String(fk.id), + fk.constraint_name, + `→ ${fk.target_schema}.${fk.target_table}`, + 'fk-outgoing', + 'right' + ) + }) + + // Triggers on the right + triggers.forEach((t) => { + addConnection( + String(t.id), + t.name, + `${t.activation} ${t.events.join('/')}`, + 'trigger', + 'right' + ) + }) + + // Hooks on the right + hooks.forEach((h) => { + addConnection(String(h.id), h.name, h.events.join('/'), 'hook', 'right') + }) + + // Policies on the right + policies.forEach((p) => { + addConnection(String(p.id), p.name, `${p.command} — ${p.action}`, 'policy', 'right') + }) + + // Realtime on the right + if (isRealtimeEnabled) { + addConnection('realtime', 'supabase_realtime', 'Published', 'realtime', 'right') + } + + return applyDagreLayout(nodes, edges) + }, [ + table, + triggers, + hooks, + policies, + outgoingForeignKeys, + incomingForeignKeys, + isRealtimeEnabled, + ]) + + useEffect(() => { + if (!isLoading && nodes.length > 0) { + reactFlow.setNodes(nodes) + reactFlow.setEdges(edges) + setTimeout(() => reactFlow.fitView({ padding: 0.3, maxZoom: 1 }), 50) + } + }, [nodes, edges, isLoading]) + + const backgroundPatternColor = + resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)' + + if (isLoading) { + return null + } + + if (nodes.length <= 1) { + return ( +
+ No connections found for this table +
+ ) + } + + return ( + + + + ) +} + +function applyDagreLayout(nodes: Node[], edges: Edge[]) { + const dagreGraph = new dagre.graphlib.Graph() + dagreGraph.setDefaultEdgeLabel(() => ({})) + dagreGraph.setGraph({ + rankdir: 'LR', + nodesep: 30, + ranksep: 80, + }) + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { + width: DIAGRAM_NODE_WIDTH, + height: DIAGRAM_NODE_HEIGHT, + }) + }) + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target) + }) + + dagre.layout(dagreGraph) + + nodes.forEach((node) => { + const pos = dagreGraph.node(node.id) + node.targetPosition = Position.Left + node.sourcePosition = Position.Right + node.position = { + x: pos.x - DIAGRAM_NODE_WIDTH / 2, + y: pos.y - DIAGRAM_NODE_HEIGHT / 2, + } + }) + + return { nodes, edges } +} diff --git a/apps/studio/components/interfaces/TableGridEditor/TableConnections/TableConnectionsPanel.tsx b/apps/studio/components/interfaces/TableGridEditor/TableConnections/TableConnectionsPanel.tsx new file mode 100644 index 00000000000..383e6aa08f4 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/TableConnections/TableConnectionsPanel.tsx @@ -0,0 +1,174 @@ +import { + ArrowDownLeft, + ArrowUpRight, + Loader2, + Radio, + Shield, + Webhook, + Zap, +} from 'lucide-react' +import { useParams } from 'common' +import { SidePanel } from 'ui' +import { Entity } from 'data/table-editor/table-editor-types' +import { useTableConnections } from './useTableConnections' +import { ConnectionSection } from './ConnectionSection' +import { ConnectionItem } from './ConnectionItem' +import { TableConnectionsDiagram } from './TableConnectionsDiagram' + +interface TableConnectionsPanelProps { + visible: boolean + onClose: () => void + table: Entity +} + +export function TableConnectionsPanel({ visible, onClose, table }: TableConnectionsPanelProps) { + const { ref } = useParams() + + const { + triggers, + hooks, + policies, + outgoingForeignKeys, + incomingForeignKeys, + isRealtimeEnabled, + isLoading, + counts, + total, + } = useTableConnections({ schema: table.schema, name: table.name, id: table.id }) + + return ( + + Connections for {table.name} + + {total} + + + } + visible={visible} + onCancel={onClose} + > + {isLoading ? ( +
+ +
+ ) : ( +
+
+ +
+ +
+ } + count={counts.outgoingForeignKeys} + > + {outgoingForeignKeys.map((fk) => ( + } + badges={[ + `${fk.source_columns.join(', ')} → ${fk.target_columns.join(', ')}`, + ]} + /> + ))} + + + } + count={counts.incomingForeignKeys} + > + {incomingForeignKeys.map((fk) => ( + } + badges={[ + `${fk.source_columns.join(', ')} → ${fk.target_columns.join(', ')}`, + ]} + /> + ))} + + + } + count={counts.triggers} + > + {triggers.map((trigger) => ( + } + badges={[trigger.activation, ...trigger.events]} + /> + ))} + + + } + count={counts.hooks} + > + {hooks.map((hook) => ( + } + badges={hook.events} + /> + ))} + + + } + count={counts.policies} + > + {policies.map((policy) => ( + } + badges={[policy.command, ...policy.roles]} + /> + ))} + + + } + count={counts.realtime} + > + {isRealtimeEnabled && ( + } + badges={['Enabled']} + /> + )} + +
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/TableConnections/index.ts b/apps/studio/components/interfaces/TableGridEditor/TableConnections/index.ts new file mode 100644 index 00000000000..e21bb2309c2 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/TableConnections/index.ts @@ -0,0 +1,2 @@ +export { TableConnectionsPanel } from './TableConnectionsPanel' +export { useTableConnections } from './useTableConnections' diff --git a/apps/studio/components/interfaces/TableGridEditor/TableConnections/useTableConnections.ts b/apps/studio/components/interfaces/TableGridEditor/TableConnections/useTableConnections.ts new file mode 100644 index 00000000000..0a4edc92cfa --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/TableConnections/useTableConnections.ts @@ -0,0 +1,103 @@ +import { useDatabaseHooksQuery } from 'data/database-triggers/database-triggers-query' +import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query' +import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' +import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' +import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' + +interface UseTableConnectionsArgs { + schema: string + name: string + id: number +} + +export function useTableConnections({ schema, name, id }: UseTableConnectionsArgs) { + const { data: project } = useSelectedProjectQuery() + const projectRef = project?.ref + const connectionString = project?.connectionString + + const { data: triggersData, isLoading: isLoadingTriggers } = useDatabaseTriggersQuery({ + projectRef, + connectionString, + }) + + const { data: hooksData, isLoading: isLoadingHooks } = useDatabaseHooksQuery({ + projectRef, + connectionString, + }) + + const { data: policiesData, isLoading: isLoadingPolicies } = useDatabasePoliciesQuery({ + projectRef, + connectionString, + }) + + const { data: foreignKeysData, isLoading: isLoadingFKs } = useForeignKeyConstraintsQuery({ + projectRef, + connectionString, + schema, + }) + + const { data: publicationsData, isLoading: isLoadingPublications } = + useDatabasePublicationsQuery({ + projectRef, + connectionString, + }) + + const triggers = (triggersData ?? []).filter( + (t) => + t.schema === schema && + t.table === name && + t.function_schema !== 'supabase_functions' + ) + + const hooks = (hooksData ?? []).filter( + (h) => h.schema === schema && h.table === name + ) + + const policies = (policiesData ?? []).filter( + (p) => p.schema === schema && p.table === name + ) + + const outgoingForeignKeys = (foreignKeysData ?? []).filter( + (fk) => fk.source_schema === schema && fk.source_table === name + ) + + const incomingForeignKeys = (foreignKeysData ?? []).filter( + (fk) => fk.target_schema === schema && fk.target_table === name + ) + + const realtimePublication = (publicationsData ?? []).find( + (pub) => pub.name === 'supabase_realtime' + ) + const isRealtimeEnabled = (realtimePublication?.tables ?? []).some((t) => t.id === id) + + const isLoading = + isLoadingTriggers || + isLoadingHooks || + isLoadingPolicies || + isLoadingFKs || + isLoadingPublications + + const counts = { + triggers: triggers.length, + hooks: hooks.length, + policies: policies.length, + outgoingForeignKeys: outgoingForeignKeys.length, + incomingForeignKeys: incomingForeignKeys.length, + realtime: isRealtimeEnabled ? 1 : 0, + } + + const total = Object.values(counts).reduce((sum, c) => sum + c, 0) + + return { + triggers, + hooks, + policies, + outgoingForeignKeys, + incomingForeignKeys, + isRealtimeEnabled, + isLoading, + counts, + total, + } +}