From 9360b943988cfda7e72ffd3062359f8f3b85fea2 Mon Sep 17 00:00:00 2001 From: Han Qiao Date: Tue, 7 May 2024 11:30:26 +0800 Subject: [PATCH 1/5] feat: version control branch configs with terraform (#23299) --- .github/workflows/config-preview.yaml | 63 ++++++++++++++++++++++++ .github/workflows/config-production.yaml | 28 +++++++++++ supabase/remotes/.gitignore | 34 +++++++++++++ supabase/remotes/preview.tf | 41 +++++++++++++++ supabase/remotes/production.tf | 17 +++++++ supabase/remotes/provider.tf | 10 ++++ 6 files changed, 193 insertions(+) create mode 100644 .github/workflows/config-preview.yaml create mode 100644 .github/workflows/config-production.yaml create mode 100644 supabase/remotes/.gitignore create mode 100644 supabase/remotes/preview.tf create mode 100644 supabase/remotes/production.tf create mode 100644 supabase/remotes/provider.tf diff --git a/.github/workflows/config-preview.yaml b/.github/workflows/config-preview.yaml new file mode 100644 index 00000000000..cca3c2d7de3 --- /dev/null +++ b/.github/workflows/config-preview.yaml @@ -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" diff --git a/.github/workflows/config-production.yaml b/.github/workflows/config-production.yaml new file mode 100644 index 00000000000..b7f3dd3d63c --- /dev/null +++ b/.github/workflows/config-production.yaml @@ -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 diff --git a/supabase/remotes/.gitignore b/supabase/remotes/.gitignore new file mode 100644 index 00000000000..9b8a46e692b --- /dev/null +++ b/supabase/remotes/.gitignore @@ -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 diff --git a/supabase/remotes/preview.tf b/supabase/remotes/preview.tf new file mode 100644 index 00000000000..663af43d97e --- /dev/null +++ b/supabase/remotes/preview.tf @@ -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 +} diff --git a/supabase/remotes/production.tf b/supabase/remotes/production.tf new file mode 100644 index 00000000000..fca9c90a355 --- /dev/null +++ b/supabase/remotes/production.tf @@ -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 + }) +} diff --git a/supabase/remotes/provider.tf b/supabase/remotes/provider.tf new file mode 100644 index 00000000000..6fba014c65c --- /dev/null +++ b/supabase/remotes/provider.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + supabase = { + source = "supabase/supabase" + version = "~> 1.0" + } + } +} + +provider "supabase" {} From c7678d1d27c2d74b9ee46f62dd96f1e51e979c4a Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 7 May 2024 13:14:11 +0800 Subject: [PATCH 2/5] Shift filtering of enum types lenght out of react query to just the enum types page (#25826) * Shift filtering of enum types lenght out of react query to just the enum types page * Update apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx Co-authored-by: Alaister Young --------- Co-authored-by: Alaister Young --- .../EnumeratedTypes/EnumeratedTypes.tsx | 30 ++++++++----------- .../enumerated-types-query.ts | 3 +- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index d0a826b5e03..a35e7ce1df9 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -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={} + icon={} /> {!isLocked && ( @@ -103,7 +101,7 @@ const EnumeratedTypes = () => { ]} body={ <> - {enumeratedTypes.length === 0 && search.length === 0 && ( + {filteredEnumeratedTypes.length === 0 && search.length === 0 && (

No enumerated types created yet

@@ -113,7 +111,7 @@ const EnumeratedTypes = () => {
)} - {enumeratedTypes.length === 0 && search.length > 0 && ( + {filteredEnumeratedTypes.length === 0 && search.length > 0 && (

No results found

@@ -123,8 +121,8 @@ const EnumeratedTypes = () => {
)} - {enumeratedTypes.length > 0 && - enumeratedTypes.map((type) => ( + {filteredEnumeratedTypes.length > 0 && + filteredEnumeratedTypes.map((type) => (

{type.schema}

@@ -136,23 +134,21 @@ const EnumeratedTypes = () => {
- + + + + + + + No schemas found + + 7 ? 'h-[210px]' : ''}> + {(schemas ?? []).map((schema) => ( + { + setSelectedSchema(schema.name) + setSchemaDropdownOpen(false) + }} + onClick={() => { + setSelectedSchema(schema.name) + setSchemaDropdownOpen(false) + }} + > + {schema.name} + {selectedEntity === schema.name && ( + + )} + + ))} + + + + + + - - - - {selectedEntity} - - - - {(entityTypes ?? []).map((entity) => ( - - {entity.name} - - ))} - - + + {isLoading ? ( + + ) : ( + + )} + + + + + + No tables found + + 7 ? 'h-[210px]' : ''}> + {(entityTypes ?? []).map((entity) => ( + { + setSelectedEntity(entity.name) + setTableDropdownOpen(false) + }} + onClick={() => { + setSelectedEntity(entity.name) + setTableDropdownOpen(false) + }} + > + {entity.name} + {selectedEntity === entity.name && ( + + )} + + ))} + + + + + + {selectedEntity && ( {isLoadingTableColumns && } {isSuccessTableColumns && ( - No options found - 5 ? 'h-[225px]' : '')}> + 7 ? 'h-[210px]' : '')}> {formattedOptions?.map((option) => { const active = selectedOptions && From 1afaae6447beda61d0ef015fce180eeb61a81347 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 7 May 2024 16:44:23 +0800 Subject: [PATCH 4/5] Chore/reports read replicas filter (#23711) * Scaffold for API reports * Add support for filtering database reports by replicas * midway adding read replica support for custom reports * Refactor reports page to use react query and deprecate project content mobxstore * Reports add chart labels if metric is read replica specific * Give an easier way to remove charts from reports * make reports layout non-blocking * Update apps/studio/components/interfaces/Reports/Reports.tsx Co-authored-by: Alaister Young --------- Co-authored-by: Alaister Young Co-authored-by: Alaister Young --- .../QueryPerformance/QueryPerformance.tsx | 4 +- .../interfaces/Reports/GridResize.tsx | 49 ++- .../interfaces/Reports/MetricOptions.tsx | 57 ++++ .../interfaces/Reports/ReportFilterBar.tsx | 54 +-- .../interfaces/Reports/ReportWidget.tsx | 27 +- .../components/interfaces/Reports/Reports.tsx | 321 ++++++------------ .../Reports/renderers/ApiRenderers.tsx | 27 +- .../layouts/ReportsLayout/ReportsLayout.tsx | 2 +- .../to-be-cleaned/Charts/ChartHandler.tsx | 154 +++++---- .../to-be-cleaned/Reports/EditReportModal.tsx | 95 ------ .../components/ui/Charts/EmptyState.tsx | 4 +- .../ui/Charts/NoDataPlaceholder.tsx | 4 +- apps/studio/data/analytics/constants.ts | 2 + .../data/analytics/infra-monitoring-query.ts | 71 ++-- apps/studio/data/analytics/keys.ts | 4 +- .../analytics/project-daily-stats-query.ts | 119 +++++++ apps/studio/data/content/content-query.ts | 12 - .../data/content/content-update-mutation.ts | 58 ++++ apps/studio/hooks/analytics/useDbQuery.tsx | 2 +- apps/studio/hooks/analytics/useLogsQuery.tsx | 16 +- .../pages/project/[ref]/reports/[id].tsx | 10 +- .../project/[ref]/reports/api-overview.tsx | 7 + .../pages/project/[ref]/reports/database.tsx | 28 +- .../pages/project/[ref]/reports/index.tsx | 43 +-- apps/studio/stores/projectContentStore.ts | 199 ----------- packages/api-types/types/api.d.ts | 12 +- 26 files changed, 647 insertions(+), 734 deletions(-) create mode 100644 apps/studio/components/interfaces/Reports/MetricOptions.tsx delete mode 100644 apps/studio/components/to-be-cleaned/Reports/EditReportModal.tsx create mode 100644 apps/studio/data/analytics/project-daily-stats-query.ts create mode 100644 apps/studio/data/content/content-update-mutation.ts delete mode 100644 apps/studio/stores/projectContentStore.ts diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx index 1c5d4d2893b..5cb52f3bef7 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Reports/GridResize.tsx b/apps/studio/components/interfaces/Reports/GridResize.tsx index 6e17cd4e33a..3c7af8abd36 100644 --- a/apps/studio/components/interfaces/Reports/GridResize.tsx +++ b/apps/studio/components/interfaces/Reports/GridResize.tsx @@ -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 ( <> + > + + + @@ -285,12 +298,13 @@ const ReportFilterBar = ({ asChild type="default" size="tiny" - icon={} + icon={} > Add filter
+ {showReadReplicasUI && } ) } diff --git a/apps/studio/components/interfaces/Reports/ReportWidget.tsx b/apps/studio/components/interfaces/Reports/ReportWidget.tsx index f8bf9fa3524..f745036efb4 100644 --- a/apps/studio/components/interfaces/Reports/ReportWidget.tsx +++ b/apps/studio/components/interfaces/Reports/ReportWidget.tsx @@ -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 extends ReportWidgetProps projectRef: string } -const ReportWidget: React.FC = (props) => { +const ReportWidget = (props: ReportWidgetProps) => { const router = useRouter() - const { ref } = router.query + const { ref } = useParams() const projectRef = ref as string + return ( @@ -47,11 +51,7 @@ const ReportWidget: React.FC = (props) => { {props?.tooltip && ( - + @@ -76,7 +76,7 @@ const ReportWidget: React.FC = (props) => { + + + )}
@@ -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')} - + {dayjs(endDate).format('MMM D, YYYY')} @@ -348,75 +259,48 @@ const Reports = () => { )}
-
- {hasEdits && ( -
- There are unsaved changes -
- )} - {hasEdits && ( - - )} - +
{canUpdateReport ? ( - - + ) : ( - - - - - - - -
- - You need additional permissions to update this project's report - -
-
-
-
+ + + You need additional permissions to update this project's report + + )} + + {hasReadReplicasEnabled && }
- {config.layout.length <= 0 ? ( + {config?.layout !== undefined && config.layout.length <= 0 ? (
{canUpdateReport ? ( - - + ) : ( @@ -431,6 +315,7 @@ const Reports = () => { endDate={endDate} interval={config.interval} editableReport={config} + onRemoveChart={popChart} setEditableReport={setConfig} /> )} diff --git a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx index e9f47b7d94f..aa96863c082 100644 --- a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx +++ b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx @@ -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 = (
) } + 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 ( Request @@ -128,10 +133,10 @@ export const TopApiRoutesRenderer = ( body={ <> {props.data.map((datum, 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) => ( -
+
)} + {children} + {chartStyle === 'bar' ? ( ) : ( diff --git a/apps/studio/components/to-be-cleaned/Reports/EditReportModal.tsx b/apps/studio/components/to-be-cleaned/Reports/EditReportModal.tsx deleted file mode 100644 index f94eb710f4c..00000000000 --- a/apps/studio/components/to-be-cleaned/Reports/EditReportModal.tsx +++ /dev/null @@ -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(false) - const [loading, setLoading] = useState(false) - const [name, setName] = useState() - const [description, setDescription] = useState() - - 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 ( - setVisible(false)} - size="medium" - onConfirm={handleSubmit} - loading={loading} - > -
- setName(e.target.value)} - > - Name - - setDescription(e.target.value)} - > - Description - -
-
- ) -} - -export default observer(EditModal) diff --git a/apps/studio/components/ui/Charts/EmptyState.tsx b/apps/studio/components/ui/Charts/EmptyState.tsx index b7f791c7bd8..39888e06666 100644 --- a/apps/studio/components/ui/Charts/EmptyState.tsx +++ b/apps/studio/components/ui/Charts/EmptyState.tsx @@ -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 " > - +

{title}

{message}

diff --git a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx index ec8599b9907..84c0d6c23bd 100644 --- a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx +++ b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx @@ -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 = ({ // extra 20 px for the x ticks style={{ minHeight: minHeight + 20 }} > - +

{title}

{message &&

{message}

} diff --git a/apps/studio/data/analytics/constants.ts b/apps/studio/data/analytics/constants.ts index 556dc996957..225ac288287 100644 --- a/apps/studio/data/analytics/constants.ts +++ b/apps/studio/data/analytics/constants.ts @@ -15,3 +15,5 @@ export interface AnalyticsData { yAxisLimit: number hasNoData?: boolean } + +export type AnalyticsInterval = '1m' | '5m' | '10m' | '30m' | '1h' | '1d' diff --git a/apps/studio/data/analytics/infra-monitoring-query.ts b/apps/studio/data/analytics/infra-monitoring-query.ts index ce73629d8b4..1c3362c5aef 100644 --- a/apps/studio/data/analytics/infra-monitoring-query.ts +++ b/apps/studio/data/analytics/infra-monitoring-query.ts @@ -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> @@ -52,6 +69,7 @@ export const useInfraMonitoringQuery = ( endDate, interval = '1d', dateFormat = 'DD MMM', + databaseIdentifier, modifier, }: InfraMonitoringVariables, { @@ -60,9 +78,18 @@ export const useInfraMonitoringQuery = ( }: UseQueryOptions = {} ) => useQuery( - 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 && diff --git a/apps/studio/data/analytics/keys.ts b/apps/studio/data/analytics/keys.ts index 9f1cee14020..a40481331fe 100644 --- a/apps/studio/data/analytics/keys.ts +++ b/apps/studio/data/analytics/keys.ts @@ -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, diff --git a/apps/studio/data/analytics/project-daily-stats-query.ts b/apps/studio/data/analytics/project-daily-stats-query.ts new file mode 100644 index 00000000000..593ff9225e2 --- /dev/null +++ b/apps/studio/data/analytics/project-daily-stats-query.ts @@ -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> +export type ProjectDailyStatsError = unknown + +export const useProjectDailyStatsQuery = ( + { + projectRef, + attribute, + startDate, + endDate, + interval = '1d', + dateFormat = 'DD MMM', + databaseIdentifier, + modifier, + }: ProjectDailyStatsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + 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, + } + ) diff --git a/apps/studio/data/content/content-query.ts b/apps/studio/data/content/content-query.ts index 578b7bf674e..d5129ace1c5 100644 --- a/apps/studio/data/content/content-query.ts +++ b/apps/studio/data/content/content-query.ts @@ -72,15 +72,3 @@ export const useContentQuery = ( ({ 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]) -} diff --git a/apps/studio/data/content/content-update-mutation.ts b/apps/studio/data/content/content-update-mutation.ts new file mode 100644 index 00000000000..c3faf2908c7 --- /dev/null +++ b/apps/studio/data/content/content-update-mutation.ts @@ -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> + +export const useContentUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (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, + } + ) +} diff --git a/apps/studio/hooks/analytics/useDbQuery.tsx b/apps/studio/hooks/analytics/useDbQuery.tsx index 583f3a09249..0bf949a1d73 100644 --- a/apps/studio/hooks/analytics/useDbQuery.tsx +++ b/apps/studio/hooks/analytics/useDbQuery.tsx @@ -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 diff --git a/apps/studio/hooks/analytics/useLogsQuery.tsx b/apps/studio/hooks/analytics/useLogsQuery.tsx index 2460f8d2c2e..eb18f77bfa3 100644 --- a/apps/studio/hooks/analytics/useLogsQuery.tsx +++ b/apps/studio/hooks/analytics/useLogsQuery.tsx @@ -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 = {} ): 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({ sql: initialParams?.sql || '', diff --git a/apps/studio/pages/project/[ref]/reports/[id].tsx b/apps/studio/pages/project/[ref]/reports/[id].tsx index fd048568f65..9ac7c0cccc5 100644 --- a/apps/studio/pages/project/[ref]/reports/[id].tsx +++ b/apps/studio/pages/project/[ref]/reports/[id].tsx @@ -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 = () => ( - <> -
- -
- - +
+ +
) PageLayout.getLayout = (page) => {page} diff --git a/apps/studio/pages/project/[ref]/reports/api-overview.tsx b/apps/studio/pages/project/[ref]/reports/api-overview.tsx index 6774133f4ea..508f71c38d0 100644 --- a/apps/studio/pages/project/[ref]/reports/api-overview.tsx +++ b/apps/studio/pages/project/[ref]/reports/api-overview.tsx @@ -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( PRESET_CONFIG.api.queries, diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index e55d4e0983b..0f9f6b4f70b 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -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(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 ( <>
- Database health}> + +

Database health

+ {showReadReplicasUI && } +
+ } + >
{ onChange={setDateRange} /> {dateRange && ( -
+

{dayjs(dateRange.period_start.date).format('MMMM D, hh:mma')}

- +

{dayjs(dateRange.period_end.date).format('MMMM D, hh:mma')} diff --git a/apps/studio/pages/project/[ref]/reports/index.tsx b/apps/studio/pages/project/[ref]/reports/index.tsx index 3972cdc5b62..72d4a30cff9 100644 --- a/apps/studio/pages/project/[ref]/reports/index.tsx +++ b/apps/studio/pages/project/[ref]/reports/index.tsx @@ -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 (

- {loading ? ( + {isLoading ? ( ) : ( <> @@ -67,12 +58,8 @@ export const UserReportPage: NextPageWithLayout = () => { { - setShowCreateReportModal(false) - }} - afterSubmit={() => { - setShowCreateReportModal(false) - }} + onCancel={() => setShowCreateReportModal(false)} + afterSubmit={() => setShowCreateReportModal(false)} /> )} diff --git a/apps/studio/stores/projectContentStore.ts b/apps/studio/stores/projectContentStore.ts deleted file mode 100644 index cac7ff84f35..00000000000 --- a/apps/studio/stores/projectContentStore.ts +++ /dev/null @@ -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 } - } - } -} diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 4181f308eb8..c88eacdb5b9 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -796,11 +796,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': { @@ -11404,7 +11404,7 @@ export interface operations { } } } - /** Sends mixpanel server activity */ + /** Sends server activity */ TelemetryActivityController_sendServerActivity: { requestBody: { content: { @@ -11415,13 +11415,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 +11432,7 @@ export interface operations { 201: { content: never } - /** @description Failed to send mixpanel page event */ + /** @description Failed to send pageview event */ 500: { content: never } From b18be7e4d24828d38c73af7ca243edc7170a8513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Og=C3=B3rek?= Date: Tue, 7 May 2024 13:19:07 +0200 Subject: [PATCH 5/5] feat: Allow for configuring target envs for Vercel integration (#25782) --- .../Reports/Reports.CreateReportModal.tsx | 1 - .../VercelIntegrationConnectionForm.tsx | 178 +++------ .../data/content/content-insert-mutation.ts | 7 +- .../data/content/content-upsert-mutation.ts | 2 +- .../data/integrations/integrations.types.ts | 5 +- .../vercel-connection-update-mutate.ts | 9 +- packages/api-types/types/api.d.ts | 358 ++++++++++++++++-- 7 files changed, 391 insertions(+), 169 deletions(-) diff --git a/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx b/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx index 8040f37309f..455ff463698 100644 --- a/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx @@ -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() diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx index b3b562d265f..fc10116559d 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx @@ -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>({ 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) { - /** - * 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 ( -
- - - Vercel Connection configuration coming soon - - This configuration will allow you to control the environment variables and auth - redirects for production and preview deployments. - - -
- -
-
- {/* {isUpdatingVercelConnection && 'isUpdatingVercelConnection'} */} -
-
Vercel Production deployments
+ +
+
+ Sync environment variables for selected target environments +
+
( - -
- - Sync environment variables for Vercel Production deployments - - - Deploy Edge Functions when merged into Production Branch - -
+ { field.onChange(e) form.handleSubmit(onSubmit)() }} /> - - )} - /> - {/* */} - ( -
- - Auto update Auth Redirect URIs for Vercel Production Deployments - + Production - Deploy Edge Functions when merged into Production Branch + Sync environment variables for production environment.
- - { - field.onChange(e) - form.handleSubmit(onSubmit)() - }} - /> -
)} /> -
- -
-
Vercel Preview deployments
( - -
- - Sync environment variables for Vercel Preview Deployments - - - Preview deployments will be able to connect to Supabase Database Preview - branches - -
+ { field.onChange(e) form.handleSubmit(onSubmit)() }} /> +
+ Preview + + Sync environment variables for preview environment. + +
)} /> ( - -
- - Auto update Auth Redirect URIs for Vercel Preview Deployments - - - Deploy Edge Functions when merged into Production Branch - -
+ { field.onChange(e) form.handleSubmit(onSubmit)() }} /> +
+ Development + + Sync environment variables for development environment. + +
)} /> diff --git a/apps/studio/data/content/content-insert-mutation.ts b/apps/studio/data/content/content-insert-mutation.ts index c7bdfab3b3c..4f77b5ab71d 100644 --- a/apps/studio/data/content/content-insert-mutation.ts +++ b/apps/studio/data/content/content-insert-mutation.ts @@ -7,7 +7,7 @@ import type { ResponseError } from 'types' import type { Content } from './content-query' import { contentKeys } from './keys' -export type InsertContentPayload = Omit & { +export type InsertContentPayload = Omit & { 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> diff --git a/apps/studio/data/content/content-upsert-mutation.ts b/apps/studio/data/content/content-upsert-mutation.ts index 06cf8f8f176..50df79e4efe 100644 --- a/apps/studio/data/content/content-upsert-mutation.ts +++ b/apps/studio/data/content/content-upsert-mutation.ts @@ -7,7 +7,7 @@ import type { ResponseError } from 'types' import type { Content } from './content-query' import { contentKeys } from './keys' -export type UpsertContentPayload = Omit & { +export type UpsertContentPayload = Omit & { content: Content['content'] } diff --git a/apps/studio/data/integrations/integrations.types.ts b/apps/studio/data/integrations/integrations.types.ts index 844b531a735..4027e04e80a 100644 --- a/apps/studio/data/integrations/integrations.types.ts +++ b/apps/studio/data/integrations/integrations.types.ts @@ -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[] } diff --git a/apps/studio/data/integrations/vercel-connection-update-mutate.ts b/apps/studio/data/integrations/vercel-connection-update-mutate.ts index 89194cf0288..57fb2c1b5d9 100644 --- a/apps/studio/data/integrations/vercel-connection-update-mutate.ts +++ b/apps/studio/data/integrations/vercel-connection-update-mutate.ts @@ -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, }, }) diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index c88eacdb5b9..f05b7377788 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -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'] @@ -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 + visibility: Record 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 owner_id?: number - project_id: number + folder_id?: string } - UpdateContentParams: { + UserContentObjectV2: { + id: string + inserted_at: string + updated_at: string + type: Record + visibility: Record + 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 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 + 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 + 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 + visibility: Record + 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 + 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: { @@ -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.