diff --git a/apps/studio/app/v2/project/[projectRef]/data/edge-functions/[functionSlug]/page.tsx b/apps/studio/app/v2/project/[projectRef]/data/edge-functions/[functionSlug]/page.tsx new file mode 100644 index 00000000000..d6ed1276e59 --- /dev/null +++ b/apps/studio/app/v2/project/[projectRef]/data/edge-functions/[functionSlug]/page.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useParams } from 'next/navigation' + +import { useV2Params } from '@/app/v2/V2ParamsContext' +import { EdgeFunctionOverviewPageContent } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionOverviewPageContent' +import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' +import { RouteParamsOverrideProvider } from 'common' + +export default function V2EdgeFunctionDetailPage() { + const params = useParams() + const { projectRef } = useV2Params() + const slug = params?.functionSlug as string + + if (!projectRef || !slug) return null + + return ( + + + + + + ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionOverviewPageContent.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionOverviewPageContent.tsx new file mode 100644 index 00000000000..b400ef62b93 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionOverviewPageContent.tsx @@ -0,0 +1,436 @@ +'use client' + +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { IS_PLATFORM, useParams } from 'common' +import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations' +import ReportWidget from 'components/interfaces/Reports/ReportWidget' +import AreaChart from 'components/ui/Charts/AreaChart' +import StackedBarChart from 'components/ui/Charts/StackedBarChart' +import NoPermission from 'components/ui/NoPermission' +import { + FunctionsCombinedStatsVariables, + useFunctionsCombinedStatsQuery, +} from 'data/analytics/functions-combined-stats-query' +import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' +import dayjs, { Dayjs } from 'dayjs' +import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import maxBy from 'lodash/maxBy' +import meanBy from 'lodash/meanBy' +import sumBy from 'lodash/sumBy' +import { useRouter } from 'next/compat/router' +import { useMemo, useState } from 'react' +import type { ChartIntervals } from 'types' +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Button, + WarningIcon, +} from 'ui' +import { PageContainer } from 'ui-patterns/PageContainer' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' + +const CHART_INTERVALS: ChartIntervals[] = [ + { + key: '15min', + label: '15 min', + startValue: 15, + startUnit: 'minute', + format: 'MMM D, h:mm:ssa', + }, + { + key: '1hr', + label: '1 hour', + startValue: 1, + startUnit: 'hour', + format: 'MMM D, h:mma', + }, + { + key: '3hr', + label: '3 hours', + startValue: 3, + startUnit: 'hour', + format: 'MMM D, h:mma', + }, + { + key: '1day', + label: '1 day', + startValue: 1, + startUnit: 'hour', + format: 'MMM D, h:mma', + }, +] + +/** Shared body of `pages/project/[ref]/functions/[functionSlug]/index.tsx` (Overview). */ +export function EdgeFunctionOverviewPageContent() { + const router = useRouter() + const { ref: projectRef, functionSlug } = useParams() + + const [interval, setInterval] = useState('15min') + const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] + const { data: selectedFunction } = useEdgeFunctionQuery({ + projectRef, + slug: functionSlug, + }) + const id = selectedFunction?.id + const combinedStatsResults = useFunctionsCombinedStatsQuery({ + projectRef, + functionId: id, + interval: selectedInterval.key as FunctionsCombinedStatsVariables['interval'], + }) + + const combinedStatsData = useMemo(() => { + const result = combinedStatsResults.data?.result as + | Record[] + | undefined + return result || [] + }, [combinedStatsResults.data]) + + const [startDate, endDate]: [Dayjs, Dayjs] = useMemo(() => { + const start = dayjs() + .subtract(selectedInterval.startValue, selectedInterval.startUnit as dayjs.ManipulateType) + .startOf(selectedInterval.startUnit as dayjs.ManipulateType) + + const end = dayjs().startOf(selectedInterval.startUnit as dayjs.ManipulateType) + return [start, end] + }, [selectedInterval]) + + const { + data: combinedStatsChartData, + error: combinedStatsError, + isError: isErrorCombinedStats, + } = useFillTimeseriesSorted({ + data: combinedStatsData, + timestampKey: 'timestamp', + valueKey: [ + 'requests_count', + 'log_count', + 'log_info_count', + 'log_warn_count', + 'log_error_count', + 'success_count', + 'redirect_count', + 'client_err_count', + 'server_err_count', + 'avg_cpu_time_used', + 'avg_memory_used', + 'avg_execution_time', + 'max_execution_time', + 'avg_heap_memory_used', + 'avg_external_memory_used', + 'max_cpu_time_used', + ], + defaultValue: 0, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + + const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckPermissions( + PermissionAction.FUNCTIONS_READ, + functionSlug as string + ) + if (!canReadFunction && !permissionsLoading) { + return + } + + const push = (path: string) => { + if (router) router.push(path) + else if (typeof window !== 'undefined') window.location.assign(path) + } + + return ( + + + + {IS_PLATFORM && id && ( +
+ +
+ )} +
+
+ {CHART_INTERVALS.map((item, i) => { + const classes = [] + + if (i === 0) { + classes.push('rounded-tr-none rounded-br-none') + } else if (i === CHART_INTERVALS.length - 1) { + classes.push('rounded-tl-none rounded-bl-none') + } else { + classes.push('rounded-none') + } + + return ( + + ) + })} +
+ + + Statistics for past {selectedInterval.label} + +
+
+
+ { + return isErrorCombinedStats ? ( + + + Failed to reterieve execution time + + {combinedStatsError?.message ?? 'Unknown error'} + + + ) : ( +
+ + +
+ ) + }} + /> + { + if (isErrorCombinedStats) { + return ( + + + Failed to reterieve invocations + + {combinedStatsError?.message ?? 'Unknown error'} + + + ) + } else { + const requestData = props.data + .map((d: any) => [ + { + status: '2xx', + count: d.success_count, + timestamp: d.timestamp, + }, + { + status: '3xx', + count: d.redirect_count, + timestamp: d.timestamp, + }, + { + status: '4xx', + count: d.client_err_count, + timestamp: d.timestamp, + }, + { + status: '5xx', + count: d.server_err_count, + timestamp: d.timestamp, + }, + ]) + .flat() + + const logsData = props.data + .map((d: any) => [ + { + status: 'error', + count: d.log_error_count, + timestamp: d.timestamp, + }, + { + status: 'info', + count: d.log_info_count, + timestamp: d.timestamp, + }, + { + status: 'warn', + count: d.log_warn_count, + timestamp: d.timestamp, + }, + ]) + .flat() + + return ( +
+ { + push( + `/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}` + ) + }} + /> + { + push( + `/project/${projectRef}/functions/${functionSlug}/logs?its=${startDate.toISOString()}` + ) + }} + /> +
+ ) + } + }} + /> + { + return isErrorCombinedStats ? ( + + + Failed to retrieve CPU time + + {combinedStatsError?.message ?? 'Unknown error'} + + + ) : ( +
+ + +
+ ) + }} + /> + { + if (isErrorCombinedStats) { + return ( + + + Failed to retrieve memory usage + + {combinedStatsError?.message ?? 'Unknown error'} + + + ) + } + + const memoryData = props.data + .map((d: any) => [ + { + type: 'heap', + count: d.avg_heap_memory_used, + timestamp: d.timestamp, + }, + { + type: 'external', + count: d.avg_external_memory_used, + timestamp: d.timestamp, + }, + ]) + .flat() + + return ( +
+ + +
+ ) + }} + /> +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index ae3565f0110..0c43d521c2d 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -18,7 +18,7 @@ import { withAuth } from 'hooks/misc/withAuth' import { DOCS_URL } from 'lib/constants' import { Clock, Download, FileArchive, Send } from 'lucide-react' import Link from 'next/link' -import { useRouter } from 'next/router' +import { useRouter } from 'next/compat/router' import React, { useEffect, useState, type PropsWithChildren } from 'react' import { toast } from 'sonner' import { @@ -64,8 +64,11 @@ const EdgeFunctionDetailsLayout = ({ children, }: PropsWithChildren) => { const router = useRouter() + const pathWithoutQuery = router?.asPath?.split('?')[0] ?? '' const { data: org } = useSelectedOrganizationQuery() const { functionSlug, ref } = useParams() + const v2OverviewPath = + ref && functionSlug ? `/v2/project/${ref}/data/edge-functions/${functionSlug}` : '' const { mutate: sendEvent } = useSendEventMutation() const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() @@ -229,7 +232,7 @@ const EdgeFunctionDetailsLayout = ({ if (!!functionSlug && isError && error.code === 404 && !cancel) { toast('Edge function cannot be found in your project') - router.push(`/project/${ref}/functions`) + router?.push(`/project/${ref}/functions`) } return () => { @@ -393,7 +396,11 @@ const EdgeFunctionDetailsLayout = ({ {navigationItems.map((item) => { - const isActive = router.asPath.split('?')[0] === item.href + const isActive = + pathWithoutQuery === item.href || + (Boolean(v2OverviewPath) && + item.href === `/project/${ref}/functions/${functionSlug}` && + pathWithoutQuery === v2OverviewPath) return ( {item.label} diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx index 3da3e6bd119..24ca97d82e2 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { ProductMenu } from 'components/ui/ProductMenu' import { withAuth } from 'hooks/misc/withAuth' -import { useRouter } from 'next/router' +import { useRouter } from 'next/compat/router' import type { ComponentProps, PropsWithChildren } from 'react' import { ProjectLayout } from '../ProjectLayout' @@ -9,7 +9,7 @@ import { ProjectLayout } from '../ProjectLayout' export const EdgeFunctionsProductMenu = () => { const { ref: projectRef = 'default' } = useParams() const router = useRouter() - const page = router.pathname.split('/')[4] + const page = router?.pathname?.split('/')[4] const menuItems = [ { diff --git a/apps/studio/components/v2/DataTabBar.tsx b/apps/studio/components/v2/DataTabBar.tsx index 8e4152bdb04..e114fe93e3f 100644 --- a/apps/studio/components/v2/DataTabBar.tsx +++ b/apps/studio/components/v2/DataTabBar.tsx @@ -42,10 +42,22 @@ export function DataTabBar() { if (!pathname.startsWith(base)) return const rest = pathname.slice(base.length) - const [category] = rest.split('/') + const [category, detailKey] = rest.split('/') if (!category || category === 'tables') return if (!(category in CATEGORY_LABELS)) return + if (category === 'edge-functions' && detailKey) { + openDataTab({ + id: `edge-function:${detailKey}`, + label: detailKey, + type: 'detail', + category, + domain: CATEGORY_DOMAIN[category] ?? 'fn', + path: `${base}${category}/${detailKey}`, + }) + return + } + openDataTab({ id: category, label: CATEGORY_LABELS[category] ?? category, diff --git a/apps/studio/components/v2/DataTableRenderer/DataTableHeader.tsx b/apps/studio/components/v2/DataTableRenderer/DataTableHeader.tsx index 17f29603d6d..671860105c5 100644 --- a/apps/studio/components/v2/DataTableRenderer/DataTableHeader.tsx +++ b/apps/studio/components/v2/DataTableRenderer/DataTableHeader.tsx @@ -2,7 +2,8 @@ import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react' import { useCallback, useRef } from 'react' -import { cn } from 'ui' +import { cn, Input_Shadcn_ } from 'ui' + import type { DataTableColumn, SortState } from './types' interface DataTableHeaderProps { @@ -80,8 +81,9 @@ export function DataTableHeader({ {selectable && ( - { diff --git a/apps/studio/components/v2/DataTableRenderer/DataTableToolbar.tsx b/apps/studio/components/v2/DataTableRenderer/DataTableToolbar.tsx index 2e046f13025..ee6046b354b 100644 --- a/apps/studio/components/v2/DataTableRenderer/DataTableToolbar.tsx +++ b/apps/studio/components/v2/DataTableRenderer/DataTableToolbar.tsx @@ -1,7 +1,20 @@ 'use client' import { Search } from 'lucide-react' -import { cn } from 'ui' +import { + cn, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, + Input_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, +} from 'ui' + import type { FilterDefinition, FilterState } from './types' interface DataTableToolbarProps { @@ -34,45 +47,95 @@ export function DataTableToolbar({
{toolbarLeft} {filters?.map((filter) => { + const currentValue = filterState[filter.id] + if (filter.render) { + return ( +
+ {filter.render({ + value: currentValue, + onChange: (value) => updateFilter(filter.id, value), + filterState, + })} +
+ ) + } + if (filter.type === 'search') { return (
- updateFilter(filter.id, e.target.value)} - className={cn( - 'h-8 rounded-md border border-control bg-surface-100', - 'pl-7 pr-3 text-xs text-foreground placeholder:text-foreground-lighter', - 'focus:outline-none focus:ring-1 focus:ring-brand', - 'min-w-[180px]' - )} + size="small" + className={cn('h-8 bg-surface-100', 'pl-7 pr-3 text-xs', 'min-w-[180px]')} />
) } - if (filter.type === 'select' || filter.type === 'multi-select') { + if (filter.type === 'select') { + const value = (filterState[filter.id] as string) ?? '__all__' return ( - + + + + + + {filter.placeholder ?? `All ${filter.label}`} + + {filter.options?.map((opt) => ( + + {opt.label} + + ))} + + + ) + } + + if (filter.type === 'multi-select') { + const selected = Array.isArray(filterState[filter.id]) + ? (filterState[filter.id] as string[]) + : [] + return ( + + + + + + {filter.options?.map((opt) => ( + { + const next = checked + ? [...selected, opt.value] + : selected.filter((v) => v !== opt.value) + updateFilter(filter.id, next) + }} + > + {opt.label} + + ))} + + ) } diff --git a/apps/studio/components/v2/DataTableRenderer/cells/AvatarCell.tsx b/apps/studio/components/v2/DataTableRenderer/cells/AvatarCell.tsx index 4ffc2aa000a..af6234d9c36 100644 --- a/apps/studio/components/v2/DataTableRenderer/cells/AvatarCell.tsx +++ b/apps/studio/components/v2/DataTableRenderer/cells/AvatarCell.tsx @@ -33,11 +33,11 @@ export function AvatarCell({ value }: AvatarCellProps) { return (
{initials} - {str} + {str}
) } diff --git a/apps/studio/components/v2/DataTableRenderer/cells/DateCell.tsx b/apps/studio/components/v2/DataTableRenderer/cells/DateCell.tsx index 4cb6219e1a7..802faa6c611 100644 --- a/apps/studio/components/v2/DataTableRenderer/cells/DateCell.tsx +++ b/apps/studio/components/v2/DataTableRenderer/cells/DateCell.tsx @@ -1,3 +1,6 @@ +import { getColumnType } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/DateTimeInput/DateTimeInput.utils' +import dayjs from 'dayjs' +import { DATETIME_FORMAT } from 'lib/constants' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' interface DateCellProps { @@ -5,28 +8,11 @@ interface DateCellProps { showTime?: boolean } -function formatRelative(date: Date): string { - const diff = Date.now() - date.getTime() - const seconds = Math.floor(diff / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - const days = Math.floor(hours / 24) - - if (seconds < 60) return 'just now' - if (minutes < 60) return `${minutes}m ago` - if (hours < 24) return `${hours}h ago` - if (days < 30) return `${days}d ago` - return date.toLocaleDateString() -} - function formatFull(date: Date, showTime: boolean): string { - if (showTime) { - return date - .toISOString() - .replace('T', ' ') - .replace(/\.\d{3}Z$/, ' UTC') - } - return date.toDateString() + const valueType = getColumnType(showTime ? 'timestamp' : 'date') + if (valueType === 'date') return dayjs(date).format('YYYY-MM-DD') + if (valueType === 'time') return dayjs(date).format('HH:mm:ss') + return dayjs(date).format('YYYY-MM-DD HH:mm:ss') } export function DateCell({ value, showTime = false }: DateCellProps) { @@ -42,7 +28,7 @@ export function DateCell({ value, showTime = false }: DateCellProps) { return ( - {formatRelative(date)} + {dayjs(date).format(DATETIME_FORMAT)} {formatFull(date, showTime)} diff --git a/apps/studio/components/v2/DataTableRenderer/index.tsx b/apps/studio/components/v2/DataTableRenderer/index.tsx index 830bde493a1..0178b2e2be3 100644 --- a/apps/studio/components/v2/DataTableRenderer/index.tsx +++ b/apps/studio/components/v2/DataTableRenderer/index.tsx @@ -24,6 +24,7 @@ export function DataTableRenderer>({ onRetry, sort: controlledSort, onSortChange: controlledOnSortChange, + renderSortControl, filters, filterState: controlledFilterState, onFilterChange: controlledOnFilterChange, @@ -172,7 +173,8 @@ export function DataTableRenderer>({ const hasPagination = !!pagination && !!onPageChange const hasRowActions = !!rowActions && rowActions.length > 0 const showChromeToolbar = - !hideToolbar && (hasBulkBar || Boolean(filters?.length) || toolbarLeft || toolbarRight) + !hideToolbar && + (hasBulkBar || Boolean(filters?.length) || toolbarLeft || toolbarRight || renderSortControl) return (
@@ -190,7 +192,15 @@ export function DataTableRenderer>({ filterState={activeFilterState} onFilterChange={handleFilterChange} toolbarLeft={toolbarLeft} - toolbarRight={toolbarRight} + toolbarRight={ + <> + {renderSortControl?.({ + sort: activeSort, + onSortChange: handleSortChange, + })} + {toolbarRight} + + } /> ) : null} diff --git a/apps/studio/components/v2/DataTableRenderer/types.ts b/apps/studio/components/v2/DataTableRenderer/types.ts index 93b98f78f7c..0152d79463c 100644 --- a/apps/studio/components/v2/DataTableRenderer/types.ts +++ b/apps/studio/components/v2/DataTableRenderer/types.ts @@ -49,6 +49,11 @@ export interface FilterDefinition { type: 'search' | 'select' | 'multi-select' | 'toggle' options?: Array<{ value: string; label: string }> placeholder?: string + render?: (args: { + value: string | string[] | boolean | undefined + onChange: (value: string | string[] | boolean) => void + filterState: FilterState + }) => ReactNode } export type FilterState = Record @@ -106,6 +111,10 @@ export interface DataTableRendererProps = any> { // If omitted, sorting is handled client-side internally sort?: SortState | null onSortChange?: (sort: SortState | null) => void + renderSortControl?: (args: { + sort: SortState | null + onSortChange: (sort: SortState | null) => void + }) => ReactNode // ── Filtering (controlled) ── // If omitted, filtering is handled client-side when filters is defined diff --git a/apps/studio/components/v2/hooks/useEntityPanelParams.ts b/apps/studio/components/v2/hooks/useEntityPanelParams.ts new file mode 100644 index 00000000000..fd17263a91b --- /dev/null +++ b/apps/studio/components/v2/hooks/useEntityPanelParams.ts @@ -0,0 +1,34 @@ +'use client' + +import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' + +export function useEntityPanelParams() { + const [isCreating, setIsCreating] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + const [editingId, setEditingId] = useQueryState( + 'edit', + parseAsString.withOptions({ history: 'push', clearOnDefault: true }) + ) + const [duplicatingId, setDuplicatingId] = useQueryState( + 'duplicate', + parseAsString.withOptions({ history: 'push', clearOnDefault: true }) + ) + + const closePanels = () => { + setIsCreating(false) + setEditingId(null) + setDuplicatingId(null) + } + + return { + isCreating, + setIsCreating, + editingId, + setEditingId, + duplicatingId, + setDuplicatingId, + closePanels, + } +} diff --git a/apps/studio/components/v2/views/V2EdgeFunctionsList.tsx b/apps/studio/components/v2/views/V2EdgeFunctionsList.tsx index b1d4650f677..b858d41f8f2 100644 --- a/apps/studio/components/v2/views/V2EdgeFunctionsList.tsx +++ b/apps/studio/components/v2/views/V2EdgeFunctionsList.tsx @@ -2,10 +2,12 @@ import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' import type { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query' +import { useRouter } from 'next/navigation' import { useV2Params } from '@/app/v2/V2ParamsContext' import { DataTableRenderer } from '@/components/v2/DataTableRenderer' import type { DataTableColumn } from '@/components/v2/DataTableRenderer' +import { useV2DashboardStore } from '@/stores/v2-dashboard' const EDGE_FUNCTIONS_COLUMNS: DataTableColumn[] = [ { @@ -54,7 +56,9 @@ const EDGE_FUNCTIONS_COLUMNS: DataTableColumn[] = [ ] export function V2EdgeFunctionsList() { + const router = useRouter() const { projectRef } = useV2Params() + const openDataTab = useV2DashboardStore((s) => s.openDataTab) const { data: functions, @@ -73,6 +77,19 @@ export function V2EdgeFunctionsList() { filters={[ { id: 'search', label: 'Search', type: 'search', placeholder: 'Filter edge functions…' }, ]} + onRowClick={(row) => { + if (!projectRef) return + const path = `/v2/project/${projectRef}/data/edge-functions/${row.slug}` + openDataTab({ + id: `edge-function:${row.slug}`, + label: row.name || row.slug, + type: 'detail', + category: 'edge-functions', + domain: 'fn', + path, + }) + router.push(path) + }} emptyState={{ title: 'No edge functions yet', description: 'Deploy your first edge function to get started.', diff --git a/apps/studio/components/v2/views/V2FunctionsList.tsx b/apps/studio/components/v2/views/V2FunctionsList.tsx index 511501a0234..8eef843003c 100644 --- a/apps/studio/components/v2/views/V2FunctionsList.tsx +++ b/apps/studio/components/v2/views/V2FunctionsList.tsx @@ -5,8 +5,10 @@ import type { DatabaseFunction } from 'data/database-functions/database-function import { useSchemasQuery } from 'data/database/schemas-query' import { isValidConnString } from 'data/fetchers' import { useProjectDetailQuery } from 'data/projects/project-detail-query' +import { CopyPlus, Pencil, Plus } from 'lucide-react' import { useState } from 'react' import { + Button, Select_Shadcn_, SelectContent_Shadcn_, SelectItem_Shadcn_, @@ -15,8 +17,10 @@ import { } from 'ui' import { useV2Params } from '@/app/v2/V2ParamsContext' +import { CreateFunction } from '@/components/interfaces/Database/Functions/CreateFunction' import { DataTableRenderer } from '@/components/v2/DataTableRenderer' import type { DataTableColumn } from '@/components/v2/DataTableRenderer' +import { useEntityPanelParams } from '@/components/v2/hooks/useEntityPanelParams' const FUNCTIONS_COLUMNS: DataTableColumn[] = [ { @@ -77,6 +81,15 @@ const FUNCTIONS_COLUMNS: DataTableColumn[] = [ export function V2FunctionsList() { const { projectRef } = useV2Params() const [schema, setSchema] = useState('public') + const { + isCreating, + setIsCreating, + editingId, + setEditingId, + duplicatingId, + setDuplicatingId, + closePanels, + } = useEntityPanelParams() const { data: project, isPending: isProjectPending } = useProjectDetailQuery( { ref: projectRef }, @@ -103,6 +116,8 @@ export function V2FunctionsList() { const schemaFunctions = (functions ?? []).filter( (f) => schema === '__all__' || f.schema === schema ) + const functionToEdit = schemaFunctions.find((f) => f.id.toString() === editingId) + const functionToDuplicate = schemaFunctions.find((f) => f.id.toString() === duplicatingId) const schemaSelector = ( @@ -121,25 +136,64 @@ export function V2FunctionsList() { ) return ( - - columns={FUNCTIONS_COLUMNS} - rows={schemaFunctions} - rowKey="id" - isLoading={isProjectPending || (shouldFetch && isFunctionsPending)} - error={isError ? (error as Error) : null} - filters={[ - { - id: 'search', - label: 'Search', - type: 'search', - placeholder: 'Filter functions…', - }, - ]} - toolbarLeft={schemaSelector} - emptyState={{ - title: 'No functions found', - description: 'Create a database function to get started.', - }} - /> + <> + + columns={FUNCTIONS_COLUMNS} + rows={schemaFunctions} + rowKey="id" + isLoading={isProjectPending || (shouldFetch && isFunctionsPending)} + error={isError ? (error as Error) : null} + filters={[ + { + id: 'search', + label: 'Search', + type: 'search', + placeholder: 'Filter functions…', + }, + ]} + toolbarLeft={schemaSelector} + toolbarRight={ + + } + rowActions={[ + { + id: 'edit', + label: 'Edit function', + icon: , + onClick: (row) => { + setDuplicatingId(null) + setEditingId(row.id.toString()) + }, + }, + { + id: 'duplicate', + label: 'Duplicate function', + icon: , + onClick: (row) => { + setEditingId(null) + setDuplicatingId(row.id.toString()) + }, + }, + ]} + emptyState={{ + title: 'No functions found', + description: 'Create a database function to get started.', + }} + /> + + + ) } diff --git a/apps/studio/components/v2/views/V2RolesList.tsx b/apps/studio/components/v2/views/V2RolesList.tsx index 64dbcd64556..acd1bf076a8 100644 --- a/apps/studio/components/v2/views/V2RolesList.tsx +++ b/apps/studio/components/v2/views/V2RolesList.tsx @@ -4,10 +4,14 @@ import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' import type { PgRole } from 'data/database-roles/database-roles-query' import { isValidConnString } from 'data/fetchers' import { useProjectDetailQuery } from 'data/projects/project-detail-query' +import { Plus } from 'lucide-react' +import { Button } from 'ui' import { useV2Params } from '@/app/v2/V2ParamsContext' +import { CreateRolePanel } from '@/components/interfaces/Database/Roles/CreateRolePanel' import { DataTableRenderer } from '@/components/v2/DataTableRenderer' import type { DataTableColumn } from '@/components/v2/DataTableRenderer' +import { useEntityPanelParams } from '@/components/v2/hooks/useEntityPanelParams' const ROLES_COLUMNS: DataTableColumn[] = [ { @@ -93,6 +97,7 @@ const ROLES_COLUMNS: DataTableColumn[] = [ export function V2RolesList() { const { projectRef } = useV2Params() + const { isCreating, setIsCreating } = useEntityPanelParams() const { data: project, isPending: isProjectPending } = useProjectDetailQuery( { ref: projectRef }, @@ -112,25 +117,39 @@ export function V2RolesList() { ) return ( - - columns={ROLES_COLUMNS} - rows={(roles as PgRole[]) ?? []} - rowKey="name" - isLoading={isProjectPending || (shouldFetch && isRolesPending)} - error={isError ? (error as Error) : null} - compact - filters={[ - { - id: 'search', - label: 'Search', - type: 'search', - placeholder: 'Filter roles…', - }, - ]} - emptyState={{ - title: 'No roles found', - description: 'Database roles will appear here.', - }} - /> + <> + + columns={ROLES_COLUMNS} + rows={(roles as PgRole[]) ?? []} + rowKey="name" + isLoading={isProjectPending || (shouldFetch && isRolesPending)} + error={isError ? (error as Error) : null} + compact + filters={[ + { + id: 'search', + label: 'Search', + type: 'search', + placeholder: 'Filter roles…', + }, + ]} + toolbarRight={ + + } + emptyState={{ + title: 'No roles found', + description: 'Database roles will appear here.', + }} + /> + + setIsCreating(false)} /> + ) } diff --git a/apps/studio/components/v2/views/V2TypesList.tsx b/apps/studio/components/v2/views/V2TypesList.tsx index 7718a0e2117..e364181ffdb 100644 --- a/apps/studio/components/v2/views/V2TypesList.tsx +++ b/apps/studio/components/v2/views/V2TypesList.tsx @@ -4,10 +4,15 @@ import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types- import type { EnumeratedType } from 'data/enumerated-types/enumerated-types-query' import { isValidConnString } from 'data/fetchers' import { useProjectDetailQuery } from 'data/projects/project-detail-query' +import { Pencil, Plus } from 'lucide-react' +import { Button } from 'ui' import { useV2Params } from '@/app/v2/V2ParamsContext' +import CreateEnumeratedTypeSidePanel from '@/components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel' +import EditEnumeratedTypeSidePanel from '@/components/interfaces/Database/EnumeratedTypes/EditEnumeratedTypeSidePanel' import { DataTableRenderer } from '@/components/v2/DataTableRenderer' import type { DataTableColumn } from '@/components/v2/DataTableRenderer' +import { useEntityPanelParams } from '@/components/v2/hooks/useEntityPanelParams' const TYPES_COLUMNS: DataTableColumn[] = [ { @@ -47,6 +52,7 @@ const TYPES_COLUMNS: DataTableColumn[] = [ export function V2TypesList() { const { projectRef } = useV2Params() + const { isCreating, setIsCreating, editingId, setEditingId } = useEntityPanelParams() const { data: project, isPending: isProjectPending } = useProjectDetailQuery( { ref: projectRef }, @@ -64,17 +70,49 @@ export function V2TypesList() { { projectRef, connectionString: project?.connectionString }, { enabled: shouldFetch } ) + const selectedType = (types ?? []).find((type) => type.id.toString() === editingId) return ( - - columns={TYPES_COLUMNS} - rows={(types as EnumeratedType[]) ?? []} - rowKey="id" - isLoading={isProjectPending || (shouldFetch && isTypesPending)} - error={isError ? (error as Error) : null} - compact - filters={[{ id: 'search', label: 'Search', type: 'search', placeholder: 'Filter types…' }]} - emptyState={{ title: 'No enumerated types found' }} - /> + <> + + columns={TYPES_COLUMNS} + rows={(types as EnumeratedType[]) ?? []} + rowKey="id" + isLoading={isProjectPending || (shouldFetch && isTypesPending)} + error={isError ? (error as Error) : null} + compact + filters={[{ id: 'search', label: 'Search', type: 'search', placeholder: 'Filter types…' }]} + toolbarRight={ + + } + rowActions={[ + { + id: 'edit', + label: 'Edit type', + icon: , + onClick: (row) => setEditingId(row.id.toString()), + }, + ]} + emptyState={{ title: 'No enumerated types found' }} + /> + + setIsCreating(false)} + schema="public" + /> + setEditingId(null)} + /> + ) } diff --git a/apps/studio/components/v2/views/V2UsersList.tsx b/apps/studio/components/v2/views/V2UsersList.tsx index 1ec8e977dc6..f581fe1e88f 100644 --- a/apps/studio/components/v2/views/V2UsersList.tsx +++ b/apps/studio/components/v2/views/V2UsersList.tsx @@ -1,15 +1,19 @@ 'use client' +import { SortDropdown } from 'components/interfaces/Auth/Users/SortDropdown' import { useUsersCountQuery } from 'data/auth/users-count-query' import { useUsersInfiniteQuery } from 'data/auth/users-infinite-query' -import type { User } from 'data/auth/users-infinite-query' +import type { User, Filter as UserTypeFilter } from 'data/auth/users-infinite-query' import { isValidConnString } from 'data/fetchers' import { useProjectDetailQuery } from 'data/projects/project-detail-query' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useState } from 'react' import { useV2Params } from '@/app/v2/V2ParamsContext' +import { AddUserDropdown } from '@/components/interfaces/Auth/Users/AddUserDropdown' +import { PROVIDER_FILTER_OPTIONS } from '@/components/interfaces/Auth/Users/Users.constants' import { DataTableRenderer } from '@/components/v2/DataTableRenderer' -import type { DataTableColumn, SortState } from '@/components/v2/DataTableRenderer' +import type { DataTableColumn, FilterState, SortState } from '@/components/v2/DataTableRenderer' const PAGE_SIZE = 50 @@ -85,7 +89,22 @@ const USERS_COLUMNS: DataTableColumn[] = [ export function V2UsersList() { const { projectRef } = useV2Params() const [search, setSearch] = useState('') + const [filterUserType, setFilterUserType] = useState<'all' | UserTypeFilter>('all') + const [selectedProviders, setSelectedProviders] = useState([]) const [sort, setSort] = useState(null) + const { + authenticationShowSortByEmail: showSortByEmail, + authenticationShowSortByPhone: showSortByPhone, + } = useIsFeatureEnabled([ + 'authentication:show_sort_by_email', + 'authentication:show_sort_by_phone', + ]) + + const sortColumn = + (sort?.columnId as 'created_at' | 'email' | 'id' | 'phone' | 'last_sign_in_at') ?? 'created_at' + const sortOrder = sort?.direction ?? 'asc' + const sortByValue = `${sortColumn}:${sortOrder}` + const [page, setPage] = useState(1) const { data: project, isLoading: isProjectLoading } = useProjectDetailQuery( @@ -95,7 +114,16 @@ export function V2UsersList() { const shouldFetch = Boolean(projectRef) && isValidConnString(project?.connectionString) - const { data: countData } = useUsersCountQuery({ projectRef }, { enabled: shouldFetch }) + const { data: countData } = useUsersCountQuery( + { + projectRef, + connectionString: project?.connectionString, + keywords: search || undefined, + filter: filterUserType === 'all' ? undefined : filterUserType, + providers: selectedProviders, + }, + { enabled: shouldFetch } + ) const total = countData?.count ?? 0 const { @@ -110,10 +138,10 @@ export function V2UsersList() { projectRef, connectionString: project?.connectionString, keywords: search || undefined, - sort: - (sort?.columnId as 'created_at' | 'email' | 'id' | 'phone' | 'last_sign_in_at') ?? - 'created_at', - order: sort?.direction ?? 'asc', + filter: filterUserType === 'all' ? undefined : filterUserType, + providers: selectedProviders, + sort: sortColumn, + order: sortOrder, }, { enabled: shouldFetch } ) @@ -133,16 +161,38 @@ export function V2UsersList() { } const handleSortChange = (s: SortState | null) => { - setSort(s) + setSort(s ?? { columnId: 'created_at', direction: 'asc' }) setPage(1) } - const handleFilterChange = (state: Record) => { + const handleSortByValue = (value: string) => { + const [columnId, direction] = value.split(':') as [ + SortState['columnId'], + SortState['direction'], + ] + setSort({ columnId, direction }) + setPage(1) + } + + const handleFilterChange = (state: FilterState) => { const newSearch = (state['search'] as string) ?? '' + const newUserType = (state['userType'] as 'all' | UserTypeFilter) ?? 'all' + const newProviders = Array.isArray(state['providers']) + ? (state['providers'] as string[]) + : ([] as string[]) + if (newSearch !== search) { setSearch(newSearch) setPage(1) } + if (newUserType !== filterUserType) { + setFilterUserType(newUserType) + setPage(1) + } + if (newProviders.join(',') !== selectedProviders.join(',')) { + setSelectedProviders(newProviders) + setPage(1) + } } return ( @@ -154,6 +204,18 @@ export function V2UsersList() { error={isError ? (error as Error) : null} sort={sort} onSortChange={handleSortChange} + renderSortControl={() => ( + + )} filters={[ { id: 'search', @@ -161,9 +223,30 @@ export function V2UsersList() { type: 'search', placeholder: 'Search by email or phone…', }, + { + id: 'userType', + label: 'User type', + type: 'select', + options: [ + { value: 'all', label: 'All users' }, + { value: 'verified', label: 'Verified users' }, + { value: 'unverified', label: 'Unverified users' }, + { value: 'anonymous', label: 'Anonymous users' }, + ], + }, + { + id: 'providers', + label: 'Provider', + type: 'multi-select', + options: PROVIDER_FILTER_OPTIONS.map((provider) => ({ + value: provider.value, + label: provider.name, + })), + }, ]} - filterState={{ search }} + filterState={{ search, userType: filterUserType, providers: selectedProviders }} onFilterChange={handleFilterChange} + toolbarRight={} pagination={{ page, pageSize: PAGE_SIZE, diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index dc2d55a6d0f..aaa02f88543 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -1,432 +1,10 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IS_PLATFORM, useParams } from 'common' -import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations' -import ReportWidget from 'components/interfaces/Reports/ReportWidget' +import { EdgeFunctionOverviewPageContent } from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionOverviewPageContent' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' -import AreaChart from 'components/ui/Charts/AreaChart' -import StackedBarChart from 'components/ui/Charts/StackedBarChart' -import NoPermission from 'components/ui/NoPermission' -import { - FunctionsCombinedStatsVariables, - useFunctionsCombinedStatsQuery, -} from 'data/analytics/functions-combined-stats-query' -import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' -import dayjs, { Dayjs } from 'dayjs' -import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' -import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import maxBy from 'lodash/maxBy' -import meanBy from 'lodash/meanBy' -import sumBy from 'lodash/sumBy' -import { useRouter } from 'next/router' -import { useMemo, useState } from 'react' -import type { ChartIntervals, NextPageWithLayout } from 'types' -import { - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Button, - WarningIcon, -} from 'ui' -import { PageContainer } from 'ui-patterns/PageContainer' -import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' - -const CHART_INTERVALS: ChartIntervals[] = [ - { - key: '15min', - label: '15 min', - startValue: 15, - startUnit: 'minute', - format: 'MMM D, h:mm:ssa', - }, - { - key: '1hr', - label: '1 hour', - startValue: 1, - startUnit: 'hour', - format: 'MMM D, h:mma', - }, - { - key: '3hr', - label: '3 hours', - startValue: 3, - startUnit: 'hour', - format: 'MMM D, h:mma', - }, - { - key: '1day', - label: '1 day', - startValue: 1, - startUnit: 'hour', - format: 'MMM D, h:mma', - }, -] +import type { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => { - const router = useRouter() - const { ref: projectRef, functionSlug } = useParams() - - const [interval, setInterval] = useState('15min') - const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] - const { data: selectedFunction } = useEdgeFunctionQuery({ - projectRef, - slug: functionSlug, - }) - const id = selectedFunction?.id - const combinedStatsResults = useFunctionsCombinedStatsQuery({ - projectRef, - functionId: id, - interval: selectedInterval.key as FunctionsCombinedStatsVariables['interval'], - }) - - const combinedStatsData = useMemo(() => { - const result = combinedStatsResults.data?.result as - | Record[] - | undefined - return result || [] - }, [combinedStatsResults.data]) - - const [startDate, endDate]: [Dayjs, Dayjs] = useMemo(() => { - const start = dayjs() - .subtract(selectedInterval.startValue, selectedInterval.startUnit as dayjs.ManipulateType) - .startOf(selectedInterval.startUnit as dayjs.ManipulateType) - - const end = dayjs().startOf(selectedInterval.startUnit as dayjs.ManipulateType) - return [start, end] - }, [selectedInterval]) - - const { - data: combinedStatsChartData, - error: combinedStatsError, - isError: isErrorCombinedStats, - } = useFillTimeseriesSorted({ - data: combinedStatsData, - timestampKey: 'timestamp', - valueKey: [ - 'requests_count', - 'log_count', - 'log_info_count', - 'log_warn_count', - 'log_error_count', - 'success_count', - 'redirect_count', - 'client_err_count', - 'server_err_count', - 'avg_cpu_time_used', - 'avg_memory_used', - 'avg_execution_time', - 'max_execution_time', - 'avg_heap_memory_used', - 'avg_external_memory_used', - 'max_cpu_time_used', - ], - defaultValue: 0, - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), - }) - - const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckPermissions( - PermissionAction.FUNCTIONS_READ, - functionSlug as string - ) - if (!canReadFunction && !permissionsLoading) { - return - } - - return ( - - - - {IS_PLATFORM && id && ( -
- -
- )} -
-
- {CHART_INTERVALS.map((item, i) => { - const classes = [] - - if (i === 0) { - classes.push('rounded-tr-none rounded-br-none') - } else if (i === CHART_INTERVALS.length - 1) { - classes.push('rounded-tl-none rounded-bl-none') - } else { - classes.push('rounded-none') - } - - return ( - - ) - })} -
- - - Statistics for past {selectedInterval.label} - -
-
-
- { - return isErrorCombinedStats ? ( - - - Failed to reterieve execution time - - {combinedStatsError?.message ?? 'Unknown error'} - - - ) : ( -
- - -
- ) - }} - /> - { - if (isErrorCombinedStats) { - return ( - - - Failed to reterieve invocations - - {combinedStatsError?.message ?? 'Unknown error'} - - - ) - } else { - const requestData = props.data - .map((d: any) => [ - { - status: '2xx', - count: d.success_count, - timestamp: d.timestamp, - }, - { - status: '3xx', - count: d.redirect_count, - timestamp: d.timestamp, - }, - { - status: '4xx', - count: d.client_err_count, - timestamp: d.timestamp, - }, - { - status: '5xx', - count: d.server_err_count, - timestamp: d.timestamp, - }, - ]) - .flat() - - const logsData = props.data - .map((d: any) => [ - { - status: 'error', - count: d.log_error_count, - timestamp: d.timestamp, - }, - { - status: 'info', - count: d.log_info_count, - timestamp: d.timestamp, - }, - { - status: 'warn', - count: d.log_warn_count, - timestamp: d.timestamp, - }, - ]) - .flat() - - return ( -
- { - router.push( - `/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}` - ) - }} - /> - { - router.push( - `/project/${projectRef}/functions/${functionSlug}/logs?its=${startDate.toISOString()}` - ) - }} - /> -
- ) - } - }} - /> - { - return isErrorCombinedStats ? ( - - - Failed to retrieve CPU time - - {combinedStatsError?.message ?? 'Unknown error'} - - - ) : ( -
- - -
- ) - }} - /> - { - if (isErrorCombinedStats) { - return ( - - - Failed to retrieve memory usage - - {combinedStatsError?.message ?? 'Unknown error'} - - - ) - } - - const memoryData = props.data - .map((d: any) => [ - { - type: 'heap', - count: d.avg_heap_memory_used, - timestamp: d.timestamp, - }, - { - type: 'external', - count: d.avg_external_memory_used, - timestamp: d.timestamp, - }, - ]) - .flat() - - return ( -
- - -
- ) - }} - /> -
-
-
-
-
- ) + return } PageLayout.getLayout = (page) => (