diff --git a/apps/www/data/PricingFAQ.json b/apps/www/data/PricingFAQ.json index b5a79b1fd45..234a99231a0 100644 --- a/apps/www/data/PricingFAQ.json +++ b/apps/www/data/PricingFAQ.json @@ -34,5 +34,9 @@ { "question": "Can I self host Supabase for free?", "answer": "Yes, our Docker setup can be found [here](https://github.com/supabase/supabase), or in our [CLI](https://github.com/supabase/cli). Supabase Studio is available in the docker setup, read more about it [here](https://supabase.com/blog/2021/11/30/supabase-studio)." + }, + { + "question": "Can I pause a free project?", + "answer": "Yes, you can pause a project at any time. Our Free tier gives you 2 free projects, but you can have as many paused projects as you want. Just pause and unpause them as needed." } ] diff --git a/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index 1ce7813ed16..22810ce4f6a 100644 --- a/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -1,69 +1,65 @@ import { FC } from 'react' +import { Badge, IconLoader, IconPauseCircle } from '@supabase/ui' -import { Badge, Button, IconClock } from '@supabase/ui' import { Project } from 'types' import CardButton from 'components/ui/CardButton' +import { PROJECT_STATUS } from 'lib/constants' interface Props { project: Project - paused: boolean - onSelectRestore?: () => void - onSelectDelete?: () => void rewriteHref?: string } -const ProjectCard: FC = ({ - project, - paused, - onSelectRestore, - onSelectDelete, - rewriteHref, -}) => { +const ProjectCard: FC = ({ project, rewriteHref }) => { const { name, ref: projectRef } = project const desc = `${project.cloud_provider} | ${project.region}` + const isPausing = project.status === PROJECT_STATUS.GOING_DOWN + const isPaused = project.status === PROJECT_STATUS.INACTIVE + const isRestoring = project.status === PROJECT_STATUS.RESTORING + return (
  • - {name} - {paused && ( -
    - -
    - - Project paused -
    -
    -
    - )} +
    + {name}
    } description={''} footer={ -
    - {desc} - {paused && ( -
    -
    - - -
    +
    + {desc} + {isRestoring ? ( +
    + +
    + + Restoring +
    +
    + ) : isPausing ? ( +
    + +
    + + Pausing +
    +
    +
    + ) : isPaused ? ( +
    + +
    + + Paused +
    +
    +
    + ) : ( + <> )}
    } diff --git a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index 7417a804e2c..3664536aee5 100644 --- a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -1,26 +1,19 @@ import { FC } from 'react' -import { Button, IconLoader, IconPlus, Loading } from '@supabase/ui' +import { Button, IconPlus } from '@supabase/ui' import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { useStore } from 'hooks' import { Organization, Project } from 'types' import { makeRandomString } from 'lib/helpers' -import { PROJECT_STATUS } from 'lib/constants' import ProjectCard from './ProjectCard' import ShimmeringCard from './ShimmeringCard' interface Props { - onSelectRestore: (project: Project) => void - onSelectDelete: (project: Project) => void rewriteHref?: (projectRef: string) => string } -const ProjectList: FC = ({ - onSelectRestore = () => {}, - onSelectDelete = () => {}, - rewriteHref, -}) => { +const ProjectList: FC = ({ rewriteHref }) => { const router = useRouter() const { app } = useStore() const { organizations, projects } = app @@ -38,17 +31,17 @@ const ProjectList: FC = ({

    {name}

    {isLoadingProjects ? ( -
      +
      ) : ( -
        +
          {!isLoadingProjects && isEmpty && ( -
          +

          No projects

          -

          +

          Get started by creating a new project.

          @@ -63,10 +56,7 @@ const ProjectList: FC = ({ onSelectDelete(project)} - onSelectRestore={() => onSelectRestore(project)} /> ))}
        diff --git a/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx b/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx index 04d314f86ca..9b4269d8002 100644 --- a/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx +++ b/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx @@ -17,7 +17,7 @@ const FreeProjectLimitWarning: FC = ({ limit }) => { description={

        - {`Your account can only have up to ${limit} free projects - to create another free project, you'll need to delete an existing free project first. Otherwise, you may create a project on the Pro tier instead.`} + {`Your account can only have up to ${limit} free projects - to create another free project, you'll need to delete or pause an existing free project first. Otherwise, you may create a project on the Pro tier instead.`}

        } diff --git a/studio/components/interfaces/Settings/General/DeleteProjectButton.tsx b/studio/components/interfaces/Settings/General/DeleteProjectButton.tsx new file mode 100644 index 00000000000..1515b85f309 --- /dev/null +++ b/studio/components/interfaces/Settings/General/DeleteProjectButton.tsx @@ -0,0 +1,68 @@ +import { FC, useState } from 'react' +import { useRouter } from 'next/router' +import { Button } from '@supabase/ui' + +import { useStore } from 'hooks' +import { API_URL } from 'lib/constants' +import { delete_ } from 'lib/common/fetch' +import TextConfirmModal from 'components/ui/Modals/TextConfirmModal' +import { Project } from 'types' + +interface Props { + project: Project + type?: 'danger' | 'default' +} + +const DeleteProjectButton: FC = ({ project, type = 'danger' }) => { + const router = useRouter() + const { ui, app } = useStore() + + const [isOpen, setIsOpen] = useState(false) + const [loading, setLoading] = useState(false) + + const toggle = () => { + if (loading) return + setIsOpen(!isOpen) + } + + async function handleDeleteProject() { + setLoading(true) + try { + const response = await delete_(`${API_URL}/projects/${project.ref}`) + if (response.error) throw response.error + app.onProjectDeleted(response) + ui.setNotification({ category: 'success', message: `Successfully deleted ${project.name}` }) + router.push(`/`) + } catch (error: any) { + setLoading(false) + ui.setNotification({ + category: 'error', + message: `Failed to delete project ${project.name}: ${error.message}`, + }) + } + } + + return ( + <> +
        + +
        + + + ) +} + +export default DeleteProjectButton diff --git a/studio/components/interfaces/Settings/General/PauseProjectButton.tsx b/studio/components/interfaces/Settings/General/PauseProjectButton.tsx new file mode 100644 index 00000000000..5d19c10b5e0 --- /dev/null +++ b/studio/components/interfaces/Settings/General/PauseProjectButton.tsx @@ -0,0 +1,65 @@ +import { FC, useState } from 'react' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { Button, IconPause } from '@supabase/ui' + +import { useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' + +interface Props { + projectId: number + projectRef: string +} + +const PauseProjectButton: FC = observer(({ projectRef, projectId }) => { + const { ui, app } = useStore() + const router = useRouter() + const [loading, setLoading] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + + const openModal = () => setIsModalOpen(true) + const closeModal = () => setIsModalOpen(false) + + const requestPauseProject = async () => { + setLoading(true) + const res = await post(`${API_URL}/projects/${projectRef}/pause`, {}) + + if (res.error) { + ui.setNotification({ + error: res.error, + category: 'error', + message: 'Failed to pause project', + }) + setLoading(false) + } else { + app.onProjectPaused(projectId) + app.onProjectStatusUpdated(projectId, PROJECT_STATUS.GOING_DOWN) + + ui.setNotification({ category: 'success', message: 'Pausing project' }) + router.push(`/project/${projectRef}`) + } + closeModal() + } + + return ( + <> + + + + ) +}) + +export default PauseProjectButton diff --git a/studio/components/interfaces/Settings/General/RestartServerButton.tsx b/studio/components/interfaces/Settings/General/RestartServerButton.tsx new file mode 100644 index 00000000000..b981b68d0ad --- /dev/null +++ b/studio/components/interfaces/Settings/General/RestartServerButton.tsx @@ -0,0 +1,63 @@ +import { FC, useState } from 'react' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { Button, IconRefreshCcw } from '@supabase/ui' + +import { useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' + +interface Props { + projectId: number + projectRef: string +} + +const RestartServerButton: FC = observer(({ projectRef, projectId }) => { + const { ui, app } = useStore() + const router = useRouter() + const [loading, setLoading] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + + const openModal = () => setIsModalOpen(true) + const closeModal = () => setIsModalOpen(false) + + const requestServerRestart = async () => { + setLoading(true) + const res = await post(`${API_URL}/projects/${projectRef}/restart`, {}) + + if (res.error) { + ui.setNotification({ + error: res.error, + category: 'error', + message: 'Unable to restart server', + }) + setLoading(false) + } else { + app.onProjectPostgrestStatusUpdated(projectId, 'OFFLINE') + ui.setNotification({ category: 'success', message: 'Restarting server' }) + router.push(`/project/${projectRef}`) + } + closeModal() + } + + return ( + <> + + + + ) +}) + +export default RestartServerButton diff --git a/studio/components/interfaces/Settings/General/index.ts b/studio/components/interfaces/Settings/General/index.ts new file mode 100644 index 00000000000..ad686684336 --- /dev/null +++ b/studio/components/interfaces/Settings/General/index.ts @@ -0,0 +1,6 @@ +import PauseProjectButton from './PauseProjectButton' +import RestartServerButton from './RestartServerButton' + +import DeleteProjectButton from './DeleteProjectButton' + +export { PauseProjectButton, RestartServerButton, DeleteProjectButton } diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 68db0e1ca2a..7dd514c1d54 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -62,12 +62,10 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = {/* Additional breadcrumbs are supplied */}
    -
    +
    {customHeaderComponents && customHeaderComponents} -
    - {IS_PLATFORM && } - {IS_PLATFORM && } -
    + {IS_PLATFORM && } + {IS_PLATFORM && } {IS_PLATFORM && }
    diff --git a/studio/components/layouts/ProjectLayout/PausingState.tsx b/studio/components/layouts/ProjectLayout/PausingState.tsx new file mode 100644 index 00000000000..bcf4e83ea60 --- /dev/null +++ b/studio/components/layouts/ProjectLayout/PausingState.tsx @@ -0,0 +1,46 @@ +import { FC } from 'react' +import { Badge, IconLoader, IconCircle } from '@supabase/ui' +import { Project } from 'types' + +interface Props { + project: Project +} + +const PausingState: FC = ({ project }) => { + return ( +
    +
    +
    +

    {project.name}

    +
    + +
    + + Pausing project +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + +
    +

    Pausing {project.name}

    +

    + You may restore your project anytime thereafter, and your data will be restored to + when it was initially paused. +

    +
    +
    +
    +
    +
    + ) +} + +export default PausingState diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 366bff4e7ed..15b9292cd35 100644 --- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -10,6 +10,7 @@ import NavigationBar from './NavigationBar/NavigationBar' import ProductMenuBar from './ProductMenuBar' import LayoutHeader from './LayoutHeader' import ConnectingState from './ConnectingState' +import PausingState from './PausingState' import BuildingState from './BuildingState' interface Props { @@ -110,13 +111,15 @@ const ContentWrapper: FC = observer(({ isLoading, children const isProjectBuilding = [PROJECT_STATUS.COMING_UP, PROJECT_STATUS.RESTORING].includes( ui.selectedProject?.status ?? '' ) - + const isProjectPausing = ui.selectedProject?.status === PROJECT_STATUS.GOING_DOWN const isProjectOffline = ui.selectedProject?.postgrestStatus === 'OFFLINE' return ( <> {isLoading || ui.selectedProject === undefined ? ( + ) : isProjectPausing ? ( + ) : requiresPostgrestConnection && isProjectOffline ? ( ) : requiresDbConnection && isProjectBuilding ? ( diff --git a/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx new file mode 100644 index 00000000000..057db902983 --- /dev/null +++ b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx @@ -0,0 +1,87 @@ +import { FC, useState } from 'react' +import { Alert, Button, IconPauseCircle } from '@supabase/ui' + +import { Project } from 'types' +import { useStore, useSubscriptionStats } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS, DEFAULT_FREE_PROJECTS_LIMIT } from 'lib/constants' +import { DeleteProjectButton } from 'components/interfaces/Settings/General' +import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' + +interface Props { + project: Project +} + +const ProjectPausedState: FC = ({ project }) => { + const { ui, app } = useStore() + const subscriptionStats = useSubscriptionStats() + const { total_active_free_projects: totalActiveFreeProjects } = subscriptionStats + const freeProjectsLimit = ui.profile?.free_project_limit ?? DEFAULT_FREE_PROJECTS_LIMIT + + const [showConfirmRestore, setShowConfirmRestore] = useState(false) + const hasExceedActiveFreeProjectsLimit = totalActiveFreeProjects >= freeProjectsLimit + + const onConfirmRestore = async () => { + await post(`${API_URL}/projects/${project.ref}/restore`, {}) + app.onProjectUpdated({ ...project, status: PROJECT_STATUS.RESTORING }) + ui.setNotification({ category: 'success', message: 'Restoring project' }) + } + + return ( + <> +
    + {hasExceedActiveFreeProjectsLimit && ( +
    + + To restore this project you'll need to pause or delete an existing free project. + +
    + )} +
    +
    +
    +
    + +
    + +

    This project is paused.

    + +
    + + +
    + + {!hasExceedActiveFreeProjectsLimit && ( +

    + Restore this project and get back to building the next big thing! +

    + )} +
    +
    +
    +
    + setShowConfirmRestore(false)} + onSelectConfirm={onConfirmRestore} + /> + + ) +} + +export default ProjectPausedState diff --git a/studio/components/ui/CardButton.tsx b/studio/components/ui/CardButton.tsx index 2fff53a667a..afeeff659fd 100644 --- a/studio/components/ui/CardButton.tsx +++ b/studio/components/ui/CardButton.tsx @@ -27,7 +27,7 @@ const CardButton: FC = ({ imgAlt, onClick, icon, - containerHeightClassName="h-32" + containerHeightClassName = 'h-32', }) => { const LinkContainer = ({ children }: { children: React.ReactNode }) => ( @@ -48,7 +48,7 @@ const CardButton: FC = ({ 'border border-panel-border-light dark:border-panel-border-dark', 'rounded-md py-4 px-6 flex flex-row', 'transition ease-in-out duration-150', - containerHeightClassName + containerHeightClassName, ] if (isLink) { @@ -62,7 +62,7 @@ const CardButton: FC = ({ } const ImageContainer = ({ children }: { children: React.ReactNode }) => { - return
    {children}
    + return
    {children}
    } const contents = ( @@ -81,9 +81,9 @@ const CardButton: FC = ({ )} {icon && {icon}} -
    +
    {title}
    -
    +

    {description}

    {children && children}
    @@ -92,14 +92,14 @@ const CardButton: FC = ({ {isLink && (
    diff --git a/studio/hooks/queries/useSubscriptionStats.ts b/studio/hooks/queries/useSubscriptionStats.ts index 3557413d527..e109b160077 100644 --- a/studio/hooks/queries/useSubscriptionStats.ts +++ b/studio/hooks/queries/useSubscriptionStats.ts @@ -5,6 +5,8 @@ import { API_URL } from 'lib/constants' export interface SubscriptionStats { total_paid_projects: number total_free_projects: number + total_active_free_projects: number + total_paused_free_projects: number total_pro_projects: number total_payg_projects: number } @@ -17,6 +19,8 @@ export function useSubscriptionStats() { return { total_paid_projects: data?.total_paid_projects ?? 0, total_free_projects: data?.total_free_projects ?? 0, + total_active_free_projects: data?.total_active_free_projects ?? 0, + total_paused_free_projects: data?.total_paused_free_projects ?? 0, total_pro_projects: data?.total_pro_projects ?? 0, total_payg_projects: data?.total_payg_projects ?? 0, isLoading: !anyError && !data, diff --git a/studio/pages/index.tsx b/studio/pages/index.tsx index d2b39ce06fe..542247844aa 100644 --- a/studio/pages/index.tsx +++ b/studio/pages/index.tsx @@ -1,56 +1,20 @@ -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { observer } from 'mobx-react-lite' import { NextRouter, useRouter } from 'next/router' -import { isUndefined } from 'lodash' -import { Typography } from '@supabase/ui' -import { NextPageWithLayout, Project } from 'types' +import { NextPageWithLayout } from 'types' import { useStore, withAuth } from 'hooks' import { auth } from 'lib/gotrue' -import { post, delete_ } from 'lib/common/fetch' -import { API_URL, IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { IS_PLATFORM } from 'lib/constants' import Connecting from 'components/ui/Loading' import { AccountLayoutWithoutAuth } from 'components/layouts' import Landing from 'components/interfaces/Home/Landing' import ProjectList from 'components/interfaces/Home/ProjectList' import OrganizationDropdown from 'components/to-be-cleaned/Dropdown/OrganizationDropdown' -import TextConfirmModal from 'components/ui/Modals/TextConfirmModal' const Home: NextPageWithLayout = () => { - const { app, ui } = useStore() - - const router = useRouter() - - const [isDeletingProject, setIsDeletingProject] = useState(false) - const [selectedProjectToDelete, setSelectedProjectToDelete] = useState() - - const onSelectDeleteProject = async (project: Project) => { - setSelectedProjectToDelete(project) - setIsDeletingProject(false) - } - - const onDeleteProject = async (project?: Project) => { - if (!project) return - setIsDeletingProject(true) - const response = await delete_(`${API_URL}/projects/${project.ref}`) - - if (response.error) { - return ui.setNotification({ category: 'error', message: response.error.message }) - } - - app.onProjectDeleted(response) - ui.setNotification({ category: 'success', message: `Deleted ${project.name} successfully!` }) - - setIsDeletingProject(false) - setSelectedProjectToDelete(undefined) - } - - const onRestoreProject = async (project: Project) => { - app.onProjectUpdated({ ...project, status: PROJECT_STATUS.RESTORING }) - await post(`${API_URL}/projects/${project.ref}/restore`, {}) - router.push(`/project/${project.ref}`) - } + const { app } = useStore() return ( <> @@ -70,32 +34,10 @@ const Home: NextPageWithLayout = () => {
    )}
    - +
    )} - - This action cannot be undone. This will - permanently delete the{' '} - {selectedProjectToDelete?.name} project and - all of its data. - - } - loading={isDeletingProject} - confirmLabel="Confirm delete" - confirmPlaceholder="Type in name of project" - confirmString={selectedProjectToDelete?.name ?? ''} - onCancel={() => setSelectedProjectToDelete(undefined)} - onConfirm={() => onDeleteProject(selectedProjectToDelete)} - /> ) } diff --git a/studio/pages/new/[slug].tsx b/studio/pages/new/[slug].tsx index 5c166f77741..c32a1820e91 100644 --- a/studio/pages/new/[slug].tsx +++ b/studio/pages/new/[slug].tsx @@ -59,7 +59,7 @@ const Wizard: NextPageWithLayout = () => { const currentOrg = organizations.find((o: any) => o.slug === slug) const stripeCustomerId = currentOrg?.stripe_customer_id - const totalFreeProjects = subscriptionStats.total_free_projects + const totalFreeProjects = subscriptionStats.total_active_free_projects const freeProjectsLimit = ui.profile?.free_project_limit ?? DEFAULT_FREE_PROJECTS_LIMIT const isOrganizationOwner = currentOrg?.is_owner || !app.organizations.isInitialized diff --git a/studio/pages/project/[ref]/index.tsx b/studio/pages/project/[ref]/index.tsx index f71f809245d..b62411ce8c8 100644 --- a/studio/pages/project/[ref]/index.tsx +++ b/studio/pages/project/[ref]/index.tsx @@ -1,17 +1,19 @@ import { observer } from 'mobx-react-lite' import { Typography } from '@supabase/ui' + import { useStore } from 'hooks' +import { NextPageWithLayout } from 'types' +import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { ProjectLayoutWithAuth } from 'components/layouts' import { ExampleProject, ClientLibrary } from 'components/interfaces/Home' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' -import { IS_PLATFORM } from 'lib/constants' -import { ProjectLayoutWithAuth } from 'components/layouts' import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' -import { NextPageWithLayout } from 'types' +import ProjectPausedState from 'components/layouts/ProjectLayout/ProjectPausedState' const Home: NextPageWithLayout = () => { const { ui } = useStore() - const project = ui.selectedProject + const projectName = project?.ref !== 'default' && project?.name !== undefined ? project?.name @@ -22,7 +24,11 @@ const Home: NextPageWithLayout = () => {

    {projectName}

    - {IS_PLATFORM && } + + {project?.status === PROJECT_STATUS.INACTIVE && } + + {IS_PLATFORM && project?.status !== PROJECT_STATUS.INACTIVE && } +
    Client libraries diff --git a/studio/pages/project/[ref]/settings/general.tsx b/studio/pages/project/[ref]/settings/general.tsx index 7b91c74a96c..a696d037af6 100644 --- a/studio/pages/project/[ref]/settings/general.tsx +++ b/studio/pages/project/[ref]/settings/general.tsx @@ -1,21 +1,23 @@ -import { FC, useState } from 'react' -import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { toJS } from 'mobx' import { projects } from 'stores/jsonSchema' import { AutoField } from 'uniforms-bootstrap4' -import { Alert, Button, Input, IconRefreshCcw } from '@supabase/ui' +import { Alert, Input } from '@supabase/ui' -import { API_URL } from 'lib/constants' -import { pluckJsonSchemaFields, pluckObjectFields } from 'lib/helpers' -import { post, delete_ } from 'lib/common/fetch' -import { useStore } from 'hooks' -import { SettingsLayout } from 'components/layouts' -import Panel from 'components/ui/Panel' -import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' -import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' -import TextConfirmModal from 'components/ui/Modals/TextConfirmModal' +import { useFlag, useStore } from 'hooks' import { NextPageWithLayout } from 'types' +import { post } from 'lib/common/fetch' +import { API_URL, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' +import { pluckJsonSchemaFields, pluckObjectFields } from 'lib/helpers' +import { SettingsLayout } from 'components/layouts' +import { + RestartServerButton, + PauseProjectButton, + DeleteProjectButton, +} from 'components/interfaces/Settings/General' + +import Panel from 'components/ui/Panel' +import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' const ProjectSettings: NextPageWithLayout = () => { return ( @@ -33,58 +35,16 @@ ProjectSettings.getLayout = (page) => {page} = observer(({ projectRef, projectId }) => { - const { ui, app } = useStore() - const router = useRouter() - const [loading, setLoading] = useState(false) - const [isModalOpen, setIsModalOpen] = useState(false) - - const openModal = () => setIsModalOpen(true) - const closeModal = () => setIsModalOpen(false) - - const requestServerRestart = async () => { - setLoading(true) - try { - await post(`${API_URL}/projects/${projectRef}/restart`, {}) - app.onProjectPostgrestStatusUpdated(projectId, 'OFFLINE') - ui.setNotification({ category: 'success', message: 'Restarting server' }) - router.push(`/project/${projectRef}`) - } catch (error) { - ui.setNotification({ error, category: 'error', message: 'Unable to restart server' }) - setLoading(false) - } - closeModal() - } - - return ( - <> - - - - ) -}) - const GeneralSettings = observer(() => { const { app, ui } = useStore() + const isProjectPauseEnabled = useFlag('projectPausing') + const project = ui.selectedProject const formModel = toJS(project) const BASIC_FIELDS = ['name'] + const isFreeProject = project?.subscription_tier === PRICING_TIER_PRODUCT_IDS.FREE + const handleUpdateProject = async (model: any) => { const response = await post(`${API_URL}/projects/${project?.ref}/update`, model) if (response.error) { @@ -136,8 +96,8 @@ const GeneralSettings = observer(() => {

    Restart server

    -
    -

    +

    +

    Your project will not be available for a few minutes.

    @@ -145,79 +105,44 @@ const GeneralSettings = observer(() => { {project && }
    + {isProjectPauseEnabled && isFreeProject && ( + +
    +
    +

    Pause project

    +
    +

    + Your project will not be accessible while it is paused. +

    +
    +
    + {project && } +
    +
    + )} -
    - Danger Zone

    }> - - -
    -

    - Make sure you have made a backup if you want to keep your data. -

    - -
    -
    -
    -
    -
    + {project !== undefined && ( +
    + Danger Zone

    }> + + +
    +

    + Make sure you have made a backup if you want to keep your data. +

    + +
    +
    +
    +
    +
    + )} ) }) - -const ProjectDeleteModal = ({ project }: any) => { - const router = useRouter() - const { ui, app } = useStore() - - const [isOpen, setIsOpen] = useState(false) - const [loading, setLoading] = useState(false) - - const toggle = () => { - if (loading) return - setIsOpen(!isOpen) - } - - async function handleDeleteProject() { - setLoading(true) - try { - const response = await delete_(`${API_URL}/projects/${project.ref}`) - if (response.error) throw response.error - app.onProjectDeleted(response) - ui.setNotification({ category: 'success', message: `Successfully deleted ${project.name}` }) - router.push(`/`) - } catch (error: any) { - setLoading(false) - ui.setNotification({ - category: 'error', - message: `Failed to delete project ${project.name}: ${error.message}`, - }) - } - } - - return ( - <> -
    - -
    - - - ) -} diff --git a/studio/pages/project/_/[[...routeSlug]].tsx b/studio/pages/project/_/[[...routeSlug]].tsx index b18f770153a..c63e4e74c9d 100644 --- a/studio/pages/project/_/[[...routeSlug]].tsx +++ b/studio/pages/project/_/[[...routeSlug]].tsx @@ -11,14 +11,14 @@ interface Props {} const Header: FC = () => { return ( -
    -
    +
    +
    Supabase @@ -50,14 +50,10 @@ const GenericProjectPage: NextPage = () => { return (
    -
    +

    Select a project to continue

    - {}} - onSelectRestore={() => {}} - /> +
    diff --git a/studio/stores/app/AppStore.ts b/studio/stores/app/AppStore.ts index e66507d00af..6b436e9ddb9 100644 --- a/studio/stores/app/AppStore.ts +++ b/studio/stores/app/AppStore.ts @@ -2,7 +2,8 @@ import { cloneDeep } from 'lodash' import { values } from 'mobx' import { Organization, Project } from 'types' -import { API_URL } from 'lib/constants' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { getWithTimeout } from 'lib/common/fetch' import { IRootStore } from '../RootStore' import DatabaseStore, { IDatabaseStore } from './DatabaseStore' import OrganizationStore from './OrganizationStore' @@ -12,9 +13,10 @@ export interface IAppStore { projects: IProjectStore organizations: OrganizationStore database: IDatabaseStore - onProjectCreated: (project: any) => void - onProjectUpdated: (project: any) => void - onProjectDeleted: (project: any) => void + onProjectCreated: (project: Project) => void + onProjectUpdated: (project: Project) => void + onProjectDeleted: (project: Project) => void + onProjectPaused: (projectId: number) => void onProjectStatusUpdated: (projectId: number, value: string) => void onProjectPostgrestStatusUpdated: (projectId: number, value: 'OFFLINE' | 'ONLINE') => void onOrgAdded: (org: any) => void @@ -76,6 +78,24 @@ export default class AppStore implements IAppStore { } } + // At global store level so that it can continue checking while the user + // is doing something else outside of the project page + async onProjectPaused(projectId: number) { + const checkProjectInactive = async () => { + const projectRef = this.projects.data[projectId]?.ref + const projectStatus = await getWithTimeout(`${API_URL}/projects/${projectRef}/status`, { + timeout: 2000, + }) + if (projectStatus.status === PROJECT_STATUS.INACTIVE) { + this.onProjectStatusUpdated(projectId, PROJECT_STATUS.INACTIVE) + } else { + setTimeout(() => checkProjectInactive(), 5000) + } + } + + setTimeout(() => checkProjectInactive(), 5000) + } + onProjectStatusUpdated(projectId: number, value: string) { const clone = cloneDeep(this.projects.data[projectId]) clone.status = value diff --git a/studio/stores/app/ProjectStore.ts b/studio/stores/app/ProjectStore.ts index d3504b75cb7..6c972a972ac 100644 --- a/studio/stores/app/ProjectStore.ts +++ b/studio/stores/app/ProjectStore.ts @@ -1,4 +1,4 @@ -import { cloneDeep } from 'lodash' +import { cloneDeep, keyBy } from 'lodash' import { Project } from 'types' import { IRootStore } from '../RootStore' import { constructHeaders } from 'lib/api/apiHelpers' @@ -24,6 +24,26 @@ export default class ProjectStore extends PostgresMetaInterface { super(rootStore, dataUrl, headers, options) } + // [Joshen] Method override here due to race condition between projects.load() and fetchDetail() + // There's a race condition that can happen here when these 2 methods are called, such that if + // fetchDetail() completes before projects.load() completes, the response from project.loads() + // then overrides what fetchDetail() has already populated into the store - causing parameters like + // connectionString to become undefined and hence causing the dashboard to stay in a Connecting state. + // You can reproduce this by giving a setTimeout to project.loads under withAuth.tsx + + // This thus ensures that the response from project.loads() do not override projects which are already + // loaded in the store from a successful fetchDetail() - This should be fine as project.loads() is only + // called once in this whole app under withAuth.tsx + setDataArray(value: any[]) { + const formattedValue = keyBy(value, this.identifier) + Object.keys(this.data).forEach((projectId) => { + if (formattedValue[projectId] !== undefined) { + formattedValue[projectId] = this.data[projectId] + } + }) + this.data = formattedValue + } + async fetchDetail(projectRef: string) { const url = `${this.url}/${projectRef}` const headers = constructHeaders(this.headers) diff --git a/web/README.md b/web/README.md index d2f4805382e..0df143b8749 100644 --- a/web/README.md +++ b/web/README.md @@ -2,13 +2,12 @@ ### Contributing to the specs -- `cd /web/spec/` +- `cd ./web/spec/` - run `npm install` - run `make` which will pull the latest TSDoc with your updates > Note: `make` requires [`jq - a lightweight and flexible command-line JSON processor`](https://stedolan.github.io/jq/). To install on OSX with homebrew, run: `brew install jq`. -- Add your comments to `supabase.yml` | `dart.yml` | `postgrest.yml` | `gotrue.yml` | `realtime.yml` -- run `npm run gen:supabase` inside the `/spec` folder which will generate the Docusaurus pages -- run `npm run build` to make sure the build is working +- Apply your updates to `supabase.yml` | `dart.yml` | `postgrest.yml` | `gotrue.yml` | `realtime.yml` +- run `npm run build && npm run start` to make sure the build is working - Submit your PR diff --git a/web/docs/guides/database/functions.mdx b/web/docs/guides/database/functions.mdx index 6ad3052330f..507643e7a31 100644 --- a/web/docs/guides/database/functions.mdx +++ b/web/docs/guides/database/functions.mdx @@ -86,7 +86,7 @@ select hello_world(); ```js -const { data, error } = supabase +const { data, error } = await supabase .rpc('hello_world') ``` diff --git a/web/sidebar_spec_cli.js b/web/sidebar_spec_cli.js index dc32a20f237..3d193c022b2 100644 --- a/web/sidebar_spec_cli.js +++ b/web/sidebar_spec_cli.js @@ -8,8 +8,8 @@ module.exports = { }, { type: 'category', - label: 'Command reference', - items: ['reference/cli/supabase-help', 'reference/cli/supabase-login', 'reference/cli/supabase-link', 'reference/cli/supabase-init', 'reference/cli/supabase-start', 'reference/cli/supabase-stop', 'reference/cli/supabase-db-branch-list', 'reference/cli/supabase-db-branch-create', 'reference/cli/supabase-db-branch-delete', 'reference/cli/supabase-db-switch', 'reference/cli/supabase-db-changes', 'reference/cli/supabase-db-commit', 'reference/cli/supabase-db-reset', 'reference/cli/supabase-db-remote-set', 'reference/cli/supabase-db-remote-commit', 'reference/cli/supabase-db-push', 'reference/cli/supabase-functions-delete', 'reference/cli/supabase-functions-deploy', 'reference/cli/supabase-functions-new', 'reference/cli/supabase-functions-serve', 'reference/cli/supabase-migration-new', 'reference/cli/supabase-secrets-list', 'reference/cli/supabase-secrets-set', 'reference/cli/supabase-secrets-unset'], + label: 'Command Reference', + items: ['reference/cli/supabase-help', 'reference/cli/supabase-login', 'reference/cli/supabase-link', 'reference/cli/supabase-init', 'reference/cli/supabase-start', 'reference/cli/supabase-stop', 'reference/cli/supabase-db-branch-list', 'reference/cli/supabase-db-branch-create', 'reference/cli/supabase-db-branch-delete', 'reference/cli/supabase-db-switch', 'reference/cli/supabase-db-changes', 'reference/cli/supabase-db-commit', 'reference/cli/supabase-db-reset', 'reference/cli/supabase-db-remote-set', 'reference/cli/supabase-db-remote-commit', 'reference/cli/supabase-db-push', 'reference/cli/supabase-functions-delete', 'reference/cli/supabase-functions-deploy', 'reference/cli/supabase-functions-new', 'reference/cli/supabase-functions-serve', 'reference/cli/supabase-migration-new', 'reference/cli/supabase-orgs-list', 'reference/cli/supabase-projects-list', 'reference/cli/supabase-projects-create', 'reference/cli/supabase-secrets-list', 'reference/cli/supabase-secrets-set', 'reference/cli/supabase-secrets-unset'], collapsed: true, } ], diff --git a/web/spec/cli.yml b/web/spec/cli.yml index ae530c35d84..46acfa810ce 100644 --- a/web/spec/cli.yml +++ b/web/spec/cli.yml @@ -39,8 +39,8 @@ info: items: - index - Installing and Updating - - Config reference - - name: 'Command reference' + - Config Reference + - name: 'Command Reference' items: - supabase help - supabase login @@ -63,6 +63,9 @@ info: - supabase functions new - supabase functions serve - supabase migration new + - supabase orgs list + - supabase projects list + - supabase projects create - supabase secrets list - supabase secrets set - supabase secrets unset @@ -315,6 +318,58 @@ pages: supabase migration new ``` + supabase orgs list: + description: | + List all organizations. + examples: + - name: Usage + sh: | + ```sh + supabase orgs list + ``` + + supabase projects list: + description: | + List all projects. + examples: + - name: Usage + sh: | + ```sh + supabase projects list + ``` + + supabase projects create: + description: | + Create a new project. + + Flags: + + `--org-id`: Organization ID to create the project in. + + `--db-password`: Database password of the project. + + `--region`: Select a region close to you for the best performance. Allowed values: + - `ap-northeast-1` + - `ap-northeast-2` + - `ap-south-1` + - `ap-southeast-1` + - `ap-southeast-2` + - `ca-central-1` + - `eu-central-1` + - `eu-west-1` + - `eu-west-2` + - `sa-east-1` + - `us-east-1` + - `us-west-1` + + `--plan`: Select a plan that suits your needs. Allowed values: `free`, `pro`. + examples: + - name: Usage + sh: | + ```sh + supabase projects create my-project --org-id cool-green-pqdr0qc --db-password ******** --region us-east-1 + ``` + supabase secrets list: description: | List all secrets in the linked project. @@ -416,7 +471,7 @@ pages: - `sudo dpkg -i <...>.deb` - `sudo rpm -i <...>.rpm` - Config reference: + Config Reference: description: | A `config.toml` file is generated after running `supabase init`.