mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
wip: connections
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
+90
@@ -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
|
||||
+46
@@ -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>
|
||||
)
|
||||
}
|
||||
+235
@@ -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 }
|
||||
}
|
||||
+174
@@ -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'
|
||||
+103
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user