diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 326d9bbe68e..3a0ea112408 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -59,16 +59,16 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
-

The project "{project?.name}" is currently paused

+

Project "{project?.name}" is paused

{isLoading && } {isPauseStatusSuccess && !isRestoreDisabled ? ( isFreePlan ? ( - <> -

- All data, including backups and storage objects, remains safe. You can resume - this project from the dashboard within{' '} +

    +
  • All data, including backups and storage objects, remains safe.
  • +
  • + You can resume this project from the dashboard within{' '} @@ -91,20 +91,42 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { className="text-sm text-foreground" labelFormat="DD MMM YYYY" /> - ). After that, this project will not be resumable, but data will still be + ). +
  • +
  • + After that, this project will not be resumable, but data will still be available for download. -

    -

    +

  • +
  • {enableProBenefitWording === 'variant-a' ? 'Upgrade to Pro to prevent pauses and unlock features like branching, compute upgrades, and daily backups.' : 'To prevent future pauses, consider upgrading to Pro.'} +
  • +
+ ) : ( + <> +

+ Your project data is safe and available for{' '} + + {finalDaysRemainingBeforeRestoreDisabled} day + {finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''} + {' '} + (until{' '} + + ), but inaccessible while paused. +

+

+ Once resumed, usage will be billed by compute size and hours active.

- ) : ( -

- Your project data is safe but inaccessible while paused. Once resumed, usage - will be billed by compute size and hours active. -

) ) : !isLoading ? (

diff --git a/apps/studio/components/ui/DevToolbar/ProjectStatusTab.tsx b/apps/studio/components/ui/DevToolbar/ProjectStatusTab.tsx new file mode 100644 index 00000000000..e31ebe9c9de --- /dev/null +++ b/apps/studio/components/ui/DevToolbar/ProjectStatusTab.tsx @@ -0,0 +1,184 @@ +'use client' + +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' +import { useEffect, useState } from 'react' +import { cn, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'ui' + +import { projectKeys } from '@/data/projects/keys' +import { useSetProjectStatus, type Project } from '@/data/projects/project-detail-query' +import { + clearPauseStatusOverride, + getPauseStatusOverride, + setPauseStatusOverride, + type PauseStateOverride, +} from '@/data/projects/project-pause-status-override' +import { + clearProjectStatusOverride, + getProjectStatusOverride, + setProjectStatusOverride, +} from '@/data/projects/project-status-override' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from '@/lib/constants' + +type ProjectStatus = Project['status'] + +const STATUS_LABELS: Record = { + INACTIVE: 'Paused', + ACTIVE_HEALTHY: 'Active (healthy)', + ACTIVE_UNHEALTHY: 'Active (unhealthy)', + COMING_UP: 'Coming up', + UNKNOWN: 'Unknown', + GOING_DOWN: 'Going down', + INIT_FAILED: 'Init failed', + REMOVED: 'Removed', + RESTARTING: 'Restarting', + RESTORING: 'Restoring', + RESTORE_FAILED: 'Restore failed', + UPGRADING: 'Upgrading', + PAUSING: 'Pausing', + PAUSE_FAILED: 'Pause failed', + RESIZING: 'Resizing', +} + +const STATUS_OPTIONS = Object.values(PROJECT_STATUS) + +const PAUSE_STATE_VALUE_REAL = 'real' + +const PAUSE_STATE_OPTIONS: { + value: PauseStateOverride | typeof PAUSE_STATE_VALUE_REAL + label: string +}[] = [ + { value: PAUSE_STATE_VALUE_REAL, label: 'Real data' }, + { value: 'restorable', label: 'Restorable (can resume)' }, + { value: 'restore-disabled', label: 'Restore disabled (90+ days)' }, +] + +export const ProjectStatusTab = () => { + const { ref } = useParams() + const queryClient = useQueryClient() + const { setProjectStatus } = useSetProjectStatus() + const { data: project } = useSelectedProjectQuery() + const { data: selectedOrg } = useSelectedOrganizationQuery() + const orgSlug = selectedOrg?.slug + + const [statusOverride, setStatusOverride] = useState(undefined) + const [pauseOverride, setPauseOverride] = useState(undefined) + useEffect(() => { + setStatusOverride(getProjectStatusOverride(ref)) + setPauseOverride(getPauseStatusOverride(ref)) + }, [ref]) + + const currentStatus = statusOverride ?? project?.status + const isPaused = currentStatus === PROJECT_STATUS.INACTIVE + const hasOverride = statusOverride !== undefined || pauseOverride !== undefined + + const refetchProjectStatus = () => { + queryClient.invalidateQueries({ queryKey: projectKeys.detail(ref) }) + queryClient.invalidateQueries({ queryKey: projectKeys.infiniteList() }) + if (orgSlug) { + queryClient.invalidateQueries({ queryKey: projectKeys.infiniteListByOrg(orgSlug) }) + } + } + + const handleStatusChange = (status: ProjectStatus) => { + if (!ref) return + setProjectStatusOverride(ref, status) + setStatusOverride(status) + setProjectStatus({ ref, slug: orgSlug, status }) + } + + const handlePauseStateChange = (value: PauseStateOverride | typeof PAUSE_STATE_VALUE_REAL) => { + if (!ref) return + if (value === PAUSE_STATE_VALUE_REAL) { + clearPauseStatusOverride(ref) + setPauseOverride(undefined) + } else { + setPauseStatusOverride(ref, value) + setPauseOverride(value) + } + queryClient.invalidateQueries({ queryKey: projectKeys.pauseStatus(ref) }) + } + + const handleReset = () => { + if (ref) { + clearProjectStatusOverride(ref) + clearPauseStatusOverride(ref) + } + setStatusOverride(undefined) + setPauseOverride(undefined) + refetchProjectStatus() + queryClient.invalidateQueries({ queryKey: projectKeys.pauseStatus(ref) }) + } + + const isDisabled = !ref + + return ( +

+
+

+ Override the status of the current project. Overrides persist across refetches until + reset. +

+ +
+ + {isDisabled && ( +

Navigate to a project page to use this tab.

+ )} + +
+
+ Status + +
+ +
+
+ Pause state + Applies when status is Paused +
+ +
+
+
+ ) +} diff --git a/apps/studio/data/projects/project-detail-query.ts b/apps/studio/data/projects/project-detail-query.ts index 315ac08a17a..6bb81111f0c 100644 --- a/apps/studio/data/projects/project-detail-query.ts +++ b/apps/studio/data/projects/project-detail-query.ts @@ -5,6 +5,7 @@ import { replicaKeys } from '../read-replicas/keys' import { ReadReplicasData } from '../read-replicas/replicas-query' import { projectKeys } from './keys' import { OrgProjectsResponse } from './org-projects-infinite-query' +import { getProjectStatusOverride } from './project-status-override' import type { components } from '@/data/api' import { get, handleError, isValidConnString, post } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' @@ -78,7 +79,13 @@ export async function getProjectDetail( } if (error) handleError(error) - return { ...data, connectionString: connectionString } as Project + + const project = { ...data, connectionString: connectionString } as Project + + const statusOverride = getProjectStatusOverride(ref) + if (statusOverride) project.status = statusOverride + + return project } export type ProjectDetailData = Awaited> diff --git a/apps/studio/data/projects/project-pause-status-override.ts b/apps/studio/data/projects/project-pause-status-override.ts new file mode 100644 index 00000000000..0277594c14f --- /dev/null +++ b/apps/studio/data/projects/project-pause-status-override.ts @@ -0,0 +1,58 @@ +import type { components } from '@/data/api' + +type PauseStatusResponse = components['schemas']['PauseStatusResponse'] + +export type PauseStateOverride = 'restorable' | 'restore-disabled' + +const STORAGE_KEY = 'devToolbar:pauseStatusOverrides' + +const IS_ENABLED = + process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + +function readOverrides(): Record { + if (!IS_ENABLED || typeof window === 'undefined') return {} + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +function writeOverrides(overrides: Record) { + if (typeof window === 'undefined') return + if (Object.keys(overrides).length > 0) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)) + } else { + window.localStorage.removeItem(STORAGE_KEY) + } +} + +export function getPauseStatusOverride(ref: string | undefined): PauseStateOverride | undefined { + if (!IS_ENABLED || !ref) return undefined + return readOverrides()[ref] +} + +export function setPauseStatusOverride(ref: string, override: PauseStateOverride) { + if (!IS_ENABLED) return + writeOverrides({ ...readOverrides(), [ref]: override }) +} + +export function clearPauseStatusOverride(ref: string) { + if (!IS_ENABLED) return + const overrides = readOverrides() + delete overrides[ref] + writeOverrides(overrides) +} + +export function buildPauseStatus(override: PauseStateOverride): PauseStatusResponse { + const isRestorable = override === 'restorable' + return { + can_restore: isRestorable, + last_paused_on: null, + latest_downloadable_backup_id: null, + max_days_till_restore_disabled: 90, + remaining_days_till_restore_disabled: isRestorable ? 78 : 0, + } +} diff --git a/apps/studio/data/projects/project-pause-status-query.ts b/apps/studio/data/projects/project-pause-status-query.ts index 63bd905b01e..540cf3ef279 100644 --- a/apps/studio/data/projects/project-pause-status-query.ts +++ b/apps/studio/data/projects/project-pause-status-query.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { projectKeys } from './keys' +import { buildPauseStatus, getPauseStatusOverride } from './project-pause-status-override' import { get, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' @@ -12,6 +13,9 @@ export async function getProjectPausedStatus( ) { if (!ref) throw new Error('Project ref is required') + const override = getPauseStatusOverride(ref) + if (override) return buildPauseStatus(override) + const { data, error } = await get('/platform/projects/{ref}/pause/status', { params: { path: { ref } }, signal, diff --git a/apps/studio/data/projects/project-status-override.ts b/apps/studio/data/projects/project-status-override.ts new file mode 100644 index 00000000000..ca5d49083ac --- /dev/null +++ b/apps/studio/data/projects/project-status-override.ts @@ -0,0 +1,45 @@ +import type { components } from '@/data/api' + +type ProjectStatus = components['schemas']['ProjectDetailResponse']['status'] + +const STORAGE_KEY = 'devToolbar:projectStatusOverrides' + +const IS_ENABLED = + process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + +function readOverrides(): Record { + if (!IS_ENABLED || typeof window === 'undefined') return {} + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +function writeOverrides(overrides: Record) { + if (typeof window === 'undefined') return + if (Object.keys(overrides).length > 0) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)) + } else { + window.localStorage.removeItem(STORAGE_KEY) + } +} + +export function getProjectStatusOverride(ref: string | undefined): ProjectStatus | undefined { + if (!IS_ENABLED || !ref) return undefined + return readOverrides()[ref] +} + +export function setProjectStatusOverride(ref: string, status: ProjectStatus) { + if (!IS_ENABLED) return + writeOverrides({ ...readOverrides(), [ref]: status }) +} + +export function clearProjectStatusOverride(ref: string) { + if (!IS_ENABLED) return + const overrides = readOverrides() + delete overrides[ref] + writeOverrides(overrides) +} diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 28836c79172..b4559f72dbd 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -81,8 +81,17 @@ const ResourceWarningsTab = IS_DEV_TOOLBAR_ENABLED ) : () => null +const ProjectStatusTab = IS_DEV_TOOLBAR_ENABLED + ? dynamic(() => + import('@/components/ui/DevToolbar/ProjectStatusTab').then((m) => m.ProjectStatusTab) + ) + : () => null + const devToolbarExtraTabs: ExtraTab[] = IS_DEV_TOOLBAR_ENABLED - ? [{ id: 'warnings', label: 'Warnings', content: }] + ? [ + { id: 'warnings', label: 'Warnings', content: }, + { id: 'project-status', label: 'Project Status', content: }, + ] : [] const FeatureFlagProviderWithOrgContext = ({ diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index 3e99c476146..dd80a116015 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -122,6 +122,14 @@ const ResourceWarningsTab = IS_DEV_TOOLBAR_ENABLED ) : () => null +const ProjectStatusTab = IS_DEV_TOOLBAR_ENABLED + ? lazy(() => + import('@/components/ui/DevToolbar/ProjectStatusTab').then((m) => ({ + default: m.ProjectStatusTab, + })) + ) + : () => null + const devToolbarExtraTabs: ExtraTab[] = IS_DEV_TOOLBAR_ENABLED ? [ { @@ -133,6 +141,15 @@ const devToolbarExtraTabs: ExtraTab[] = IS_DEV_TOOLBAR_ENABLED ), }, + { + id: 'project-status', + label: 'Project Status', + content: ( + + + + ), + }, ] : []