mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 23:59:08 +03:00
Merge branch 'master' of github.com:supabase/supabase
This commit is contained in:
commit
56a1babebf
42 files changed
+1385
-966
No files matched your search
@@ -0,0 +1,63 @@
|
||||
name: Config (Preview)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "supabase/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
wait:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
status: ${{ steps.check.outputs.conclusion }}
|
||||
steps:
|
||||
- uses: fountainhead/action-wait-for-check@v1.2.0
|
||||
id: check
|
||||
with:
|
||||
checkName: Supabase
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
apply:
|
||||
needs:
|
||||
- wait
|
||||
if: ${{ needs.wait.outputs.status == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
||||
TF_VAR_linked_project: xguihxuzqibwxjnimxev
|
||||
TF_VAR_git_branch: ${{ github.head_ref }}
|
||||
TF_CLI_ARGS_apply: -target=supabase_settings.preview
|
||||
defaults:
|
||||
run:
|
||||
working-directory: supabase/remotes
|
||||
outputs:
|
||||
db_user: ${{ steps.branch.outputs.user }}
|
||||
db_pass: ${{ steps.branch.outputs.password }}
|
||||
db_host: ${{ steps.branch.outputs.host }}
|
||||
db_port: ${{ steps.branch.outputs.port }}
|
||||
jwt_secret: ${{ steps.branch.outputs.jwt_secret }}
|
||||
ref: ${{ steps.branch.outputs.id }}
|
||||
status: ${{ steps.branch.outputs.status }}
|
||||
version: ${{ steps.branch.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_wrapper: false
|
||||
|
||||
- run: terraform init
|
||||
- run: terraform apply -auto-approve -no-color
|
||||
- id: branch
|
||||
run: |
|
||||
terraform output -json branch_database \
|
||||
| jq -r "to_entries|map(\"\(.key)=\(.value|tostring)\")|.[]" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Config (Production)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "supabase/remotes/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
||||
TF_VAR_linked_project: xguihxuzqibwxjnimxev
|
||||
defaults:
|
||||
run:
|
||||
working-directory: supabase/remotes
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_wrapper: false
|
||||
|
||||
- run: terraform init
|
||||
- run: terraform apply -auto-approve -no-color
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Edit, MoreVertical, Search, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
@@ -5,10 +6,6 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconEdit,
|
||||
IconMoreVertical,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
Input,
|
||||
} from 'ui'
|
||||
|
||||
@@ -45,12 +42,13 @@ const EnumeratedTypes = () => {
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const enumeratedTypes =
|
||||
const enumeratedTypes = (data ?? []).filter((type) => type.enums.length > 0)
|
||||
const filteredEnumeratedTypes =
|
||||
search.length > 0
|
||||
? (data ?? []).filter(
|
||||
? enumeratedTypes.filter(
|
||||
(x) => x.schema === selectedSchema && x.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: (data ?? []).filter((x) => x.schema === selectedSchema)
|
||||
: enumeratedTypes.filter((x) => x.schema === selectedSchema)
|
||||
|
||||
const protectedSchemas = (schemas ?? []).filter((schema) =>
|
||||
EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
|
||||
@@ -75,7 +73,7 @@ const EnumeratedTypes = () => {
|
||||
className="w-64"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search for a type"
|
||||
icon={<IconSearch size={14} />}
|
||||
icon={<Search size={14} />}
|
||||
/>
|
||||
</div>
|
||||
{!isLocked && (
|
||||
@@ -103,7 +101,7 @@ const EnumeratedTypes = () => {
|
||||
]}
|
||||
body={
|
||||
<>
|
||||
{enumeratedTypes.length === 0 && search.length === 0 && (
|
||||
{filteredEnumeratedTypes.length === 0 && search.length === 0 && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<p className="text-sm text-foreground">No enumerated types created yet</p>
|
||||
@@ -113,7 +111,7 @@ const EnumeratedTypes = () => {
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
{enumeratedTypes.length === 0 && search.length > 0 && (
|
||||
{filteredEnumeratedTypes.length === 0 && search.length > 0 && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
@@ -123,8 +121,8 @@ const EnumeratedTypes = () => {
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
{enumeratedTypes.length > 0 &&
|
||||
enumeratedTypes.map((type) => (
|
||||
{filteredEnumeratedTypes.length > 0 &&
|
||||
filteredEnumeratedTypes.map((type) => (
|
||||
<Table.tr key={type.id}>
|
||||
<Table.td className="w-20">
|
||||
<p className="w-20 truncate">{type.schema}</p>
|
||||
@@ -136,23 +134,21 @@ const EnumeratedTypes = () => {
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1">
|
||||
<IconMoreVertical />
|
||||
</Button>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-32">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => setSelectedTypeToEdit(type)}
|
||||
>
|
||||
<IconEdit size="tiny" />
|
||||
<Edit size={14} />
|
||||
<p>Update type</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => setSelectedTypeToDelete(type)}
|
||||
>
|
||||
<IconTrash stroke="red" size="tiny" />
|
||||
<Trash size={14} />
|
||||
<p>Delete type</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -2,7 +2,6 @@ import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { SelectValue } from '@ui/components/shadcn/ui/select'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { CodeEditor } from 'components/ui/CodeEditor'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
@@ -11,9 +10,19 @@ import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useTableColumnsQuery } from 'data/database/table-columns-query'
|
||||
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
|
||||
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Command_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
ScrollArea,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectSeparator_Shadcn_,
|
||||
@@ -21,8 +30,10 @@ import {
|
||||
SelectValue_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
SidePanel,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import MultiSelect, { MultiSelectOption } from 'ui-patterns/MultiSelect'
|
||||
import { MultiSelectOption } from 'ui-patterns/MultiSelect'
|
||||
import { MultiSelectV2 } from 'ui-patterns/MultiSelect/MultiSelectV2'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { INDEX_TYPES } from './Indexes.constants'
|
||||
|
||||
@@ -37,6 +48,8 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
|
||||
const [selectedEntity, setSelectedEntity] = useState<string | undefined>(undefined)
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
|
||||
const [selectedIndexType, setSelectedIndexType] = useState<string>(INDEX_TYPES[0].value)
|
||||
const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false)
|
||||
const [tableDropdownOpen, setTableDropdownOpen] = useState(false)
|
||||
|
||||
const { refetch: refetchIndexes } = useIndexesQuery({
|
||||
schema: selectedSchema,
|
||||
@@ -47,7 +60,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const { data: entities } = useEntityTypesQuery({
|
||||
const { data: entities, isLoading } = useEntityTypesQuery({
|
||||
schema: selectedSchema,
|
||||
sort: 'alphabetical',
|
||||
search: undefined,
|
||||
@@ -136,22 +149,62 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
|
||||
<div className="py-6 space-y-6">
|
||||
<SidePanel.Content className="space-y-6">
|
||||
<FormItemLayout label="Select a schema" name="select-schema" isReactForm={false}>
|
||||
<Select_Shadcn_
|
||||
value={selectedSchema}
|
||||
onValueChange={setSelectedSchema}
|
||||
name="select-schema"
|
||||
<Popover_Shadcn_
|
||||
modal={false}
|
||||
open={schemaDropdownOpen}
|
||||
onOpenChange={setSchemaDropdownOpen}
|
||||
>
|
||||
<SelectTrigger_Shadcn_ size="small">
|
||||
<SelectValue_Shadcn_>{selectedSchema}</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{(schemas ?? []).map((schema) => (
|
||||
<SelectItem_Shadcn_ key={schema.name} value={schema.name}>
|
||||
{schema.name}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size={'medium'}
|
||||
className={`w-full [&>span]:w-full text-left`}
|
||||
iconRight={
|
||||
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
|
||||
}
|
||||
>
|
||||
{selectedSchema !== undefined && selectedSchema !== ''
|
||||
? selectedSchema
|
||||
: 'Choose a schema'}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sameWidthAsTrigger
|
||||
>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder="Find schema..." />
|
||||
<CommandList_Shadcn_>
|
||||
<CommandEmpty_Shadcn_>No schemas found</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<ScrollArea className={(schemas || []).length > 7 ? 'h-[210px]' : ''}>
|
||||
{(schemas ?? []).map((schema) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={schema.name}
|
||||
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
|
||||
onSelect={() => {
|
||||
setSelectedSchema(schema.name)
|
||||
setSchemaDropdownOpen(false)
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelectedSchema(schema.name)
|
||||
setSchemaDropdownOpen(false)
|
||||
}}
|
||||
>
|
||||
<span>{schema.name}</span>
|
||||
{selectedEntity === schema.name && (
|
||||
<Check className="text-brand" strokeWidth={2} size={16} />
|
||||
)}
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
</FormItemLayout>
|
||||
|
||||
<FormItemLayout
|
||||
@@ -159,42 +212,85 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
|
||||
name="select-table"
|
||||
description={
|
||||
isSelectEntityDisabled &&
|
||||
!isLoading &&
|
||||
'Create a table in this schema via the Table or SQL editor first'
|
||||
}
|
||||
isReactForm={false}
|
||||
>
|
||||
<Select_Shadcn_
|
||||
disabled={isSelectEntityDisabled}
|
||||
value={selectedEntity}
|
||||
onValueChange={setSelectedEntity}
|
||||
name="select-table"
|
||||
<Popover_Shadcn_
|
||||
modal={false}
|
||||
open={tableDropdownOpen}
|
||||
onOpenChange={setTableDropdownOpen}
|
||||
>
|
||||
<SelectTrigger_Shadcn_ size="small">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isSelectEntityDisabled ? 'No tables available in schema' : 'Choose a table'
|
||||
}
|
||||
>
|
||||
{selectedEntity}
|
||||
</SelectValue>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{(entityTypes ?? []).map((entity) => (
|
||||
<SelectItem_Shadcn_ key={entity.name} value={entity.name}>
|
||||
{entity.name}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
<PopoverTrigger_Shadcn_ asChild disabled={isSelectEntityDisabled || isLoading}>
|
||||
{isLoading ? (
|
||||
<ShimmeringLoader className="h-[38px]" />
|
||||
) : (
|
||||
<Button
|
||||
type="default"
|
||||
size="medium"
|
||||
className={cn(
|
||||
'w-full [&>span]:w-full text-left',
|
||||
selectedEntity === '' && 'text-foreground-lighter'
|
||||
)}
|
||||
iconRight={
|
||||
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
|
||||
}
|
||||
>
|
||||
{selectedEntity !== undefined && selectedEntity !== ''
|
||||
? selectedEntity
|
||||
: isSelectEntityDisabled
|
||||
? 'No tables available in schema'
|
||||
: 'Choose a table'}
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sameWidthAsTrigger
|
||||
>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder="Find table..." />
|
||||
<CommandList_Shadcn_>
|
||||
<CommandEmpty_Shadcn_>No tables found</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<ScrollArea className={(entityTypes || []).length > 7 ? 'h-[210px]' : ''}>
|
||||
{(entityTypes ?? []).map((entity) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={entity.name}
|
||||
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
|
||||
onSelect={() => {
|
||||
setSelectedEntity(entity.name)
|
||||
setTableDropdownOpen(false)
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelectedEntity(entity.name)
|
||||
setTableDropdownOpen(false)
|
||||
}}
|
||||
>
|
||||
<span>{entity.name}</span>
|
||||
{selectedEntity === entity.name && (
|
||||
<Check className="text-brand" strokeWidth={2} size={16} />
|
||||
)}
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
</FormItemLayout>
|
||||
|
||||
{selectedEntity && (
|
||||
<FormItemLayout label="Select up to 32 columns" isReactForm={false}>
|
||||
{isLoadingTableColumns && <ShimmeringLoader className="py-4" />}
|
||||
{isSuccessTableColumns && (
|
||||
<MultiSelect
|
||||
<MultiSelectV2
|
||||
options={columnOptions}
|
||||
placeholder=""
|
||||
placeholder="Choose which columns to create an index on"
|
||||
searchPlaceholder="Search for a column"
|
||||
value={selectedColumns}
|
||||
onChange={setSelectedColumns}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { InformationCircleIcon } from '@heroicons/react/16/solid'
|
||||
import { X } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
@@ -10,6 +10,7 @@ import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { useLocalStorageQuery } from 'hooks'
|
||||
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import {
|
||||
Button,
|
||||
TabsList_Shadcn_,
|
||||
@@ -28,7 +29,6 @@ import { PresetHookResult } from '../Reports/Reports.utils'
|
||||
import { QUERY_PERFORMANCE_REPORT_TYPES } from './QueryPerformance.constants'
|
||||
import { QueryPerformanceFilterBar } from './QueryPerformanceFilterBar'
|
||||
import { QueryPerformanceGrid } from './QueryPerformanceGrid'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
|
||||
interface QueryPerformanceProps {
|
||||
queryHitRate: PresetHookResult
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
import { X } from 'lucide-react'
|
||||
import RGL, { WidthProvider } from 'react-grid-layout'
|
||||
import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import { Button, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
|
||||
import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
|
||||
|
||||
const ReactGridLayout = WidthProvider(RGL)
|
||||
|
||||
const GridResize = ({ startDate, endDate, interval, editableReport, setEditableReport }: any) => {
|
||||
if (!editableReport) return null
|
||||
interface GridResizeProps {
|
||||
startDate: string
|
||||
endDate: string
|
||||
interval: string
|
||||
editableReport: any
|
||||
onRemoveChart: ({ metric }: { metric: { key: string } }) => void
|
||||
setEditableReport: (payload: any) => void
|
||||
}
|
||||
|
||||
const GridResize = ({
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
editableReport,
|
||||
onRemoveChart,
|
||||
setEditableReport,
|
||||
}: GridResizeProps) => {
|
||||
const { ref } = useParams()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
|
||||
function onLayoutChange(layout: any) {
|
||||
let updatedLayout = editableReport.layout
|
||||
@@ -23,6 +45,8 @@ const GridResize = ({ startDate, endDate, interval, editableReport, setEditableR
|
||||
setEditableReport(payload)
|
||||
}
|
||||
|
||||
if (!editableReport) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactGridLayout
|
||||
@@ -47,13 +71,26 @@ const GridResize = ({ startDate, endDate, interval, editableReport, setEditableR
|
||||
interval={interval}
|
||||
attribute={x.attribute}
|
||||
provider={x.provider}
|
||||
label={x.label}
|
||||
label={`${x.label}${ref !== state.selectedDatabaseId ? (x.provider === 'infra-monitoring' ? ' of replica' : ' on project') : ''}`}
|
||||
customDateFormat={'MMM D, YYYY'}
|
||||
/>
|
||||
>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X />}
|
||||
className="ml-2 px-1"
|
||||
onClick={() => onRemoveChart({ metric: { key: x.attribute } })}
|
||||
/>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom">Remove chart</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</ChartHandler>
|
||||
|
||||
<div className="absolute inset-x-0 top-3 ">
|
||||
<div className="flex justify-around">
|
||||
<div className="flex h-3 w-24 cursor-move flex-col space-y-2">
|
||||
<div className="hidden h-3 w-full border-4 border-dotted border-green-900 opacity-50 transition-all hover:opacity-100 group-hover:block"></div>
|
||||
<div className="hidden h-3 w-full border-4 border-dotted border-green-900 opacity-50 transition-all hover:opacity-100 group-hover:block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Home } from 'lucide-react'
|
||||
|
||||
import { useIsFeatureEnabled } from 'hooks'
|
||||
import { METRICS, METRIC_CATEGORIES } from 'lib/constants/metrics'
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from 'ui'
|
||||
|
||||
interface MetricOptionsProps {
|
||||
config: any
|
||||
handleChartSelection: any
|
||||
}
|
||||
|
||||
export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsProps) => {
|
||||
const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled } = useIsFeatureEnabled([
|
||||
'project_auth:all',
|
||||
'project_storage:all',
|
||||
])
|
||||
|
||||
const metricCategories = Object.values(METRIC_CATEGORIES).filter(({ key }) => {
|
||||
if (key === 'api_auth') return authEnabled
|
||||
if (key === 'api_storage') return storageEnabled
|
||||
return true
|
||||
})
|
||||
|
||||
return metricCategories.map((cat) => {
|
||||
return (
|
||||
<DropdownMenuSub key={cat.key}>
|
||||
<DropdownMenuSubTrigger className="space-x-2">
|
||||
{cat.icon ? cat.icon : <Home size={14} />}
|
||||
<p>{cat.label}</p>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{METRICS.filter((metric) => metric?.category?.key === cat.key).map((metric) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={metric.key}
|
||||
checked={config.layout?.some((x: any) => x.attribute === metric.key)}
|
||||
onCheckedChange={(e) => handleChartSelection({ metric, value: e })}
|
||||
>
|
||||
<div className="flex flex-col space-y-0">
|
||||
<span>{metric.label}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,25 +1,23 @@
|
||||
import { ChevronDown, Database, Plus, X } from 'lucide-react'
|
||||
import { ComponentProps, useState } from 'react'
|
||||
import SVG from 'react-inlinesvg'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import DatabaseSelector from 'components/ui/DatabaseSelector'
|
||||
import { Auth, Realtime, Storage } from 'icons'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
IconBox,
|
||||
IconChevronDown,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconKey,
|
||||
IconPlus,
|
||||
IconX,
|
||||
IconZap,
|
||||
Input,
|
||||
Popover,
|
||||
Select,
|
||||
cn,
|
||||
} from 'ui'
|
||||
|
||||
import DatePickers from '../Settings/Logs/Logs.DatePickers'
|
||||
import { REPORTS_DATEPICKER_HELPERS } from './Reports.constants'
|
||||
import type { ReportFilterItem } from './Reports.types'
|
||||
@@ -41,7 +39,7 @@ const PRODUCT_FILTERS = [
|
||||
filterValue: '/rest',
|
||||
label: 'REST',
|
||||
description: 'Requests made to PostgREST',
|
||||
icon: IconDatabase,
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
key: 'auth',
|
||||
@@ -49,7 +47,7 @@ const PRODUCT_FILTERS = [
|
||||
filterValue: '/auth',
|
||||
label: 'Auth',
|
||||
description: 'Auth and authorization requests',
|
||||
icon: IconKey,
|
||||
icon: Auth,
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
@@ -57,7 +55,7 @@ const PRODUCT_FILTERS = [
|
||||
filterValue: '/storage',
|
||||
label: 'Storage',
|
||||
description: 'Storage asset requests',
|
||||
icon: IconBox,
|
||||
icon: Storage,
|
||||
},
|
||||
{
|
||||
key: 'realtime',
|
||||
@@ -65,7 +63,7 @@ const PRODUCT_FILTERS = [
|
||||
filterValue: '/realtime',
|
||||
label: 'Realtime',
|
||||
description: 'Realtime connection requests',
|
||||
icon: IconZap,
|
||||
icon: Realtime,
|
||||
},
|
||||
{
|
||||
key: 'graphql',
|
||||
@@ -73,7 +71,7 @@ const PRODUCT_FILTERS = [
|
||||
filterValue: '/graphql',
|
||||
label: 'GraphQL',
|
||||
description: 'Requests made to pg_graphql',
|
||||
icon: IconCode,
|
||||
icon: null,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -86,6 +84,10 @@ const ReportFilterBar = ({
|
||||
onRemoveFilters,
|
||||
datepickerHelpers,
|
||||
}: ReportFilterBarProps) => {
|
||||
const { project } = useProjectContext()
|
||||
// [Joshen] TODO: Once API support is out
|
||||
const showReadReplicasUI = false // project?.is_read_replicas_enabled
|
||||
|
||||
const filterKeys = [
|
||||
'request.path',
|
||||
'request.method',
|
||||
@@ -135,7 +137,7 @@ const ReportFilterBar = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-row justify-start items-center flex-wrap gap-2">
|
||||
<DatePickers
|
||||
onChange={onDatepickerChange}
|
||||
@@ -148,7 +150,7 @@ const ReportFilterBar = ({
|
||||
<Button
|
||||
type="default"
|
||||
className="inline-flex flex-row gap-2"
|
||||
iconRight={<IconChevronDown size={14} />}
|
||||
iconRight={<ChevronDown size={14} />}
|
||||
>
|
||||
<span>
|
||||
{currentProductFilter === null ? 'All Requests' : currentProductFilter.label}
|
||||
@@ -162,6 +164,7 @@ const ReportFilterBar = ({
|
||||
<DropdownMenuSeparator />
|
||||
{PRODUCT_FILTERS.map((productFilter) => {
|
||||
const Icon = productFilter.icon
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={productFilter.key}
|
||||
@@ -169,7 +172,17 @@ const ReportFilterBar = ({
|
||||
disabled={productFilter.key === currentProductFilter?.key}
|
||||
onClick={() => handleProductFilterChange(productFilter)}
|
||||
>
|
||||
<Icon size={20} className="mr-2" />
|
||||
{productFilter.key === 'graphql' ? (
|
||||
<SVG
|
||||
src={`${BASE_PATH}/img/graphql.svg`}
|
||||
className="w-[20px] h-[20px] mr-2"
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
}
|
||||
/>
|
||||
) : Icon !== null ? (
|
||||
<Icon size={20} strokeWidth={1.5} className="mr-2" />
|
||||
) : null}
|
||||
<div className="flex flex-col">
|
||||
<p
|
||||
className={cn(
|
||||
@@ -205,7 +218,7 @@ const ReportFilterBar = ({
|
||||
size="tiny"
|
||||
className="!p-0 !space-x-0"
|
||||
onClick={() => onRemoveFilters([filter])}
|
||||
icon={<IconX size="tiny" className="text-foreground-light" />}
|
||||
icon={<X size={14} className="text-foreground-light" />}
|
||||
>
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
@@ -285,12 +298,13 @@ const ReportFilterBar = ({
|
||||
asChild
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<IconPlus size="tiny" className={`text-foreground-light `} />}
|
||||
icon={<Plus size={14} className={`text-foreground-light `} />}
|
||||
>
|
||||
<span>Add filter</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
{showReadReplicasUI && <DatabaseSelector />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { ExternalLink, HelpCircle } from 'lucide-react'
|
||||
import { NextRouter, useRouter } from 'next/router'
|
||||
import { Button, IconExternalLink, IconHelpCircle, Loading } from 'ui'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { Button, Loading, cn } from 'ui'
|
||||
import { LogsEndpointParams } from '../Settings/Logs'
|
||||
import type { BaseReportParams, ReportQueryType } from './Reports.types'
|
||||
|
||||
@@ -27,15 +30,16 @@ export interface ReportWidgetRendererProps<T = any> extends ReportWidgetProps<T>
|
||||
projectRef: string
|
||||
}
|
||||
|
||||
const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
|
||||
const ReportWidget = (props: ReportWidgetProps) => {
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
const { ref } = useParams()
|
||||
const projectRef = ref as string
|
||||
|
||||
return (
|
||||
<Panel
|
||||
noMargin
|
||||
noHideOverflow
|
||||
className={'pb-0 ' + props.className}
|
||||
className={cn('pb-0', props.className)}
|
||||
bodyClassName="h-full"
|
||||
wrapWithLoading={false}
|
||||
>
|
||||
@@ -47,11 +51,7 @@ const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
|
||||
{props?.tooltip && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<IconHelpCircle
|
||||
className="text-foreground-light"
|
||||
size="tiny"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<HelpCircle className="text-foreground-light" size={14} />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
@@ -76,7 +76,7 @@ const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconExternalLink strokeWidth={1.5} />}
|
||||
icon={<ExternalLink />}
|
||||
className="px-1"
|
||||
onClick={() => {
|
||||
const isDbQueryType = props.queryType === 'db'
|
||||
@@ -95,10 +95,7 @@ const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
|
||||
query.ite = props.params!.iso_timestamp_end
|
||||
}
|
||||
|
||||
router.push({
|
||||
pathname,
|
||||
query,
|
||||
})
|
||||
router.push({ pathname, query })
|
||||
}}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
@@ -46,7 +46,6 @@ export const CreateReportModal = ({ visible, onCancel, afterSubmit }: CreateRepo
|
||||
},
|
||||
})
|
||||
toast.success('New report created')
|
||||
|
||||
const newReportId = res[0].id
|
||||
router.push(`/project/${ref}/reports/${newReportId}`)
|
||||
afterSubmit()
|
||||
|
||||
@@ -1,39 +1,34 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import { groupBy, isNull } from 'lodash'
|
||||
import { toJS } from 'mobx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuTrigger,
|
||||
IconArrowRight,
|
||||
IconHome,
|
||||
IconPlus,
|
||||
IconSave,
|
||||
IconSettings,
|
||||
} from 'ui'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { useParams } from 'common/hooks'
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
|
||||
import DatabaseSelector from 'components/ui/DatabaseSelector'
|
||||
import { Loading } from 'components/ui/Loading'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useCheckPermissions, useIsFeatureEnabled } from 'hooks'
|
||||
import { METRICS, METRIC_CATEGORIES, TIME_PERIODS_REPORTS } from 'lib/constants/metrics'
|
||||
import { useContentQuery } from 'data/content/content-query'
|
||||
import { useContentUpdateMutation } from 'data/content/content-update-mutation'
|
||||
import { useCheckPermissions } from 'hooks'
|
||||
import { TIME_PERIODS_REPORTS } from 'lib/constants/metrics'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { useProjectContentStore } from 'stores/projectContentStore'
|
||||
import { ArrowRight, Plus, Save, Settings } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
Tooltip_Shadcn_,
|
||||
} from 'ui'
|
||||
import GridResize from './GridResize'
|
||||
import { MetricOptions } from './MetricOptions'
|
||||
import { LAYOUT_COLUMN_COUNT } from './Reports.constants'
|
||||
import { useContentQuery } from 'data/content/content-query'
|
||||
|
||||
const DEFAULT_CHART_COLUMN_COUNT = 12
|
||||
const DEFAULT_CHART_ROW_COUNT = 4
|
||||
@@ -41,87 +36,46 @@ const DEFAULT_CHART_ROW_COUNT = 4
|
||||
const Reports = () => {
|
||||
const { id, ref } = useParams()
|
||||
const { profile } = useProfile()
|
||||
const { project } = useProjectContext()
|
||||
const hasReadReplicasEnabled = project?.is_read_replicas_enabled
|
||||
|
||||
const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled } = useIsFeatureEnabled([
|
||||
'project_auth:all',
|
||||
'project_storage:all',
|
||||
])
|
||||
|
||||
const reportQuery = useContentQuery(ref)
|
||||
const currentReport =
|
||||
reportQuery.data && reportQuery.data?.content.find((report) => report.id === id)
|
||||
|
||||
const [report, setReport] = useState<any>()
|
||||
|
||||
const [loading, setLoading] = useState<any>(false)
|
||||
const [config, setConfig] = useState<any>(null)
|
||||
const [originalConfig, setOriginalConfig] = useState<any>(null)
|
||||
const [hasEdits, setHasEdits] = useState<any>(false)
|
||||
const [saving, setSaving] = useState<any>(false)
|
||||
const [config, setConfig] = useState<any>(undefined)
|
||||
const [startDate, setStartDate] = useState<any>(null)
|
||||
const [endDate, setEndDate] = useState<any>(null)
|
||||
const [hasEdits, setHasEdits] = useState<any>(false)
|
||||
|
||||
const { data: userContents, isLoading } = useContentQuery(ref)
|
||||
const { mutateAsync: saveReport, isLoading: isSaving } = useContentUpdateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully saved report!')
|
||||
},
|
||||
})
|
||||
const currentReport = userContents?.content.find((report) => report.id === id)
|
||||
|
||||
const contentStore = useProjectContentStore(ref)
|
||||
const canReadReport = useCheckPermissions(PermissionAction.READ, 'user_content', {
|
||||
resource: {
|
||||
type: 'report',
|
||||
visibility: report?.visibility,
|
||||
owner_id: report?.owner_id,
|
||||
visibility: currentReport?.visibility,
|
||||
owner_id: currentReport?.owner_id,
|
||||
},
|
||||
subject: { id: profile?.id },
|
||||
})
|
||||
const canUpdateReport = useCheckPermissions(PermissionAction.UPDATE, 'user_content', {
|
||||
resource: {
|
||||
type: 'report',
|
||||
visibility: report?.visibility,
|
||||
owner_id: report?.owner_id,
|
||||
visibility: currentReport?.visibility,
|
||||
owner_id: currentReport?.owner_id,
|
||||
},
|
||||
subject: { id: profile?.id },
|
||||
})
|
||||
|
||||
/*
|
||||
* fetchReport()
|
||||
*
|
||||
* Fetches the report and sets the main states of the page
|
||||
* Also sets an 'original' report for comaprison.
|
||||
*
|
||||
* toJS() is used to deepcopy mobx to js
|
||||
*/
|
||||
const fetchReport = async () => {
|
||||
setLoading(true)
|
||||
await contentStore.load()
|
||||
const reportData = contentStore.byId(id)
|
||||
|
||||
if (reportData) {
|
||||
setReport(reportData)
|
||||
|
||||
// [Joshen TODO] Worth refactoring - no need for so many states
|
||||
setConfig(toJS(reportData.content))
|
||||
setOriginalConfig(toJS(reportData.content))
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
return reportData
|
||||
}
|
||||
|
||||
/*
|
||||
* handleDateRangePicker()
|
||||
*
|
||||
* Sets date range of reports, using the DateRangePicker component
|
||||
*/
|
||||
function handleDateRangePicker({ period_start, period_end }: any) {
|
||||
setStartDate(period_start.date)
|
||||
setEndDate(period_end.date)
|
||||
}
|
||||
|
||||
/*
|
||||
* checkEditState()
|
||||
*
|
||||
* Makes a comparison of report changes vs the original report
|
||||
*
|
||||
* returns setHasEdits(bool)
|
||||
*/
|
||||
function checkEditState() {
|
||||
if (config === undefined) return
|
||||
/*
|
||||
* Shallow copying the config state variable maintains a mobx reference
|
||||
* Instead, we stringify it and parse it again to remove anything
|
||||
@@ -131,7 +85,7 @@ const Reports = () => {
|
||||
* want to compare fixed dates as possible differences from saved and edited versions of report.
|
||||
*/
|
||||
let _config = JSON.parse(JSON.stringify(config))
|
||||
let _original = JSON.parse(JSON.stringify(originalConfig))
|
||||
let _original = JSON.parse(JSON.stringify(currentReport?.content))
|
||||
|
||||
if (!_original || !_config) return
|
||||
|
||||
@@ -159,58 +113,23 @@ const Reports = () => {
|
||||
setHasEdits(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Reloads the entire report when id changes
|
||||
useEffect(() => {
|
||||
fetchReport()
|
||||
}, [id])
|
||||
|
||||
// Runs when any changes are made to report
|
||||
useEffect(() => {
|
||||
checkEditState()
|
||||
}, [config])
|
||||
|
||||
if (!config) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
if (!canReadReport) {
|
||||
return <NoPermission isFullPage resourceText="access this project's report" />
|
||||
}
|
||||
|
||||
// Updates the report and reloads the report again
|
||||
const saveReport = async () => {
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
content: config,
|
||||
}
|
||||
await contentStore.update(id, payload, 'report')
|
||||
await fetchReport()
|
||||
const onSaveReport = async () => {
|
||||
if (ref === undefined) return console.error('Project ref is required')
|
||||
if (id === undefined) return console.error('Report ID is required')
|
||||
|
||||
await saveReport({ projectRef: ref, id, type: 'report', content: config })
|
||||
|
||||
setHasEdits(false)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
/*
|
||||
* handleChartSelection()
|
||||
*
|
||||
* Handler for the chart behavior
|
||||
*/
|
||||
function handleChartSelection({ metric, value }: any) {
|
||||
if (value) {
|
||||
pushChart({ metric })
|
||||
} else {
|
||||
popChart({ metric })
|
||||
}
|
||||
if (value) pushChart({ metric })
|
||||
else popChart({ metric })
|
||||
}
|
||||
|
||||
/*
|
||||
* pushChart()
|
||||
*
|
||||
* Adds a new chart to the report
|
||||
*/
|
||||
function pushChart({ metric }: any) {
|
||||
let current = config.layout
|
||||
const index = current.length + 1
|
||||
const current = [...config.layout]
|
||||
|
||||
let x = 0
|
||||
let y = null
|
||||
@@ -257,14 +176,9 @@ const Reports = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* popChart()
|
||||
*
|
||||
* Removes a chart from the report based on its attribute
|
||||
*/
|
||||
function popChart({ metric }: any) {
|
||||
const { key } = metric
|
||||
let current = config.layout
|
||||
const current = [...config.layout]
|
||||
|
||||
const foundIndex = current.findIndex((x: any, i: number) => {
|
||||
if (x.attribute === key) {
|
||||
@@ -278,51 +192,48 @@ const Reports = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const metricCategories = Object.values(METRIC_CATEGORIES).filter(({ key }) => {
|
||||
if (key === 'api_auth') return authEnabled
|
||||
if (key === 'api_storage') return storageEnabled
|
||||
return true
|
||||
})
|
||||
useEffect(() => {
|
||||
if (currentReport !== undefined) setConfig(currentReport?.content)
|
||||
}, [currentReport])
|
||||
|
||||
const MetricOptions = () => {
|
||||
return (
|
||||
<>
|
||||
{metricCategories.map((cat) => {
|
||||
return (
|
||||
<DropdownMenuSub key={cat.key}>
|
||||
<DropdownMenuSubTrigger className="space-x-2">
|
||||
{cat.icon ? cat.icon : <IconHome size="tiny" />}
|
||||
<p>{cat.label}</p>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{METRICS.filter((metric) => metric?.category?.key === cat.key).map((metric) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={metric.key}
|
||||
checked={config.layout?.some((x: any) => x.attribute === metric.key)}
|
||||
onCheckedChange={(e) => handleChartSelection({ metric, value: e })}
|
||||
>
|
||||
<div className="flex flex-col space-y-0">
|
||||
<span>{metric.label}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
useEffect(() => {
|
||||
checkEditState()
|
||||
}, [config])
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
if (!canReadReport) {
|
||||
return <NoPermission isFullPage resourceText="access this project's report" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4" style={{ maxHeight: '100%' }}>
|
||||
<div className="space-y-0.5">
|
||||
<h1 className="text-xl text-foreground">{currentReport?.name || 'Reports'}</h1>
|
||||
<p className="text-foreground-light">{currentReport?.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl text-foreground">{currentReport?.name || 'Reports'}</h1>
|
||||
<p className="text-foreground-light">{currentReport?.description}</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => setConfig(currentReport?.content)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Save />}
|
||||
onClick={() => onSaveReport()}
|
||||
loading={isSaving}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between space-x-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -330,7 +241,7 @@ const Reports = () => {
|
||||
onChange={handleDateRangePicker}
|
||||
value="7d"
|
||||
options={TIME_PERIODS_REPORTS}
|
||||
loading={loading}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
{startDate && endDate && (
|
||||
@@ -339,7 +250,7 @@ const Reports = () => {
|
||||
{dayjs(startDate).format('MMM D, YYYY')}
|
||||
</span>
|
||||
<span className="text-foreground-lighter">
|
||||
<IconArrowRight size={12} />
|
||||
<ArrowRight size={12} />
|
||||
</span>
|
||||
<span className="text-sm text-foreground-light">
|
||||
{dayjs(endDate).format('MMM D, YYYY')}
|
||||
@@ -348,75 +259,48 @@ const Reports = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasEdits && (
|
||||
<div className="hidden xl:inline-block">
|
||||
<Badge variant="brand">There are unsaved changes</Badge>
|
||||
</div>
|
||||
)}
|
||||
{hasEdits && (
|
||||
<Button
|
||||
type={!hasEdits ? 'default' : 'primary'}
|
||||
disabled={!hasEdits}
|
||||
icon={<IconSave />}
|
||||
onClick={() => saveReport()}
|
||||
loading={saving}
|
||||
>
|
||||
{hasEdits && 'Save changes'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
{canUpdateReport ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<IconSettings />}>
|
||||
<Button type="default" iconRight={<Settings size={14} />}>
|
||||
<span>Add / Remove charts</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<MetricOptions />
|
||||
<MetricOptions config={config} handleChartSelection={handleChartSelection} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button disabled type="default" iconRight={<IconSettings />}>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<Button disabled type="default" iconRight={<Settings size={14} />}>
|
||||
Add / Remove charts
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-alternative py-1 px-2 leading-none shadow',
|
||||
'border border-background',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-foreground">
|
||||
You need additional permissions to update this project's report
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom" className="w-56 text-center" align="end">
|
||||
You need additional permissions to update this project's report
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
)}
|
||||
|
||||
{hasReadReplicasEnabled && <DatabaseSelector />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.layout.length <= 0 ? (
|
||||
{config?.layout !== undefined && config.layout.length <= 0 ? (
|
||||
<div className="flex min-h-full items-center justify-center rounded border-2 border-dashed p-16 border-default">
|
||||
{canUpdateReport ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<IconPlus />}>
|
||||
<Button type="default" iconRight={<Plus size={14} />}>
|
||||
<span>
|
||||
{config.layout.length <= 0 ? 'Add your first chart' : 'Add another chart'}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="center">
|
||||
<MetricOptions />
|
||||
<MetricOptions config={config} handleChartSelection={handleChartSelection} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
@@ -431,6 +315,7 @@ const Reports = () => {
|
||||
endDate={endDate}
|
||||
interval={config.interval}
|
||||
editableReport={config}
|
||||
onRemoveChart={popChart}
|
||||
setEditableReport={setConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
jsonSyntaxHighlight,
|
||||
TextFormatter,
|
||||
@@ -5,12 +10,9 @@ import {
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import BarChart from 'components/ui/Charts/BarChart'
|
||||
import useFillTimeseriesSorted from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Button, Collapsible, IconChevronRight } from 'ui'
|
||||
import { Button, Collapsible } from 'ui'
|
||||
import { queryParamsToObject } from '../Reports.utils'
|
||||
import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget'
|
||||
import { useParams } from 'common/hooks'
|
||||
|
||||
export const NetworkTrafficRenderer = (
|
||||
props: ReportWidgetProps<{
|
||||
@@ -63,6 +65,7 @@ export const NetworkTrafficRenderer = (
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TotalRequestsChartRenderer = (
|
||||
props: ReportWidgetProps<{
|
||||
timestamp: string
|
||||
@@ -107,15 +110,17 @@ export const TopApiRoutesRenderer = (
|
||||
}>
|
||||
) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
|
||||
const headerClasses = '!text-xs !py-2 p-0 font-bold !bg-surface-200 !border-x-0 !rounded-none'
|
||||
const cellClasses = '!text-xs !py-2 !border-x-0 !rounded-none align-middle'
|
||||
|
||||
if (props.data.length === 0) return null
|
||||
const headerClasses = '!text-xs !py-2 p-0 font-bold !bg-surface-200'
|
||||
const cellClasses = '!text-xs !py-2'
|
||||
|
||||
return (
|
||||
<Collapsible>
|
||||
<Table
|
||||
className="rounded-t-none"
|
||||
head={
|
||||
<>
|
||||
<Table.th className={headerClasses}>Request</Table.th>
|
||||
@@ -128,10 +133,10 @@ export const TopApiRoutesRenderer = (
|
||||
body={
|
||||
<>
|
||||
{props.data.map((datum, index) => (
|
||||
<Fragment key={datum.path + (datum.search || '')}>
|
||||
<Fragment key={datum.method + datum.path + (datum.search || '')}>
|
||||
<Table.tr
|
||||
className={[
|
||||
'p-0 transition transform duration-700',
|
||||
'p-0 transition transform cursor-pointer hover:bg-surface-200',
|
||||
showMore && index >= 3 ? 'w-full h-full opacity-100' : '',
|
||||
!showMore && index >= 3 ? ' w-0 h-0 translate-y-10 opacity-0' : '',
|
||||
].join(' ')}
|
||||
@@ -270,10 +275,10 @@ interface RouteTdContentProps {
|
||||
const RouteTdContent = (datum: RouteTdContentProps) => (
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger asChild>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button asChild type="text" className=" !py-0 !p-1" title="Show more route details">
|
||||
<span>
|
||||
<IconChevronRight
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="transition data-open-parent:rotate-90 data-closed-parent:rotate-0"
|
||||
/>
|
||||
|
||||
+53
-125
@@ -1,29 +1,23 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import * as z from 'zod'
|
||||
|
||||
import type {
|
||||
EnvironmentTargets,
|
||||
Integration,
|
||||
IntegrationProjectConnection,
|
||||
} from 'data/integrations/integrations.types'
|
||||
import { useVercelConnectionUpdateMutation } from 'data/integrations/vercel-connection-update-mutate'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormDescription_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormItem_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
IconClock,
|
||||
Switch,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { ScaffoldDivider } from 'components/layouts/Scaffold'
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationProjectConnection,
|
||||
} from 'data/integrations/integrations.types'
|
||||
import { useVercelConnectionUpdateMutation } from 'data/integrations/vercel-connection-update-mutate'
|
||||
import { useFlag } from 'hooks'
|
||||
|
||||
const VercelIntegrationConnectionForm = ({
|
||||
connection,
|
||||
@@ -32,190 +26,124 @@ const VercelIntegrationConnectionForm = ({
|
||||
connection: IntegrationProjectConnection
|
||||
integration: Integration
|
||||
}) => {
|
||||
const config = connection.metadata.supabaseConfig
|
||||
const enableVercelConnectionsConfig = useFlag('enableVercelConnectionsConfig')
|
||||
const envSyncTargets = connection.env_sync_targets ?? []
|
||||
|
||||
const FormSchema = z.object({
|
||||
environmentVariablesProduction: z
|
||||
.boolean()
|
||||
.default(config?.environmentVariables?.production ?? true),
|
||||
authRedirectUrisProduction: z.boolean().default(config?.authRedirectUris?.production ?? true),
|
||||
environmentVariablesPreview: z.boolean().default(config?.environmentVariables?.preview ?? true),
|
||||
authRedirectUrisPreview: z.boolean().default(config?.authRedirectUris?.preview ?? true),
|
||||
environmentVariablesProduction: z.boolean().default(envSyncTargets.includes('production')),
|
||||
environmentVariablesPreview: z.boolean().default(envSyncTargets.includes('preview')),
|
||||
environmentVariablesDevelopment: z.boolean().default(envSyncTargets.includes('development')),
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
environmentVariablesProduction: config?.environmentVariables?.production ?? true,
|
||||
environmentVariablesPreview: config?.environmentVariables?.preview ?? true,
|
||||
authRedirectUrisProduction: config?.authRedirectUris?.production ?? true,
|
||||
authRedirectUrisPreview: config?.authRedirectUris?.preview ?? true,
|
||||
environmentVariablesProduction: envSyncTargets.includes('production'),
|
||||
environmentVariablesPreview: envSyncTargets.includes('preview'),
|
||||
environmentVariablesDevelopment: envSyncTargets.includes('development'),
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: updateVercelConnection } = useVercelConnectionUpdateMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Updated Supabase directory`)
|
||||
},
|
||||
onSuccess: () => toast.success(`Updated Supabase directory`),
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
/**
|
||||
* remove this hardcoded if statement when we are ready to enable this feature
|
||||
*/
|
||||
if (!enableVercelConnectionsConfig) return
|
||||
const {
|
||||
environmentVariablesProduction,
|
||||
environmentVariablesPreview,
|
||||
environmentVariablesDevelopment,
|
||||
} = data
|
||||
|
||||
const metadata = {
|
||||
...connection.metadata,
|
||||
}
|
||||
const envSyncTargets: string[] = []
|
||||
|
||||
metadata.supabaseConfig = {
|
||||
environmentVariables: {
|
||||
production: data.environmentVariablesProduction,
|
||||
preview: data.environmentVariablesPreview,
|
||||
},
|
||||
authRedirectUris: {
|
||||
production: data.authRedirectUrisProduction,
|
||||
preview: data.authRedirectUrisPreview,
|
||||
},
|
||||
}
|
||||
if (environmentVariablesProduction) envSyncTargets.push('production')
|
||||
if (environmentVariablesPreview) envSyncTargets.push('preview')
|
||||
if (environmentVariablesDevelopment) envSyncTargets.push('development')
|
||||
|
||||
updateVercelConnection({
|
||||
id: connection.id,
|
||||
metadata,
|
||||
envSyncTargets: envSyncTargets as EnvironmentTargets[],
|
||||
organizationIntegrationId: integration.id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<div className="py-4 px-8">
|
||||
<Alert_Shadcn_ variant="default">
|
||||
<IconClock className="h-4 w-4" strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>Vercel Connection configuration coming soon</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
This configuration will allow you to control the environment variables and auth
|
||||
redirects for production and preview deployments.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</div>
|
||||
<ScaffoldDivider />
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn(!enableVercelConnectionsConfig && 'opacity-30', 'w-full space-y-6')}
|
||||
>
|
||||
<div>
|
||||
{/* {isUpdatingVercelConnection && 'isUpdatingVercelConnection'} */}
|
||||
<div className="flex flex-col gap-6 px-8 py-8">
|
||||
<h5 className="text-foreground text-sm">Vercel Production deployments </h5>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className={'w-full space-y-6'}>
|
||||
<div className="px-6 py-4 flex flex-col gap-y-4">
|
||||
<h5 className="text-foreground text-sm">
|
||||
Sync environment variables for selected target environments
|
||||
</h5>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="environmentVariablesProduction"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_ className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<FormLabel_Shadcn_ className="!text">
|
||||
Sync environment variables for Vercel Production deployments
|
||||
</FormLabel_Shadcn_>
|
||||
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter">
|
||||
Deploy Edge Functions when merged into Production Branch
|
||||
</FormDescription_Shadcn_>
|
||||
</div>
|
||||
<FormItem_Shadcn_ className="space-y-0 flex gap-x-4">
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
className="mt-1"
|
||||
checked={field.value}
|
||||
disabled={!enableVercelConnectionsConfig}
|
||||
onCheckedChange={(e) => {
|
||||
field.onChange(e)
|
||||
form.handleSubmit(onSubmit)()
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
{/* <Button htmlType="submit">Submit</Button> */}
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="authRedirectUrisProduction"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_ className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<FormLabel_Shadcn_ className="!text">
|
||||
Auto update Auth Redirect URIs for Vercel Production Deployments
|
||||
</FormLabel_Shadcn_>
|
||||
<FormLabel_Shadcn_ className="!text">Production</FormLabel_Shadcn_>
|
||||
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter">
|
||||
Deploy Edge Functions when merged into Production Branch
|
||||
Sync environment variables for <code>production</code> environment.
|
||||
</FormDescription_Shadcn_>
|
||||
</div>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
disabled={!enableVercelConnectionsConfig}
|
||||
onCheckedChange={(e) => {
|
||||
field.onChange(e)
|
||||
form.handleSubmit(onSubmit)()
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ScaffoldDivider />
|
||||
<div className="flex flex-col gap-6 px-8 py-8">
|
||||
<h5 className="text-foreground text-sm">Vercel Preview deployments </h5>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="environmentVariablesPreview"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_ className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<FormLabel_Shadcn_ className="!text">
|
||||
Sync environment variables for Vercel Preview Deployments
|
||||
</FormLabel_Shadcn_>
|
||||
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter">
|
||||
Preview deployments will be able to connect to Supabase Database Preview
|
||||
branches
|
||||
</FormDescription_Shadcn_>
|
||||
</div>
|
||||
<FormItem_Shadcn_ className="space-y-0 flex gap-x-4">
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
className="mt-1"
|
||||
checked={field.value}
|
||||
disabled={!enableVercelConnectionsConfig}
|
||||
onCheckedChange={(e) => {
|
||||
field.onChange(e)
|
||||
form.handleSubmit(onSubmit)()
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
<div>
|
||||
<FormLabel_Shadcn_ className="!text">Preview</FormLabel_Shadcn_>
|
||||
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter">
|
||||
Sync environment variables for <code>preview</code> environment.
|
||||
</FormDescription_Shadcn_>
|
||||
</div>
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="authRedirectUrisPreview"
|
||||
name="environmentVariablesDevelopment"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_ className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<FormLabel_Shadcn_ className="!text">
|
||||
Auto update Auth Redirect URIs for Vercel Preview Deployments
|
||||
</FormLabel_Shadcn_>
|
||||
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter">
|
||||
Deploy Edge Functions when merged into Production Branch
|
||||
</FormDescription_Shadcn_>
|
||||
</div>
|
||||
<FormItem_Shadcn_ className="space-y-0 flex gap-x-4">
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
className="mt-1"
|
||||
checked={field.value}
|
||||
disabled={!enableVercelConnectionsConfig}
|
||||
onCheckedChange={(e) => {
|
||||
field.onChange(e)
|
||||
form.handleSubmit(onSubmit)()
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
<div>
|
||||
<FormLabel_Shadcn_ className="!text">Development</FormLabel_Shadcn_>
|
||||
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter">
|
||||
Sync environment variables for <code>development</code> environment.
|
||||
</FormDescription_Shadcn_>
|
||||
</div>
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ReportsLayoutProps {
|
||||
|
||||
const ReportsLayout = ({ title, children }: PropsWithChildren<ReportsLayoutProps>) => {
|
||||
return (
|
||||
<ProjectLayout title={title} product="Reports" productMenu={<ReportsMenu />}>
|
||||
<ProjectLayout title={title} product="Reports" productMenu={<ReportsMenu />} isBlocking={false}>
|
||||
<main style={{ maxHeight: '100vh' }} className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { Dictionary } from 'types'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
import { Button, IconActivity, IconAlertCircle, IconBarChart, IconLoader } from 'ui'
|
||||
import { PropsWithChildren, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import AreaChart from 'components/ui/Charts/AreaChart'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { AnalyticsInterval } from 'data/analytics/constants'
|
||||
import {
|
||||
InfraMonitoringAttribute,
|
||||
useInfraMonitoringQuery,
|
||||
} from 'data/analytics/infra-monitoring-query'
|
||||
import { useProjectDailyStatsQuery } from 'data/analytics/project-daily-stats-query'
|
||||
import { Activity, BarChartIcon, Loader2 } from 'lucide-react'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import { WarningIcon } from 'ui-patterns/Icons/StatusIcons'
|
||||
import type { ChartData } from './ChartHandler.types'
|
||||
import { BarChart } from './ChartRenderer'
|
||||
|
||||
interface ChartHandlerProps {
|
||||
label: string
|
||||
attribute: string
|
||||
provider: string
|
||||
provider: 'infra-monitoring' | 'daily-stats'
|
||||
startDate: string
|
||||
endDate: string
|
||||
interval: string
|
||||
@@ -56,72 +63,77 @@ const ChartHandler = ({
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
|
||||
// internal state is overridden by isLoading prop
|
||||
const [fetching, setFetching] = useState<boolean>(false)
|
||||
const [fetchedData, setFetchedData] = useState<any>(undefined)
|
||||
const { project } = useProjectContext()
|
||||
const isReadReplicasEnabled = project?.is_read_replicas_enabled
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
const [chartStyle, setChartStyle] = useState<string>(defaultChartStyle)
|
||||
const chartData = data || fetchedData
|
||||
const loading = isLoading || fetching
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
const databaseIdentifier = isReadReplicasEnabled ? state.selectedDatabaseId : undefined
|
||||
|
||||
const fetchChartData = async () => {
|
||||
setFetching(true)
|
||||
const { data: dailyStatsData, isLoading: isFetchingDailyStats } = useProjectDailyStatsQuery(
|
||||
{
|
||||
projectRef: ref as string,
|
||||
attribute: attribute as InfraMonitoringAttribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval: interval as AnalyticsInterval,
|
||||
databaseIdentifier,
|
||||
},
|
||||
{ enabled: provider === 'daily-stats' && data === undefined }
|
||||
)
|
||||
|
||||
const url = `${API_URL}/projects/${ref}/${provider}?attribute=${attribute}&startDate=${encodeURIComponent(
|
||||
startDate
|
||||
)}&endDate=${encodeURIComponent(endDate)}&interval=${interval ? interval : '1d'}`
|
||||
const { data: infraMonitoringData, isLoading: isFetchingInfraMonitoring } =
|
||||
useInfraMonitoringQuery(
|
||||
{
|
||||
projectRef: ref as string,
|
||||
attribute: attribute as InfraMonitoringAttribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval: interval as AnalyticsInterval,
|
||||
databaseIdentifier,
|
||||
},
|
||||
{ enabled: provider === 'infra-monitoring' && data === undefined }
|
||||
)
|
||||
|
||||
const { error, ...res } = await get(url)
|
||||
|
||||
if ((error || isUndefined(res)) && !cancel) {
|
||||
setFetching(false)
|
||||
setFetchedData(undefined)
|
||||
return console.error('Chart error:', error)
|
||||
}
|
||||
|
||||
// Convert null values to 0
|
||||
// TODO: Chart endpoint should handle this data formatting to 0 instead of client.
|
||||
const formattedChartData = (res?.data ?? []).map(
|
||||
(dataPoint: Dictionary<any>, idx: number) => {
|
||||
return {
|
||||
...dataPoint,
|
||||
[attribute]: dataPoint[attribute] ? Number(dataPoint[attribute]) : 0,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!cancel) {
|
||||
setFetchedData({ ...res, data: formattedChartData })
|
||||
setFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// only fetch if data prop is not provided
|
||||
if (data === undefined && !isLoading) {
|
||||
fetchChartData()
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [startDate])
|
||||
|
||||
highlightedValue = highlightedValue
|
||||
? highlightedValue
|
||||
: provider === 'daily-stats' && !attribute.includes('ingress') && !attribute.includes('egress')
|
||||
? chartData?.maximum
|
||||
const chartData =
|
||||
data ||
|
||||
(provider === 'infra-monitoring'
|
||||
? infraMonitoringData
|
||||
: provider === 'daily-stats'
|
||||
? chartData?.total
|
||||
: provider === 'log-stats'
|
||||
? chartData?.totalGrouped?.[attribute]
|
||||
: chartData?.data[chartData?.data.length - 1]?.[attribute]
|
||||
? dailyStatsData
|
||||
: undefined)
|
||||
|
||||
const loading =
|
||||
isLoading ||
|
||||
(provider === 'infra-monitoring'
|
||||
? isFetchingInfraMonitoring
|
||||
: provider === 'daily-stats'
|
||||
? isFetchingDailyStats
|
||||
: isLoading)
|
||||
|
||||
const shouldHighlightMaxValue =
|
||||
provider === 'daily-stats' &&
|
||||
!attribute.includes('ingress') &&
|
||||
!attribute.includes('egress') &&
|
||||
chartData !== undefined &&
|
||||
'maximum' in chartData
|
||||
const shouldHighlightTotalGroupedValue = chartData !== undefined && 'totalGrouped' in chartData
|
||||
|
||||
const _highlightedValue =
|
||||
highlightedValue !== undefined
|
||||
? highlightedValue
|
||||
: shouldHighlightMaxValue
|
||||
? chartData?.maximum
|
||||
: provider === 'daily-stats'
|
||||
? chartData?.total
|
||||
: shouldHighlightTotalGroupedValue
|
||||
? chartData?.totalGrouped?.[attribute]
|
||||
: (chartData?.data[chartData?.data.length - 1] as any)?.[attribute as any]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-52 w-full flex-col items-center justify-center space-y-4">
|
||||
<IconLoader className="animate-spin text-border-strong" />
|
||||
<div className="flex h-52 w-full flex-col items-center justify-center gap-y-2">
|
||||
<Loader2 size={18} className="animate-spin text-border-strong" />
|
||||
<p className="text-xs text-foreground-lighter">Loading data for {label}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -129,8 +141,8 @@ const ChartHandler = ({
|
||||
|
||||
if (isUndefined(chartData)) {
|
||||
return (
|
||||
<div className="flex h-52 w-full flex-col items-center justify-center space-y-4">
|
||||
<IconAlertCircle className="text-border-strong" />
|
||||
<div className="flex h-52 w-full flex-col items-center justify-center gap-y-2">
|
||||
<WarningIcon />
|
||||
<p className="text-xs text-foreground-lighter">Unable to load data for {label}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -139,37 +151,39 @@ const ChartHandler = ({
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="absolute right-6 z-50 flex justify-between">
|
||||
<div className="space-y-3">{children}</div>
|
||||
{!hideChartType && (
|
||||
<div>
|
||||
<div className="flex w-full space-x-3">
|
||||
<Button
|
||||
type="default"
|
||||
icon={chartStyle === 'bar' ? <IconActivity /> : <IconBarChart />}
|
||||
className="px-1.5"
|
||||
icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
|
||||
onClick={() => setChartStyle(chartStyle === 'bar' ? 'line' : 'bar')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{chartStyle === 'bar' ? (
|
||||
<BarChart
|
||||
data={chartData?.data ?? []}
|
||||
attribute={attribute}
|
||||
yAxisLimit={chartData?.yAxisLimit}
|
||||
format={format || chartData?.format}
|
||||
highlightedValue={highlightedValue}
|
||||
highlightedValue={_highlightedValue}
|
||||
label={label}
|
||||
customDateFormat={customDateFormat}
|
||||
onBarClick={onBarClick}
|
||||
/>
|
||||
) : (
|
||||
<AreaChart
|
||||
data={chartData?.data ?? []}
|
||||
data={(chartData?.data ?? []) as any}
|
||||
format={format || chartData?.format}
|
||||
xAxisKey="period_start"
|
||||
yAxisKey={attribute}
|
||||
highlightedValue={highlightedValue}
|
||||
highlightedValue={_highlightedValue}
|
||||
title={label}
|
||||
customDateFormat={customDateFormat}
|
||||
/>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { toJS } from 'mobx'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { useProjectContentStore } from 'stores/projectContentStore'
|
||||
import { Input, Modal } from 'ui'
|
||||
|
||||
const EditModal = () => {
|
||||
const router = useRouter()
|
||||
const { id, ref } = router.query
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [name, setName] = useState<string>()
|
||||
const [description, setDescription] = useState<string>()
|
||||
|
||||
const contentStore = useProjectContentStore(ref)
|
||||
|
||||
/*
|
||||
* fetchReport()
|
||||
*
|
||||
* Fetches the report and sets the Name and description
|
||||
*
|
||||
* toJS() is used to deepcopy mobx to js
|
||||
*/
|
||||
const fetchReport = async () => {
|
||||
setLoading(true)
|
||||
await contentStore.load()
|
||||
let reportData = contentStore.byId(id)
|
||||
|
||||
setName(toJS(reportData?.name))
|
||||
setDescription(toJS(reportData?.description))
|
||||
|
||||
setLoading(false)
|
||||
return reportData
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport()
|
||||
setVisible(false)
|
||||
}, [])
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const { data, error } = await contentStore.update(id, {
|
||||
name,
|
||||
description,
|
||||
})
|
||||
if (error) throw error
|
||||
setLoading(false)
|
||||
await contentStore.load()
|
||||
setVisible(!visible)
|
||||
return data
|
||||
} catch (error: any) {
|
||||
toast.error(error.message)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Edit report"
|
||||
visible={visible}
|
||||
onCancel={() => setVisible(false)}
|
||||
size="medium"
|
||||
onConfirm={handleSubmit}
|
||||
loading={loading}
|
||||
>
|
||||
<div className="w-full space-y-4">
|
||||
<Input
|
||||
layout="horizontal"
|
||||
label="Name"
|
||||
descriptionText="Name cannot be longer than 128 char"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
>
|
||||
Name
|
||||
</Input>
|
||||
<Input
|
||||
layout="horizontal"
|
||||
label="Description"
|
||||
descriptionText="description cannot be longer than 256 char"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
>
|
||||
Description
|
||||
</Input>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(EditModal)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconBarChart2 } from 'ui'
|
||||
import { BarChart2 } from 'lucide-react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
title?: string
|
||||
@@ -17,7 +17,7 @@ const EmptyState = ({
|
||||
border-dashed border-control py-4 text-center
|
||||
"
|
||||
>
|
||||
<IconBarChart2 className="text-foreground-light" />
|
||||
<BarChart2 size={20} className="text-foreground-light" />
|
||||
<div>
|
||||
<p className="text-xs text-foreground-light">{title}</p>
|
||||
<p className="text-xs text-foreground-light">{message}</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconBarChart2 } from 'ui'
|
||||
import { BarChart2 } from 'lucide-react'
|
||||
import { useChartSize } from './Charts.utils'
|
||||
|
||||
interface Props {
|
||||
@@ -23,7 +23,7 @@ const NoDataPlaceholder: React.FC<Props> = ({
|
||||
// extra 20 px for the x ticks
|
||||
style={{ minHeight: minHeight + 20 }}
|
||||
>
|
||||
<IconBarChart2 className="text-border-stronger" />
|
||||
<BarChart2 size={20} className="text-border-stronger" />
|
||||
<div>
|
||||
<p className="text-foreground-light text-xs">{title}</p>
|
||||
{message && <p className="text-foreground-lighter text-xs">{message}</p>}
|
||||
|
||||
@@ -15,3 +15,5 @@ export interface AnalyticsData {
|
||||
yAxisLimit: number
|
||||
hasNoData?: boolean
|
||||
}
|
||||
|
||||
export type AnalyticsInterval = '1m' | '5m' | '10m' | '30m' | '1h' | '1d'
|
||||
@@ -1,28 +1,38 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import type { AnalyticsData } from './constants'
|
||||
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import type { AnalyticsData, AnalyticsInterval } from './constants'
|
||||
import { analyticsKeys } from './keys'
|
||||
|
||||
export type InfraMonitoringAttribute =
|
||||
| 'max_cpu_usage'
|
||||
| 'avg_cpu_usage'
|
||||
| 'disk_io_budget'
|
||||
| 'ram_usage'
|
||||
| 'disk_io_consumption'
|
||||
| 'swap_usage'
|
||||
|
||||
export type InfraMonitoringVariables = {
|
||||
projectRef?: string
|
||||
attribute:
|
||||
| 'max_cpu_usage'
|
||||
| 'avg_cpu_usage'
|
||||
| 'disk_io_budget'
|
||||
| 'ram_usage'
|
||||
| 'disk_io_consumption'
|
||||
| 'swap_usage'
|
||||
attribute: InfraMonitoringAttribute
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
interval?: '1m' | '5m' | '10m' | '30m' | '1h' | '1d'
|
||||
interval?: AnalyticsInterval
|
||||
dateFormat?: string
|
||||
databaseIdentifier?: string
|
||||
modifier?: (x: number) => number
|
||||
}
|
||||
|
||||
export async function getInfraMonitoring(
|
||||
{ projectRef, attribute, startDate, endDate, interval = '1d' }: InfraMonitoringVariables,
|
||||
{
|
||||
projectRef,
|
||||
attribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval = '1d',
|
||||
databaseIdentifier,
|
||||
}: InfraMonitoringVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
@@ -30,15 +40,22 @@ export async function getInfraMonitoring(
|
||||
if (!startDate) throw new Error('Start date is required')
|
||||
if (!endDate) throw new Error('End date is required')
|
||||
|
||||
const data = await get(
|
||||
`${API_URL}/projects/${projectRef}/infra-monitoring?attribute=${attribute}&startDate=${encodeURIComponent(
|
||||
startDate
|
||||
)}&endDate=${encodeURIComponent(endDate)}&interval=${interval}`,
|
||||
{ signal }
|
||||
)
|
||||
const { data, error } = await get('/platform/projects/{ref}/infra-monitoring', {
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
query: {
|
||||
attribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
databaseIdentifier,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (data.error) throw data.error
|
||||
return data as AnalyticsData
|
||||
if (error) throw handleError(error)
|
||||
return data as unknown as AnalyticsData
|
||||
}
|
||||
|
||||
export type InfraMonitoringData = Awaited<ReturnType<typeof getInfraMonitoring>>
|
||||
@@ -52,6 +69,7 @@ export const useInfraMonitoringQuery = <TData = InfraMonitoringData>(
|
||||
endDate,
|
||||
interval = '1d',
|
||||
dateFormat = 'DD MMM',
|
||||
databaseIdentifier,
|
||||
modifier,
|
||||
}: InfraMonitoringVariables,
|
||||
{
|
||||
@@ -60,9 +78,18 @@ export const useInfraMonitoringQuery = <TData = InfraMonitoringData>(
|
||||
}: UseQueryOptions<InfraMonitoringData, InfraMonitoringError, TData> = {}
|
||||
) =>
|
||||
useQuery<InfraMonitoringData, InfraMonitoringError, TData>(
|
||||
analyticsKeys.infraMonitoring(projectRef, { attribute, startDate, endDate, interval }),
|
||||
analyticsKeys.infraMonitoring(projectRef, {
|
||||
attribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
databaseIdentifier,
|
||||
}),
|
||||
({ signal }) =>
|
||||
getInfraMonitoring({ projectRef, attribute, startDate, endDate, interval }, signal),
|
||||
getInfraMonitoring(
|
||||
{ projectRef, attribute, startDate, endDate, interval, databaseIdentifier },
|
||||
signal
|
||||
),
|
||||
{
|
||||
enabled:
|
||||
enabled &&
|
||||
|
||||
@@ -115,18 +115,20 @@ export const analyticsKeys = {
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
databaseIdentifier,
|
||||
}: {
|
||||
attribute?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
interval?: string
|
||||
databaseIdentifier?: string
|
||||
}
|
||||
) =>
|
||||
[
|
||||
'projects',
|
||||
projectRef,
|
||||
'infra-monitoring',
|
||||
{ attribute, startDate, endDate, interval },
|
||||
{ attribute, startDate, endDate, interval, databaseIdentifier },
|
||||
] as const,
|
||||
usageApiCounts: (projectRef: string | undefined, interval: string | undefined) =>
|
||||
['projects', projectRef, 'usage.api-counts', interval] as const,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import type { AnalyticsData } from './constants'
|
||||
import { analyticsKeys } from './keys'
|
||||
|
||||
export type ProjectDailyStatsAttribute =
|
||||
| 'max_cpu_usage'
|
||||
| 'avg_cpu_usage'
|
||||
| 'disk_io_budget'
|
||||
| 'ram_usage'
|
||||
| 'disk_io_consumption'
|
||||
| 'swap_usage'
|
||||
|
||||
export type ProjectDailyStatsInterval = '1m' | '5m' | '10m' | '30m' | '1h' | '1d'
|
||||
|
||||
export type ProjectDailyStatsVariables = {
|
||||
projectRef?: string
|
||||
attribute: ProjectDailyStatsAttribute
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
interval?: ProjectDailyStatsInterval
|
||||
dateFormat?: string
|
||||
databaseIdentifier?: string
|
||||
modifier?: (x: number) => number
|
||||
}
|
||||
|
||||
export async function getProjectDailyStats(
|
||||
{
|
||||
projectRef,
|
||||
attribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval = '1d',
|
||||
databaseIdentifier,
|
||||
}: ProjectDailyStatsVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
if (!attribute) throw new Error('Attribute is required')
|
||||
if (!startDate) throw new Error('Start date is required')
|
||||
if (!endDate) throw new Error('End date is required')
|
||||
|
||||
const { data, error } = await get('/platform/projects/{ref}/daily-stats', {
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
query: {
|
||||
attribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
// [Joshen] TODO: Once API support is ready
|
||||
// databaseIdentifier,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error) throw handleError(error)
|
||||
return data as unknown as AnalyticsData
|
||||
}
|
||||
|
||||
export type ProjectDailyStatsData = Awaited<ReturnType<typeof getProjectDailyStats>>
|
||||
export type ProjectDailyStatsError = unknown
|
||||
|
||||
export const useProjectDailyStatsQuery = <TData = ProjectDailyStatsData>(
|
||||
{
|
||||
projectRef,
|
||||
attribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval = '1d',
|
||||
dateFormat = 'DD MMM',
|
||||
databaseIdentifier,
|
||||
modifier,
|
||||
}: ProjectDailyStatsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<ProjectDailyStatsData, ProjectDailyStatsError, TData> = {}
|
||||
) =>
|
||||
useQuery<ProjectDailyStatsData, ProjectDailyStatsError, TData>(
|
||||
analyticsKeys.infraMonitoring(projectRef, {
|
||||
attribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
databaseIdentifier,
|
||||
}),
|
||||
({ signal }) =>
|
||||
getProjectDailyStats(
|
||||
{ projectRef, attribute, startDate, endDate, interval, databaseIdentifier },
|
||||
signal
|
||||
),
|
||||
{
|
||||
enabled:
|
||||
enabled &&
|
||||
typeof projectRef !== 'undefined' &&
|
||||
typeof attribute !== 'undefined' &&
|
||||
typeof startDate !== 'undefined' &&
|
||||
typeof endDate !== 'undefined',
|
||||
select(data) {
|
||||
return {
|
||||
...data,
|
||||
data: data.data.map((x) => {
|
||||
return {
|
||||
...x,
|
||||
[attribute]:
|
||||
modifier !== undefined ? modifier(Number(x[attribute])) : Number(x[attribute]),
|
||||
periodStartFormatted: dayjs(x.period_start).format(dateFormat),
|
||||
}
|
||||
}),
|
||||
} as TData
|
||||
},
|
||||
staleTime: 1000 * 60, // default good for a minute
|
||||
...options,
|
||||
}
|
||||
)
|
||||
@@ -7,7 +7,7 @@ import type { ResponseError } from 'types'
|
||||
import type { Content } from './content-query'
|
||||
import { contentKeys } from './keys'
|
||||
|
||||
export type InsertContentPayload = Omit<components['schemas']['CreateContentParams'], 'content'> & {
|
||||
export type InsertContentPayload = Omit<components['schemas']['CreateContentBody'], 'content'> & {
|
||||
content: Content['content']
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export type InsertContentVariables = {
|
||||
payload: InsertContentPayload
|
||||
}
|
||||
|
||||
export type InsertContentResponse = components['schemas']['UserContentObject']
|
||||
|
||||
export async function insertContent(
|
||||
{ projectRef, payload }: InsertContentVariables,
|
||||
signal?: AbortSignal
|
||||
@@ -35,7 +37,8 @@ export async function insertContent(
|
||||
})
|
||||
if (error) throw error
|
||||
|
||||
return data
|
||||
// [Joshen] There's an issue with the API codegen due to content endpoint having 2 versions
|
||||
return data as unknown as InsertContentResponse[]
|
||||
}
|
||||
|
||||
export type InsertContentData = Awaited<ReturnType<typeof insertContent>>
|
||||
|
||||
@@ -72,15 +72,3 @@ export const useContentQuery = <TData = ContentData>(
|
||||
({ signal }) => getContent(projectRef, signal),
|
||||
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
|
||||
)
|
||||
|
||||
export const useContentPrefetch = (projectRef: string | undefined) => {
|
||||
const client = useQueryClient()
|
||||
|
||||
return useCallback(() => {
|
||||
if (projectRef) {
|
||||
client.prefetchQuery(contentKeys.list(projectRef), ({ signal }) =>
|
||||
getContent(projectRef, signal)
|
||||
)
|
||||
}
|
||||
}, [projectRef])
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { patch } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { contentKeys } from './keys'
|
||||
|
||||
type UpdateContentVariables = { projectRef: string; id: string; type: 'report'; content: any }
|
||||
|
||||
export async function updateContent(
|
||||
{ projectRef, id, type, content }: UpdateContentVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const { data, error } = await patch('/platform/projects/{ref}/content', {
|
||||
params: {
|
||||
// @ts-ignore API codegen issue
|
||||
path: { ref: projectRef },
|
||||
query: { id },
|
||||
},
|
||||
body: { id, type, content },
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
type UpdateContentData = Awaited<ReturnType<typeof updateContent>>
|
||||
|
||||
export const useContentUpdateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<UpdateContentData, ResponseError, UpdateContentVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<UpdateContentData, ResponseError, UpdateContentVariables>(
|
||||
(args) => updateContent(args),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries(contentKeys.list(projectRef))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to update content: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { ResponseError } from 'types'
|
||||
import type { Content } from './content-query'
|
||||
import { contentKeys } from './keys'
|
||||
|
||||
export type UpsertContentPayload = Omit<components['schemas']['UpsertContentParams'], 'content'> & {
|
||||
export type UpsertContentPayload = Omit<components['schemas']['UpsertContentBody'], 'content'> & {
|
||||
content: Content['content']
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,7 @@ export async function getEnumeratedTypes(
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
const enumeratedTypes = data.filter((type) => type.enums.length > 0)
|
||||
return enumeratedTypes
|
||||
return data
|
||||
}
|
||||
|
||||
export type EnumeratedTypesData = Awaited<ReturnType<typeof getEnumeratedTypes>>
|
||||
|
||||
@@ -154,6 +154,7 @@ export type IntegrationProjectConnection = {
|
||||
supabase_project_ref: string
|
||||
foreign_project_id: string
|
||||
organization_integration_id: string
|
||||
env_sync_targets?: string[]
|
||||
metadata: Imetadata
|
||||
}
|
||||
|
||||
@@ -264,8 +265,10 @@ export type GitHubConnectionCreateVariables = {
|
||||
connection: components['schemas']['CreateGitHubConnectionsBody']
|
||||
}
|
||||
|
||||
export type EnvironmentTargets = 'production' | 'preview' | 'development'
|
||||
|
||||
export type UpdateConnectionPayload = {
|
||||
id: string
|
||||
organizationIntegrationId: string
|
||||
metadata: Imetadata
|
||||
envSyncTargets: EnvironmentTargets[]
|
||||
}
|
||||
@@ -6,18 +6,13 @@ import type { ResponseError } from 'types'
|
||||
import type { UpdateConnectionPayload } from './integrations.types'
|
||||
import { integrationKeys } from './keys'
|
||||
|
||||
export async function updateVercelConnection({
|
||||
id,
|
||||
metadata,
|
||||
organizationIntegrationId,
|
||||
}: UpdateConnectionPayload) {
|
||||
export async function updateVercelConnection({ id, envSyncTargets }: UpdateConnectionPayload) {
|
||||
const { data, error } = await patch('/platform/integrations/vercel/connections/{connection_id}', {
|
||||
params: {
|
||||
path: { connection_id: id },
|
||||
},
|
||||
body: {
|
||||
// @ts-expect-error
|
||||
metadata,
|
||||
env_sync_targets: envSyncTargets,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ const useDbQuery = ({
|
||||
orderBy?: string
|
||||
}): DbQueryHook => {
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
|
||||
const { data: databases } = useReadReplicasQuery({ projectRef: project?.ref })
|
||||
const connectionString = project?.is_read_replicas_enabled
|
||||
? (databases || []).find((db) => db.identifier === state.selectedDatabaseId)?.connectionString
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
|
||||
import {
|
||||
EXPLORER_DATEPICKER_HELPERS,
|
||||
genQueryParams,
|
||||
getDefaultHelper,
|
||||
} from 'components/interfaces/Settings/Logs'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
import type {
|
||||
LogsEndpointParams,
|
||||
Logs,
|
||||
LogData,
|
||||
Logs,
|
||||
LogsEndpointParams,
|
||||
} from 'components/interfaces/Settings/Logs/Logs.types'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { get, isResponseOk } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
|
||||
export interface LogsQueryHook {
|
||||
params: LogsEndpointParams
|
||||
isLoading: boolean
|
||||
@@ -27,6 +31,10 @@ const useLogsQuery = (
|
||||
projectRef: string,
|
||||
initialParams: Partial<LogsEndpointParams> = {}
|
||||
): LogsQueryHook => {
|
||||
// [Joshen] TODO once API is unblocked
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
const { data: databases } = useReadReplicasQuery({ projectRef })
|
||||
|
||||
const defaultHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS)
|
||||
const [params, setParams] = useState<LogsEndpointParams>({
|
||||
sql: initialParams?.sql || '',
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import Reports from 'components/interfaces/Reports/Reports'
|
||||
import { ReportsLayout } from 'components/layouts'
|
||||
import EditReportModal from 'components/to-be-cleaned/Reports/EditReportModal'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => (
|
||||
<>
|
||||
<div className="mx-auto flex flex-col gap-4 p-3">
|
||||
<Reports />
|
||||
</div>
|
||||
<EditReportModal />
|
||||
</>
|
||||
<div className="mx-auto flex flex-col gap-4 p-3">
|
||||
<Reports />
|
||||
</div>
|
||||
)
|
||||
|
||||
PageLayout.getLayout = (page) => <ReportsLayout>{page}</ReportsLayout>
|
||||
|
||||
@@ -25,6 +25,8 @@ import { ReportsLayout } from 'components/layouts'
|
||||
import ShimmerLine from 'components/ui/ShimmerLine'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useSelectedOrganization } from 'hooks'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
|
||||
export const ApiReport: NextPageWithLayout = () => {
|
||||
const report = useApiReport()
|
||||
@@ -108,7 +110,12 @@ export const ApiReport: NextPageWithLayout = () => {
|
||||
|
||||
// hook to fetch data
|
||||
const useApiReport = () => {
|
||||
const { project } = useProjectContext()
|
||||
const { ref: projectRef } = useParams()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
|
||||
// [Joshen] TODO: Once API support is out
|
||||
const showReadReplicasUI = false // project?.is_read_replicas_enabled
|
||||
|
||||
const queryHooks = queriesFactory<keyof typeof PRESET_CONFIG.api.queries>(
|
||||
PRESET_CONFIG.api.queries,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { useParams } from 'common/hooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
IconArrowRight,
|
||||
IconExternalLink,
|
||||
} from 'ui'
|
||||
import { AlertDescription_Shadcn_, Alert_Shadcn_, Button, IconExternalLink } from 'ui'
|
||||
|
||||
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
|
||||
import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants'
|
||||
@@ -18,6 +13,7 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
|
||||
import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler'
|
||||
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import DatabaseSelector from 'components/ui/DatabaseSelector'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useDatabaseSizeQuery } from 'data/database/database-size-query'
|
||||
import type { DbQueryHook } from 'hooks/analytics/useDbQuery'
|
||||
@@ -45,19 +41,27 @@ const DatabaseUsage = () => {
|
||||
const { project } = useProjectContext()
|
||||
const [dateRange, setDateRange] = useState<any>(undefined)
|
||||
|
||||
const showReadReplicasUI = project?.is_read_replicas_enabled
|
||||
|
||||
const report = useDatabaseReport()
|
||||
const { data } = useDatabaseSizeQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const databaseSizeBytes = data?.result[0].db_size ?? 0
|
||||
|
||||
const report = useDatabaseReport()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<section>
|
||||
<Panel title={<h2>Database health</h2>}>
|
||||
<Panel
|
||||
title={
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<h2>Database health</h2>
|
||||
{showReadReplicasUI && <DatabaseSelector />}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Panel.Content>
|
||||
<div className="mb-4 flex items-center space-x-3">
|
||||
<DateRangePicker
|
||||
@@ -68,12 +72,12 @@ const DatabaseUsage = () => {
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
{dateRange && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-foreground-light">
|
||||
{dayjs(dateRange.period_start.date).format('MMMM D, hh:mma')}
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
<IconArrowRight size={12} />
|
||||
<ArrowRight size={12} />
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
{dayjs(dateRange.period_end.date).format('MMMM D, hh:mma')}
|
||||
|
||||
@@ -1,50 +1,41 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common/hooks'
|
||||
import { useParams } from 'common'
|
||||
import { CreateReportModal } from 'components/interfaces/Reports/Reports.CreateReportModal'
|
||||
import { ReportsLayout } from 'components/layouts'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import { Loading } from 'components/ui/Loading'
|
||||
import { useContentQuery } from 'data/content/content-query'
|
||||
import { useCheckPermissions } from 'hooks'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { useProjectContentStore } from 'stores/projectContentStore'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
export const UserReportPage: NextPageWithLayout = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
|
||||
const { profile } = useProfile()
|
||||
const [showCreateReportModal, setShowCreateReportModal] = useState(false)
|
||||
|
||||
const contentStore = useProjectContentStore(ref)
|
||||
const { isLoading } = useContentQuery(ref, {
|
||||
onSuccess: (data) => {
|
||||
const reports = data.content
|
||||
.filter((x) => x.type === 'report')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
if (reports.length > 1) router.push(`/project/${ref}/reports/${reports[0].id}`)
|
||||
},
|
||||
})
|
||||
|
||||
const canCreateReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
|
||||
resource: { type: 'report', owner_id: profile?.id },
|
||||
subject: { id: profile?.id },
|
||||
})
|
||||
|
||||
async function loadReports() {
|
||||
await contentStore.load()
|
||||
const reports = contentStore.reports()
|
||||
|
||||
if (reports.length >= 1) {
|
||||
router.push(`/project/${ref}/reports/${reports[0].id}`)
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadReports()
|
||||
}, [ref])
|
||||
|
||||
return (
|
||||
<div className="mx-auto my-32 w-full max-w-7xl flex-grow space-y-16">
|
||||
{loading ? (
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
@@ -67,12 +58,8 @@ export const UserReportPage: NextPageWithLayout = () => {
|
||||
</ProductEmptyState>
|
||||
<CreateReportModal
|
||||
visible={showCreateReportModal}
|
||||
onCancel={() => {
|
||||
setShowCreateReportModal(false)
|
||||
}}
|
||||
afterSubmit={() => {
|
||||
setShowCreateReportModal(false)
|
||||
}}
|
||||
onCancel={() => setShowCreateReportModal(false)}
|
||||
afterSubmit={() => setShowCreateReportModal(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { makeObservable, observable, computed } from 'mobx'
|
||||
import { get, patch, post, delete_ } from 'lib/common/fetch'
|
||||
import { keyBy } from 'lodash'
|
||||
import type { UserContent } from 'types'
|
||||
import { API_URL } from 'lib/constants'
|
||||
|
||||
/**
|
||||
* Singleton
|
||||
* Usage:
|
||||
* import { useProjectContentStore } from 'stores/userContent/projectContentStore'
|
||||
* const contentStore = useProjectContentStore(projectRef)
|
||||
* contentStore.load() // inside usEffect()
|
||||
* @deprecated use content store on RootStore instead.
|
||||
*/
|
||||
let store: any = null
|
||||
let projectRef: any = null
|
||||
export function useProjectContentStore(ref: any) {
|
||||
if (store === null || ref !== projectRef) {
|
||||
// set the current ref
|
||||
projectRef = ref
|
||||
const baseUrl = `${API_URL}/projects/${ref}/content`
|
||||
store = new ProjectContent(baseUrl)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Local type declarations
|
||||
*/
|
||||
interface UserContentMap {
|
||||
[key: string]: UserContent
|
||||
}
|
||||
|
||||
/**
|
||||
* MobX Store
|
||||
* @deprecated
|
||||
*/
|
||||
export default class ProjectContent {
|
||||
STATES = {
|
||||
INITIAL: 'initial',
|
||||
LOADING: 'loading',
|
||||
ERROR: 'error',
|
||||
LOADED: 'loaded',
|
||||
}
|
||||
rootStore = null
|
||||
url = ''
|
||||
state = this.STATES.INITIAL
|
||||
data: UserContentMap = {}
|
||||
error = null
|
||||
|
||||
/**
|
||||
* @param connectionString And encrypted database connection string to pass to the API
|
||||
*/
|
||||
constructor(dataUrl: any) {
|
||||
this.url = dataUrl
|
||||
makeObservable(this, {
|
||||
state: observable,
|
||||
data: observable,
|
||||
list: observable,
|
||||
hasError: computed,
|
||||
isLoading: computed,
|
||||
})
|
||||
}
|
||||
|
||||
async fetchData() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const res = await get(this.url, { headers })
|
||||
if (res.error) {
|
||||
throw res.error
|
||||
}
|
||||
this.data = res.data.length > 0 ? keyBy(res.data, 'id') : res.data
|
||||
return res
|
||||
}
|
||||
|
||||
async load() {
|
||||
let { LOADING, ERROR, LOADED } = this.STATES
|
||||
try {
|
||||
this.error = null
|
||||
this.state = LOADING
|
||||
await this.fetchData()
|
||||
this.state = LOADED
|
||||
return this.data
|
||||
} catch (e: any) {
|
||||
console.error('e.message', e.message)
|
||||
this.error = e
|
||||
this.state = ERROR
|
||||
}
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.state === this.STATES.ERROR
|
||||
}
|
||||
|
||||
get isLoading() {
|
||||
return this.state === this.STATES.INITIAL || this.state === this.STATES.LOADING
|
||||
}
|
||||
|
||||
list(filter: any) {
|
||||
let arr = Object.values(this.data)
|
||||
if (!!filter) {
|
||||
return arr.filter(filter).sort((a, b) => a.name.localeCompare(b.name))
|
||||
} else {
|
||||
return arr.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
}
|
||||
|
||||
reports(filter: any) {
|
||||
let arr = Object.values(this.data)
|
||||
if (!!filter) {
|
||||
return arr
|
||||
.filter((x) => x.type == 'report')
|
||||
.filter(filter)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
} else {
|
||||
return arr.filter((x) => x.type == 'report').sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
}
|
||||
|
||||
sqlSnippets(filter: any) {
|
||||
let arr = Object.values(this.data)
|
||||
if (!!filter) {
|
||||
const arr_filtered = arr
|
||||
.filter((x) => x.type == 'sql')
|
||||
.filter(filter)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
return arr_filtered
|
||||
} else {
|
||||
const arr_filtered = arr
|
||||
.filter((x) => x.type == 'sql')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
return arr_filtered
|
||||
}
|
||||
}
|
||||
|
||||
byId(id: any) {
|
||||
return this.data[id]
|
||||
}
|
||||
|
||||
async create(payload: any) {
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const created = await post(this.url, payload, { headers })
|
||||
if (created.error) throw created.error
|
||||
this.data[created['id']] = created
|
||||
return { data: created, error: null }
|
||||
} catch (error) {
|
||||
return { data: null, error }
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: any, updates: any, type: any) {
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
let payload = {
|
||||
...updates,
|
||||
type,
|
||||
id,
|
||||
}
|
||||
const url = `${this.url}?id=${id}`
|
||||
const updated = await patch(url, payload, { headers })
|
||||
if (updated.error) throw updated.error
|
||||
this.data[id] = updated
|
||||
return { data: updated, error: null }
|
||||
} catch (error) {
|
||||
return { data: null, error }
|
||||
}
|
||||
}
|
||||
|
||||
async updateSql(id: any, updates: any) {
|
||||
return this.update(id, updates, 'sql')
|
||||
}
|
||||
|
||||
async updateReport(id: any, updates: any) {
|
||||
return this.update(id, updates, 'report')
|
||||
}
|
||||
|
||||
async del(id: any) {
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const url = `${this.url}?id=${id}`
|
||||
const deleted = await delete_(url, {}, { headers })
|
||||
if (deleted.error) throw deleted.error
|
||||
delete this.data[id]
|
||||
return { data: true, error: null }
|
||||
} catch (error) {
|
||||
return { data: false, error }
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+332
-38
@@ -472,17 +472,41 @@ export interface paths {
|
||||
get: operations['ProjectsController_getProjectByFlyExtensionId']
|
||||
}
|
||||
'/platform/projects/{ref}/content': {
|
||||
/** Gets project's content */
|
||||
/**
|
||||
* Gets project's content
|
||||
* @deprecated
|
||||
*/
|
||||
get: operations['ContentController_getContent']
|
||||
/** Updates project's content */
|
||||
put: operations['ContentController_updateWholeContent']
|
||||
put: operations['ContentController_updateWholeContentV2']
|
||||
/** Creates project's content */
|
||||
post: operations['ContentController_createContent']
|
||||
post: operations['ContentController_createContentV2']
|
||||
/** Deletes project's contents */
|
||||
delete: operations['ContentController_deleteContents']
|
||||
/** Updates project's content */
|
||||
/**
|
||||
* Updates project's content
|
||||
* @deprecated
|
||||
*/
|
||||
patch: operations['ContentController_updateContent']
|
||||
}
|
||||
'/platform/projects/{ref}/content/item/{id}': {
|
||||
/** Gets project's content by the given id */
|
||||
get: operations['ContentController_getContentById']
|
||||
}
|
||||
'/platform/projects/{ref}/content/folders': {
|
||||
/** Gets project's content root folder */
|
||||
get: operations['ContentFoldersController_getRootFolder']
|
||||
/** Creates project's content folder */
|
||||
post: operations['ContentFoldersController_createFolder']
|
||||
/** Deletes project's content folders */
|
||||
delete: operations['ContentFoldersController_DeleteFolder']
|
||||
}
|
||||
'/platform/projects/{ref}/content/folders/{id}': {
|
||||
/** Gets project's content folder */
|
||||
get: operations['ContentFoldersController_getFolder']
|
||||
/** Updates project's content folder */
|
||||
patch: operations['ContentFoldersController_updateFolder']
|
||||
}
|
||||
'/platform/projects/{ref}/daily-stats': {
|
||||
/** Gets daily project stats */
|
||||
get: operations['DailyStatsController_getDailyStats']
|
||||
@@ -796,11 +820,11 @@ export interface paths {
|
||||
post: operations['TelemetryPageController_sendServerPage']
|
||||
}
|
||||
'/platform/telemetry/activity': {
|
||||
/** Sends mixpanel server activity */
|
||||
/** Sends server activity */
|
||||
post: operations['TelemetryActivityController_sendServerActivity']
|
||||
}
|
||||
'/platform/telemetry/pageview': {
|
||||
/** Send mixpanel page event */
|
||||
/** Send pageview event */
|
||||
post: operations['TelemetryPageviewController_sendServerPageViewed']
|
||||
}
|
||||
'/platform/vercel/token': {
|
||||
@@ -923,6 +947,11 @@ export interface paths {
|
||||
post: operations['GithubSecretAlertController_resetJwt']
|
||||
}
|
||||
'/system/projects/{ref}/functions/{function_slug}': {
|
||||
/**
|
||||
* Delete a function
|
||||
* @description Deletes a function with the specified slug from the specified project.
|
||||
*/
|
||||
delete: operations['SystemFunctionSlugController_deleteFunction']
|
||||
/**
|
||||
* Update a function
|
||||
* @description Updates a function with the specified slug and project.
|
||||
@@ -1328,17 +1357,27 @@ export interface paths {
|
||||
get: operations['V0ProjectsMetricsController_getProjectsMetrics']
|
||||
}
|
||||
'/v0/projects/{ref}/content': {
|
||||
/** Gets project's content */
|
||||
/**
|
||||
* Gets project's content
|
||||
* @deprecated
|
||||
*/
|
||||
get: operations['ContentController_getContent']
|
||||
/** Updates project's content */
|
||||
put: operations['ContentController_updateWholeContent']
|
||||
put: operations['ContentController_updateWholeContentV2']
|
||||
/** Creates project's content */
|
||||
post: operations['ContentController_createContent']
|
||||
post: operations['ContentController_createContentV2']
|
||||
/** Deletes project's contents */
|
||||
delete: operations['ContentController_deleteContents']
|
||||
/** Updates project's content */
|
||||
/**
|
||||
* Updates project's content
|
||||
* @deprecated
|
||||
*/
|
||||
patch: operations['ContentController_updateContent']
|
||||
}
|
||||
'/v0/projects/{ref}/content/item/{id}': {
|
||||
/** Gets project's content by the given id */
|
||||
get: operations['ContentController_getContentById']
|
||||
}
|
||||
'/v0/projects/{ref}/databases': {
|
||||
/** Gets non-removed databases of a specified project */
|
||||
get: operations['DatabasesController_getDatabases']
|
||||
@@ -3709,15 +3748,28 @@ export interface components {
|
||||
description?: string
|
||||
project_id: number
|
||||
owner_id: number
|
||||
last_updated_by: number
|
||||
last_updated_by?: number
|
||||
}
|
||||
GetUserContentResponse: {
|
||||
data: components['schemas']['GetUserContentObject'][]
|
||||
}
|
||||
CreateContentParams: {
|
||||
GetUserContentByIdResponse: {
|
||||
folder_id?: string
|
||||
id: string
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
type: Record<string, never>
|
||||
visibility: Record<string, never>
|
||||
name: string
|
||||
description: string
|
||||
description?: string
|
||||
project_id: number
|
||||
owner_id: number
|
||||
last_updated_by?: number
|
||||
}
|
||||
CreateContentBody: {
|
||||
id?: string
|
||||
name: string
|
||||
description?: string
|
||||
/** @enum {string} */
|
||||
type: 'sql' | 'report' | 'log_sql'
|
||||
/** @enum {string} */
|
||||
@@ -3735,21 +3787,34 @@ export interface components {
|
||||
description?: string
|
||||
project_id: number
|
||||
owner_id: number
|
||||
last_updated_by: number
|
||||
last_updated_by?: number
|
||||
}
|
||||
UpsertContentParams: {
|
||||
id: string
|
||||
CreateContentBodyV2: {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
description?: string
|
||||
/** @enum {string} */
|
||||
type: 'sql' | 'report' | 'log_sql'
|
||||
/** @enum {string} */
|
||||
visibility: 'user' | 'project' | 'org' | 'public'
|
||||
content?: Record<string, never>
|
||||
owner_id?: number
|
||||
project_id: number
|
||||
folder_id?: string
|
||||
}
|
||||
UpdateContentParams: {
|
||||
UserContentObjectV2: {
|
||||
id: string
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
type: Record<string, never>
|
||||
visibility: Record<string, never>
|
||||
name: string
|
||||
description?: string
|
||||
project_id: number
|
||||
owner_id: number
|
||||
last_updated_by?: number
|
||||
folder_id?: string
|
||||
}
|
||||
UpdateContentBody: {
|
||||
id?: string
|
||||
name?: string
|
||||
description?: string
|
||||
@@ -3760,9 +3825,74 @@ export interface components {
|
||||
content?: Record<string, never>
|
||||
owner_id?: number
|
||||
}
|
||||
UpsertContentBody: {
|
||||
id?: string
|
||||
name: string
|
||||
description?: string
|
||||
/** @enum {string} */
|
||||
type: 'sql' | 'report' | 'log_sql'
|
||||
/** @enum {string} */
|
||||
visibility: 'user' | 'project' | 'org' | 'public'
|
||||
content?: Record<string, never>
|
||||
owner_id?: number
|
||||
project_id: number
|
||||
}
|
||||
UpsertContentBodyV2: {
|
||||
id?: string
|
||||
name: string
|
||||
description?: string
|
||||
/** @enum {string} */
|
||||
type: 'sql' | 'report' | 'log_sql'
|
||||
/** @enum {string} */
|
||||
visibility: 'user' | 'project' | 'org' | 'public'
|
||||
content?: Record<string, never>
|
||||
owner_id?: number
|
||||
project_id: number
|
||||
folder_id?: string
|
||||
}
|
||||
BulkDeleteUserContentResponse: {
|
||||
id: string
|
||||
}
|
||||
UserContentFolder: {
|
||||
id: string
|
||||
name: string
|
||||
project_id: number
|
||||
owner_id: number
|
||||
parent_id?: string | null
|
||||
}
|
||||
UserContentObjectMeta: {
|
||||
id: string
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
type: Record<string, never>
|
||||
visibility: Record<string, never>
|
||||
name: string
|
||||
description?: string
|
||||
project_id: number
|
||||
owner_id: number
|
||||
last_updated_by?: number
|
||||
folder_id?: string
|
||||
}
|
||||
GetUserContentFolderResponse: {
|
||||
data: {
|
||||
folders?: components['schemas']['UserContentFolder'][]
|
||||
contents?: components['schemas']['UserContentObjectMeta'][]
|
||||
}
|
||||
}
|
||||
CreateContentFolderBody: {
|
||||
name: string
|
||||
parent_id?: string
|
||||
}
|
||||
CreateUserContentFolderResponse: {
|
||||
id: string
|
||||
name: string
|
||||
project_id: number
|
||||
owner_id: number
|
||||
parent_id?: string | null
|
||||
}
|
||||
UpdateContentFolderBody: {
|
||||
name: string
|
||||
}
|
||||
DatabaseDetailResponse: {
|
||||
/** @enum {string} */
|
||||
status:
|
||||
@@ -4557,7 +4687,7 @@ export interface components {
|
||||
env_sync_error?: components['schemas']['SyncVercelEnvError']
|
||||
}
|
||||
UpdateVercelConnectionsBody: {
|
||||
metadata: Record<string, never>
|
||||
env_sync_targets?: ('production' | 'preview' | 'development')[]
|
||||
}
|
||||
DeleteVercelConnectionResponse: {
|
||||
id: string
|
||||
@@ -5663,7 +5793,7 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
visibility: 'user' | 'project' | 'org' | 'public'
|
||||
name: string
|
||||
description: string | null
|
||||
description?: string
|
||||
project: components['schemas']['SnippetProject']
|
||||
owner: components['schemas']['SnippetUser']
|
||||
updated_by: components['schemas']['SnippetUser']
|
||||
@@ -5685,7 +5815,7 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
visibility: 'user' | 'project' | 'org' | 'public'
|
||||
name: string
|
||||
description: string | null
|
||||
description?: string
|
||||
project: components['schemas']['SnippetProject']
|
||||
owner: components['schemas']['SnippetUser']
|
||||
updated_by: components['schemas']['SnippetUser']
|
||||
@@ -9350,7 +9480,10 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets project's content */
|
||||
/**
|
||||
* Gets project's content
|
||||
* @deprecated
|
||||
*/
|
||||
ContentController_getContent: {
|
||||
parameters: {
|
||||
path: {
|
||||
@@ -9371,17 +9504,15 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
/** Updates project's content */
|
||||
ContentController_updateWholeContent: {
|
||||
ContentController_updateWholeContentV2: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UpsertContentParams']
|
||||
'application/json': components['schemas']['UpsertContentBodyV2']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UserContentObject']
|
||||
}
|
||||
content: never
|
||||
}
|
||||
/** @description Failed to update project's content */
|
||||
500: {
|
||||
@@ -9390,7 +9521,7 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
/** Creates project's content */
|
||||
ContentController_createContent: {
|
||||
ContentController_createContentV2: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
@@ -9399,13 +9530,13 @@ export interface operations {
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['CreateContentParams']
|
||||
'application/json': components['schemas']['CreateContentBodyV2']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
201: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UserContentObject'][]
|
||||
'application/json': components['schemas']['UserContentObjectV2']
|
||||
}
|
||||
}
|
||||
/** @description Failed to create project's content */
|
||||
@@ -9437,7 +9568,10 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Updates project's content */
|
||||
/**
|
||||
* Updates project's content
|
||||
* @deprecated
|
||||
*/
|
||||
ContentController_updateContent: {
|
||||
parameters: {
|
||||
query: {
|
||||
@@ -9446,7 +9580,7 @@ export interface operations {
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UpdateContentParams']
|
||||
'application/json': components['schemas']['UpdateContentBody']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
@@ -9461,6 +9595,140 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets project's content by the given id */
|
||||
ContentController_getContentById: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
/** @description Content id */
|
||||
id: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['GetUserContentByIdResponse']
|
||||
}
|
||||
}
|
||||
/** @description Failed to retrieve project's content by the given id */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets project's content root folder */
|
||||
ContentFoldersController_getRootFolder: {
|
||||
parameters: {
|
||||
query?: {
|
||||
type?: 'sql' | 'report' | 'log_sql'
|
||||
}
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['GetUserContentFolderResponse']
|
||||
}
|
||||
}
|
||||
/** @description Failed to retrieve project's content root folder */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Creates project's content folder */
|
||||
ContentFoldersController_createFolder: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['CreateContentFolderBody']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
201: {
|
||||
content: {
|
||||
'application/json': components['schemas']['CreateUserContentFolderResponse']
|
||||
}
|
||||
}
|
||||
/** @description Failed to create project's content folder */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Deletes project's content folders */
|
||||
ContentFoldersController_DeleteFolder: {
|
||||
parameters: {
|
||||
query: {
|
||||
ids: string[]
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: never
|
||||
}
|
||||
/** @description Failed to delete project's content folders */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets project's content folder */
|
||||
ContentFoldersController_getFolder: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
/** @description Content folder id */
|
||||
id: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['GetUserContentFolderResponse']
|
||||
}
|
||||
}
|
||||
/** @description Failed to retrieve project's content folder */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Updates project's content folder */
|
||||
ContentFoldersController_updateFolder: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
/** @description Content folder id */
|
||||
id: string
|
||||
}
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UpdateContentFolderBody']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: never
|
||||
}
|
||||
/** @description Failed to update project's content folder */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets daily project stats */
|
||||
DailyStatsController_getDailyStats: {
|
||||
parameters: {
|
||||
@@ -11404,7 +11672,7 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Sends mixpanel server activity */
|
||||
/** Sends server activity */
|
||||
TelemetryActivityController_sendServerActivity: {
|
||||
requestBody: {
|
||||
content: {
|
||||
@@ -11415,13 +11683,13 @@ export interface operations {
|
||||
201: {
|
||||
content: never
|
||||
}
|
||||
/** @description Failed to send mixpanel server activity */
|
||||
/** @description Failed to send server activity */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Send mixpanel page event */
|
||||
/** Send pageview event */
|
||||
TelemetryPageviewController_sendServerPageViewed: {
|
||||
requestBody: {
|
||||
content: {
|
||||
@@ -11432,7 +11700,7 @@ export interface operations {
|
||||
201: {
|
||||
content: never
|
||||
}
|
||||
/** @description Failed to send mixpanel page event */
|
||||
/** @description Failed to send pageview event */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
@@ -11743,7 +12011,7 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
204: {
|
||||
content: never
|
||||
}
|
||||
/** @description Failed to update Vercel connection */
|
||||
@@ -12120,6 +12388,32 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Delete a function
|
||||
* @description Deletes a function with the specified slug from the specified project.
|
||||
*/
|
||||
SystemFunctionSlugController_deleteFunction: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
/** @description Function slug */
|
||||
function_slug: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: never
|
||||
}
|
||||
403: {
|
||||
content: never
|
||||
}
|
||||
/** @description Failed to delete function with given slug */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Update a function
|
||||
* @description Updates a function with the specified slug and project.
|
||||
|
||||
@@ -116,7 +116,7 @@ export const MultiSelectV2 = ({
|
||||
<CommandList_Shadcn_>
|
||||
<CommandEmpty_Shadcn_>No options found</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<ScrollArea className={cn(formattedOptions.length > 5 ? 'h-[225px]' : '')}>
|
||||
<ScrollArea className={cn(formattedOptions.length > 7 ? 'h-[210px]' : '')}>
|
||||
{formattedOptions?.map((option) => {
|
||||
const active =
|
||||
selectedOptions &&
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Local .terraform directories
|
||||
**/.terraform/*
|
||||
|
||||
# .tfstate files
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
|
||||
# Crash log files
|
||||
crash.log
|
||||
crash.*.log
|
||||
|
||||
# Exclude all .tfvars files, which are likely to contain sensitive data, such as
|
||||
# password, private keys, and other secrets. These should not be part of version
|
||||
# control as they are data points which are potentially sensitive and subject
|
||||
# to change depending on the environment.
|
||||
*.tfvars
|
||||
*.tfvars.json
|
||||
|
||||
# Ignore override files as they are usually used to override resources locally and so
|
||||
# are not checked in
|
||||
override.tf
|
||||
override.tf.json
|
||||
*_override.tf
|
||||
*_override.tf.json
|
||||
|
||||
# Include override files you do wish to add to version control using negated pattern
|
||||
# !example_override.tf
|
||||
|
||||
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
|
||||
# example: *tfplan*
|
||||
|
||||
# Ignore CLI configuration files
|
||||
.terraformrc
|
||||
terraform.rc
|
||||
@@ -0,0 +1,41 @@
|
||||
variable "git_branch" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
# Fetch all branches of a linked project
|
||||
data "supabase_branch" "all" {
|
||||
parent_project_ref = var.linked_project
|
||||
}
|
||||
|
||||
# Import an existing branch database
|
||||
import {
|
||||
for_each = {
|
||||
for b in data.supabase_branch.all.branches :
|
||||
b.git_branch => b
|
||||
if b.git_branch == var.git_branch
|
||||
}
|
||||
to = supabase_branch.imported[0]
|
||||
id = each.value.id
|
||||
}
|
||||
|
||||
resource "supabase_branch" "imported" {
|
||||
count = length(var.git_branch[*])
|
||||
parent_project_ref = var.linked_project
|
||||
git_branch = var.git_branch
|
||||
}
|
||||
|
||||
# Override auth settings for the current branch
|
||||
resource "supabase_settings" "preview" {
|
||||
count = length(var.git_branch[*])
|
||||
project_ref = supabase_branch.imported[0].database.id
|
||||
|
||||
auth = jsonencode({
|
||||
site_url = "http://localhost:3001"
|
||||
})
|
||||
}
|
||||
|
||||
output "branch_database" {
|
||||
value = one(supabase_branch.imported[*].database)
|
||||
sensitive = true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Define a linked project variable as user input
|
||||
variable "linked_project" {
|
||||
type = string
|
||||
}
|
||||
|
||||
# Configure api settings for the linked project
|
||||
resource "supabase_settings" "production" {
|
||||
project_ref = var.linked_project
|
||||
|
||||
database = jsonencode({})
|
||||
|
||||
api = jsonencode({
|
||||
db_schema = "public, storage, graphql_public"
|
||||
db_extra_search_path = "public, extensions"
|
||||
max_rows = 1000
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
supabase = {
|
||||
source = "supabase/supabase"
|
||||
version = "~> 1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "supabase" {}
|
||||
Reference in new issue
Block a user