Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/content/guides/self-hosting/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ Update the `./docker/.env` file with your own secrets. In particular, these are
- `SITE_URL`: the base URL of your site.
- `SMTP_*`: mail server credentials. You can use any SMTP server.
- `POOLER_TENANT_ID`: the tenant-id that will be used by Supavisor pooler for your connection string
- `PG_META_CRYPTO_KEY`: encryption key for securing connection strings between Studio and postgres-meta

You will need to [restart](#restarting-all-services) the services for the changes to take effect.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { PropsWithChildren } from 'react'

import { ActionName, ActionStatus, type ActionRunStep } from 'data/actions/action-runs-query'
import { Badge, StatusIcon, Tooltip, TooltipContent, TooltipTrigger } from 'ui'

export interface ActionStatusBadgeProps {
name: ActionName
status: ActionStatus
}

const UNHEALTHY_STATUES: ActionStatus[] = ['DEAD', 'REMOVING']
const WAITING_STATUSES: ActionStatus[] = ['CREATED', 'RESTARTING', 'RUNNING']

export const STATUS_TO_LABEL: Record<ActionStatus, string> = {
CREATED: 'pending',
DEAD: 'failed',
EXITED: 'succeeded',
PAUSED: 'skipped',
REMOVING: 'failed',
RESTARTING: 'restarting',
RUNNING: 'running',
}

const NAME_TO_LABEL: Record<ActionName, string> = {
clone: 'Cloning repo',
pull: 'Pulling data',
health: 'Health check',
configure: 'Configurations',
migrate: 'Migrations',
seed: 'Data seeding',
deploy: 'Functions deployment',
}

export const ActionStatusBadgeCondensed = ({
children,
status,
details,
}: PropsWithChildren<{
status: ActionStatus
details: Array<ActionRunStep>
}>) => {
if (status === 'EXITED') {
return null
}

const isUnhealthy = UNHEALTHY_STATUES.includes(status)
const isWaiting = WAITING_STATUSES.includes(status)

return (
<Tooltip>
<TooltipTrigger>
<Badge variant={isUnhealthy ? 'destructive' : 'default'} className="gap-1.5">
{(isUnhealthy || isWaiting) && (
<StatusIcon variant={isUnhealthy ? 'destructive' : 'default'} hideBackground />
)}
{children}
</Badge>
</TooltipTrigger>
<TooltipContent>
Additional {STATUS_TO_LABEL[status]} steps:
<ul>
{details.map((step) => (
<li key={step.name} className="before:content-['-'] before:mr-0.5">
{NAME_TO_LABEL[step.name]}
</li>
))}
</ul>
</TooltipContent>
</Tooltip>
)
}

export const ActionStatusBadge = ({ name, status }: ActionStatusBadgeProps) => {
if (status === 'EXITED') {
return null
}

const isUnhealthy = UNHEALTHY_STATUES.includes(status)
const isWaiting = WAITING_STATUSES.includes(status)

return (
<Badge variant={isUnhealthy ? 'destructive' : 'default'} className="gap-1.5">
{(isUnhealthy || isWaiting) && (
<StatusIcon variant={isUnhealthy ? 'destructive' : 'default'} hideBackground />
)}
{NAME_TO_LABEL[name]}: {STATUS_TO_LABEL[status]}
</Badge>
)
}
86 changes: 58 additions & 28 deletions apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { Branch } from 'data/branches/branches-query'
import dayjs from 'dayjs'
import { groupBy } from 'lodash'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useState } from 'react'
import { StatusIcon } from 'ui'

import AlertError from 'components/ui/AlertError'
import { useWorkflowRunLogsQuery } from 'data/workflow-runs/workflow-run-logs-query'
import { useWorkflowRunsQuery } from 'data/workflow-runs/workflow-runs-query'
import { ActionRunData } from 'data/actions/action-detail-query'
import { useActionRunLogsQuery } from 'data/actions/action-logs-query'
import {
useActionsQuery,
type ActionRunStep,
type ActionStatus,
} from 'data/actions/action-runs-query'
import type { Branch } from 'data/branches/branches-query'
import {
Button,
cn,
Expand All @@ -18,24 +23,21 @@ import {
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
StatusIcon,
} from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { ActionStatusBadge, ActionStatusBadgeCondensed, STATUS_TO_LABEL } from './ActionStatusBadge'
import BranchStatusBadge from './BranchStatusBadge'

interface WorkflowLogsProps {
projectRef: string
status?: Branch['status'] | string
status: Branch['status']
}

type StatusType = Branch['status'] | string
type StatusType = Branch['status']

const UNHEALTHY_STATUSES: StatusType[] = [
'ACTIVE_UNHEALTHY',
'INIT_FAILED',
'UNKNOWN',
'MIGRATIONS_FAILED',
'FUNCTIONS_FAILED',
]
const HEALTHY_STATUSES: StatusType[] = ['FUNCTIONS_DEPLOYED', 'MIGRATIONS_PASSED']
const UNHEALTHY_STATUSES: StatusType[] = ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED']

export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
const [isOpen, setIsOpen] = useState(false)
Expand All @@ -46,27 +48,25 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
isLoading: isWorkflowRunsLoading,
isError: isWorkflowRunsError,
error: workflowRunsError,
} = useWorkflowRunsQuery({ projectRef }, { enabled: isOpen })
} = useActionsQuery({ ref: projectRef }, { enabled: isOpen })

