wip: connections

This commit is contained in:
Ali Waseem committed 2026-03-19 14:22:02 -06:00
1 parent 3b94d12a2d
commit 8c84b39074
8 files changed
+719 -1

No files matched your search

@@ -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
</Popover_Shadcn_>
)}
{isTable && (
<ButtonTooltip
type="default"
icon={<Network strokeWidth={1.5} className="text-foreground-muted" />}
onClick={() => setShowConnections(true)}
tooltip={{
content: {
side: 'bottom',
text: 'View table connections',
},
}}
>
Connections
</ButtonTooltip>
)}
<RoleImpersonationPopover serviceRoleLabel="postgres" />
{doesHaveAutoGeneratedAPIDocs && (
@@ -528,6 +546,12 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
setIsAutofixViewSecurityModalOpen={setIsAutofixViewSecurityModalOpen}
/>
<TableConnectionsPanel
visible={showConnections}
onClose={() => setShowConnections(false)}
table={table}
/>
{isTable && (
<ConfirmationModal
visible={rlsConfirmModalOpen}
@@ -0,0 +1,44 @@
import { ReactNode } from 'react'
import Link from 'next/link'
import { ExternalLink } from 'lucide-react'
interface ConnectionItemProps {
name: string
description?: string
href: string
icon: ReactNode
badges?: string[]
}
export function ConnectionItem({ name, description, href, icon, badges }: ConnectionItemProps) {
return (
<Link
href={href}
className="flex items-center gap-3 px-4 py-2 text-sm hover:bg-surface-200 transition-colors group"
>
<span className="text-foreground-muted">{icon}</span>
<div className="flex-1 min-w-0">
<p className="text-foreground truncate">{name}</p>
{description && (
<p className="text-xs text-foreground-lighter truncate">{description}</p>
)}
{badges && badges.length > 0 && (
<div className="flex gap-1 mt-0.5 flex-wrap">
{badges.map((badge) => (
<span
key={badge}
className="inline-flex items-center rounded bg-surface-300 px-1.5 py-0.5 text-[10px] font-mono text-foreground-light"
>
{badge}
</span>
))}
</div>
)}
</div>
<ExternalLink
size={12}
className="text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</Link>
)
}
@@ -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<CenterTableNodeData>) => {
return (
<div
style={{ width: NODE_WIDTH }}
className="rounded-md border-2 border-brand bg-surface-100 shadow-md"
>
<div className="flex items-center gap-2 px-3 py-2 bg-brand/10 border-b border-brand/20">
<Table2 size={14} className="text-brand" />
<span className="text-sm font-medium text-foreground truncate">{data.name}</span>
</div>
<div className="px-3 py-1.5">
<span className="text-xs text-foreground-lighter">{data.schema}</span>
</div>
<Handle type="source" position={Position.Right} className="!opacity-0" />
<Handle type="target" position={Position.Left} className="!opacity-0" />
</div>
)
}
export const ConnectionNode = ({ data }: NodeProps<ConnectionNodeData>) => {
const config = categoryConfig[data.category]
const Icon = config.icon
return (
<div
style={{ width: NODE_WIDTH }}
className={cn('rounded-md border bg-surface-100 shadow-sm', config.borderColor)}
>
<div className="flex items-center gap-2 px-3 py-2">
<Icon size={14} className={config.color} />
<span className="text-xs font-medium text-foreground truncate">{data.label}</span>
</div>
{data.description && (
<div className="px-3 pb-2 -mt-0.5">
<span className="text-[11px] text-foreground-lighter truncate block">
{data.description}
</span>
</div>
)}
<Handle type="target" position={Position.Left} className="!opacity-0" />
<Handle type="source" position={Position.Right} className="!opacity-0" />
</div>
)
}
export const DIAGRAM_NODE_WIDTH = NODE_WIDTH
export const DIAGRAM_NODE_HEIGHT = 56
@@ -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 (
<Collapsible defaultOpen={defaultOpen}>
<Collapsible.Trigger asChild>
<button className="flex w-full items-center gap-2 py-2 px-4 text-sm font-medium text-foreground hover:bg-surface-200 transition-colors group">
{icon}
<span className="flex-1 text-left">{title}</span>
<span className="flex items-center justify-center rounded-full bg-surface-300 px-1.5 min-w-[20px] h-5 text-xs text-foreground-light font-mono">
{count}
</span>
<ChevronUp
size={14}
className="text-foreground-muted transition data-open-parent:rotate-0 data-closed-parent:rotate-180"
/>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
{count === 0 ? (
<p className="text-xs text-foreground-lighter px-4 py-2">
No {title.toLowerCase()} found for this table
</p>
) : (
<div className="pb-2">{children}</div>
)}
</Collapsible.Content>
</Collapsible>
)
}
@@ -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 (
<ReactFlowProvider>
<DiagramContent table={table} />
</ReactFlowProvider>
)
}
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<CenterTableNodeData | ConnectionNodeData>[] = []
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 (
<div className="flex items-center justify-center h-full text-sm text-foreground-lighter">
No connections found for this table
</div>
)
}
return (
<ReactFlow
fitView
fitViewOptions={{ padding: 0.3, maxZoom: 1 }}
zoomOnPinch={true}
zoomOnScroll={true}
nodesDraggable={true}
nodesConnectable={false}
zoomOnDoubleClick={false}
edgesFocusable={false}
edgesUpdatable={false}
defaultNodes={[]}
defaultEdges={[]}
nodeTypes={nodeTypes}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: 'smoothstep',
animated: true,
}}
>
<Background color={backgroundPatternColor} />
</ReactFlow>
)
}
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 }
}
@@ -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 (
<SidePanel
hideFooter
size="large"
header={
<div className="flex items-center gap-2">
<span>Connections for {table.name}</span>
<span className="flex items-center justify-center rounded-full bg-surface-300 px-1.5 min-w-[20px] h-5 text-xs text-foreground-light font-mono">
{total}
</span>
</div>
}
visible={visible}
onCancel={onClose}
>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-foreground-muted" />
</div>
) : (
<div className="flex flex-col">
<div className="h-[300px] border-b">
<TableConnectionsDiagram table={table} />
</div>
<div className="py-2 overflow-auto">
<ConnectionSection
title="Foreign Keys (outgoing)"
icon={<ArrowUpRight size={14} className="text-foreground-muted" />}
count={counts.outgoingForeignKeys}
>
{outgoingForeignKeys.map((fk) => (
<ConnectionItem
key={fk.id}
name={fk.constraint_name}
description={`→ ${fk.target_schema}.${fk.target_table}`}
href={`/project/${ref}/editor/${fk.target_id}`}
icon={<ArrowUpRight size={14} />}
badges={[
`${fk.source_columns.join(', ')} → ${fk.target_columns.join(', ')}`,
]}
/>
))}
</ConnectionSection>
<ConnectionSection
title="Foreign Keys (incoming)"
icon={<ArrowDownLeft size={14} className="text-foreground-muted" />}
count={counts.incomingForeignKeys}
>
{incomingForeignKeys.map((fk) => (
<ConnectionItem
key={fk.id}
name={fk.constraint_name}
description={`← ${fk.source_schema}.${fk.source_table}`}
href={`/project/${ref}/editor/${fk.source_id}`}
icon={<ArrowDownLeft size={14} />}
badges={[
`${fk.source_columns.join(', ')} → ${fk.target_columns.join(', ')}`,
]}
/>
))}
</ConnectionSection>
<ConnectionSection
title="Triggers"
icon={<Zap size={14} className="text-foreground-muted" />}
count={counts.triggers}
>
{triggers.map((trigger) => (
<ConnectionItem
key={trigger.id}
name={trigger.name}
description={`${trigger.activation} ${trigger.events.join('/')} → ${trigger.function_name}`}
href={`/project/${ref}/database/triggers`}
icon={<Zap size={14} />}
badges={[trigger.activation, ...trigger.events]}
/>
))}
</ConnectionSection>
<ConnectionSection
title="Database Hooks"
icon={<Webhook size={14} className="text-foreground-muted" />}
count={counts.hooks}
>
{hooks.map((hook) => (
<ConnectionItem
key={hook.id}
name={hook.name}
description={`${hook.events.join('/')} → ${hook.function_name}`}
href={`/project/${ref}/database/hooks`}
icon={<Webhook size={14} />}
badges={hook.events}
/>
))}
</ConnectionSection>
<ConnectionSection
title="RLS Policies"
icon={<Shield size={14} className="text-foreground-muted" />}
count={counts.policies}
>
{policies.map((policy) => (
<ConnectionItem
key={policy.id}
name={policy.name}
description={`${policy.command} — ${policy.action}`}
href={`/project/${ref}/auth/policies?search=${table.name}&schema=${table.schema}`}
icon={<Shield size={14} />}
badges={[policy.command, ...policy.roles]}
/>
))}
</ConnectionSection>
<ConnectionSection
title="Realtime"
icon={<Radio size={14} className="text-foreground-muted" />}
count={counts.realtime}
>
{isRealtimeEnabled && (
<ConnectionItem
name="supabase_realtime"
description="Table is published to realtime"
href={`/project/${ref}/database/publications`}
icon={<Radio size={14} />}
badges={['Enabled']}
/>
)}
</ConnectionSection>
</div>
</div>
)}
</SidePanel>
)
}
@@ -0,0 +1,2 @@
export { TableConnectionsPanel } from './TableConnectionsPanel'
export { useTableConnections } from './useTableConnections'
@@ -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,
}
}