const [selectedWorkflowRunId, setSelectedWorkflowRunId] = useState<string | undefined>(undefined)
const [selectedWorkflowRun, setSelectedWorkflowRun] = useState<ActionRunData | undefined>(
undefined
)

const {
data: workflowRunLogs,
isSuccess: isWorkflowRunLogsSuccess,
isLoading: isWorkflowRunLogsLoading,
isError: isWorkflowRunLogsError,
error: workflowRunLogsError,
} = useWorkflowRunLogsQuery(
{ workflowRunId: selectedWorkflowRunId },
{ enabled: isOpen && selectedWorkflowRunId !== undefined }
} = useActionRunLogsQuery(
{ ref: projectRef, run_id: selectedWorkflowRun?.id ?? '' },
{ enabled: isOpen && Boolean(selectedWorkflowRun) }
)

const showStatusIcon =
status !== undefined &&
status !== 'ACTIVE_HEALTHY' &&
status !== 'FUNCTIONS_DEPLOYED' &&
status !== 'MIGRATIONS_PASSED'
const isUnhealthy = status !== undefined && UNHEALTHY_STATUSES.includes(status)
const showStatusIcon = !HEALTHY_STATUSES.includes(status)
const isUnhealthy = UNHEALTHY_STATUSES.includes(status)

return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
Expand All @@ -93,7 +93,7 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
<DialogSectionSeparator />

<DialogSection className={cn('px-0', isWorkflowRunLogsSuccess ? 'py-0 pt-2' : '!py-0')}>
{selectedWorkflowRunId === undefined ? (
{!selectedWorkflowRun ? (
<>
{isWorkflowRunsLoading && <GenericSkeletonLoader className="py-4" />}
{isWorkflowRunsError && (
Expand All @@ -108,11 +108,15 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
<li key={workflowRun.id} className="py-3">
<button
type="button"
onClick={() => setSelectedWorkflowRunId(workflowRun.id)}
onClick={() => setSelectedWorkflowRun(workflowRun)}
className="flex items-center gap-2 w-full justify-between"
>
<div className="flex items-center gap-4">
<BranchStatusBadge status={workflowRun.status} />
{workflowRun.run_steps.length > 0 ? (
<RunSteps steps={workflowRun.run_steps} />
) : (
<BranchStatusBadge status={status} />
)}
<span className="text-sm">
{dayjs(workflowRun.created_at).format('DD MMM, YYYY HH:mm')}
</span>
Expand All @@ -131,7 +135,7 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
) : (
<div className="flex flex-col gap-2 py-2">
<Button
onClick={() => setSelectedWorkflowRunId(undefined)}
onClick={() => setSelectedWorkflowRun(undefined)}
type="text"
icon={<ArrowLeft />}
className="self-start"
Expand Down Expand Up @@ -161,3 +165,29 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
</Dialog>
)
}

function RunSteps({ steps }: { steps: Array<ActionRunStep> }) {
const stepsByStatus = groupBy(steps, 'status') as Record<ActionStatus, Array<ActionRunStep>>
const firstFailedStep = stepsByStatus.DEAD?.[0]
const numberFailedSteps = stepsByStatus.DEAD?.length ?? 0

return (
<>
{firstFailedStep && (
<ActionStatusBadge name={firstFailedStep.name} status={firstFailedStep.status} />
)}
{numberFailedSteps > 1 && (
<ActionStatusBadgeCondensed status={'DEAD'} details={stepsByStatus.DEAD.slice(1)}>
{numberFailedSteps - 1} more
</ActionStatusBadgeCondensed>
)}
{(Object.keys(stepsByStatus) as Array<ActionStatus>)
.filter((status) => status !== 'DEAD')
.map((status) => (
<ActionStatusBadgeCondensed key={status} status={status} details={stepsByStatus[status]}>
{stepsByStatus[status].length} {STATUS_TO_LABEL[status]}
</ActionStatusBadgeCondensed>
))}
</>
)
}
30 changes: 30 additions & 0 deletions apps/studio/data/actions/action-detail-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'

import type { operations } from 'data/api'
import { get, handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { actionKeys } from './keys'

export type ActionRunVariables = operations['v1-get-action-run']['parameters']['path']

export async function getActionRun(params: ActionRunVariables, signal?: AbortSignal) {
const { data, error } = await get('/v1/projects/{ref}/actions/{run_id}', {
params: { path: params },
signal,
})
if (error) handleError(error)
return data
}

export type ActionRunData = Awaited<ReturnType<typeof getActionRun>>
export type ActionRunError = ResponseError

export const useActionRunQuery = <TData = ActionRunData>(
{ ref, run_id }: ActionRunVariables,
{ enabled = true, ...options }: UseQueryOptions<ActionRunData, ActionRunError, TData> = {}
) =>
useQuery<ActionRunData, ActionRunError, TData>(
actionKeys.detail(ref, run_id),
({ signal }) => getActionRun({ ref, run_id }, signal),
{ enabled: enabled && Boolean(ref) && Boolean(run_id), staleTime: 0, ...options }
)
36 changes: 36 additions & 0 deletions apps/studio/data/actions/action-logs-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'

import { operations } from 'data/api'
import { get, handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { actionKeys } from './keys'

export type ActionLogsVariables = operations['v1-get-action-run-logs']['parameters']['path']

export async function getActionRunLogs(params: ActionLogsVariables, signal?: AbortSignal) {
const { data, error } = await get(`/v1/projects/{ref}/actions/{run_id}/logs`, {
params: { path: params },
parseAs: 'text',
headers: { Accept: 'text/plain' },
signal,
})
if (error) handleError(error)
return data
.split('\n')
.flatMap((line) => line.split('\r'))
.join('\n')
.trim()
}

export type ActionLogsData = Awaited<ReturnType<typeof getActionRunLogs>>
export type WorkflowRunLogsError = ResponseError

export const useActionRunLogsQuery = <TData = ActionLogsData>(
{ ref, run_id }: ActionLogsVariables,
{ enabled = true, ...options }: UseQueryOptions<ActionLogsData, WorkflowRunLogsError, TData> = {}
) =>
useQuery<ActionLogsData, WorkflowRunLogsError, TData>(
actionKeys.detail(ref, run_id),
({ signal }) => getActionRunLogs({ ref, run_id }, signal),
{ enabled: enabled && Boolean(ref) && Boolean(run_id), staleTime: 0, ...options }
)
34 changes: 34 additions & 0 deletions apps/studio/data/actions/action-runs-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useQuery, type UseQueryOptions } from '@tanstack/react-query'

import type { operations } from 'data/api'
import { get, handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { actionKeys } from './keys'

export type ActionsVariables = operations['v1-list-action-runs']['parameters']['path']

export async function listActionRuns(params: ActionsVariables, signal?: AbortSignal) {
const { data, error } = await get(`/v1/projects/{ref}/actions`, {
params: { path: params },
signal,
})
if (error) handleError(error)
return data
}

export type ActionsData = Awaited<ReturnType<typeof listActionRuns>>
export type ActionsError = ResponseError

export type ActionRunStep = ActionsData[number]['run_steps'][number]
export type ActionName = ActionRunStep['name']
export type ActionStatus = ActionRunStep['status']

export const useActionsQuery = <TData = ActionsData>(
{ ref }: ActionsVariables,
{ enabled = true, ...options }: UseQueryOptions<ActionsData, ActionsError, TData> = {}
) =>
useQuery<ActionsData, ActionsError, TData>(
actionKeys.list(ref),
({ signal }) => listActionRuns({ ref }, signal),
{ enabled: enabled && Boolean(ref), staleTime: 0, ...options }
)
5 changes: 5 additions & 0 deletions apps/studio/data/actions/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const actionKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'actions'] as const,
detail: (projectRef: string | undefined, runId: string | undefined) =>
['projects', projectRef, 'actions', runId] as const,
}
9 changes: 9 additions & 0 deletions apps/studio/lib/api/self-hosted/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Constants specific to self-hosted environments

export const ENCRYPTION_KEY = process.env.PG_META_CRYPTO_KEY || 'SAMPLE_KEY'
export const POSTGRES_PORT = process.env.POSTGRES_PORT || 5432
export const POSTGRES_HOST = process.env.POSTGRES_HOST || 'db'
export const POSTGRES_DATABASE = process.env.POSTGRES_DB || 'postgres'
export const POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD || 'postgres'
export const POSTGRES_USER_READ_WRITE = 'postgres'
export const POSTGRES_USER_READ_ONLY = 'supabase_read_only_user'
4 changes: 2 additions & 2 deletions apps/studio/lib/api/self-hosted/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export function getDatabaseOperations({
}: GetDatabaseOperationsOptions): DatabaseOperations {
return {
async executeSql<T>(_projectRef: string, options: ExecuteSqlOptions) {
const { query } = options
const { data, error } = await executeQuery<T>({ query, headers })
const { query, read_only: readOnly } = options
const { data, error } = await executeQuery<T>({ query, headers, readOnly })

if (error) {
throw error
Expand Down
Loading
Loading