+
{!hideSidebar && !noContent && (
{title && (
- = ({ links, subitems, subitemsParentKey }) => {
return links.map((x: any) => (
-
+
{title}
@@ -74,9 +76,9 @@ const WithSidebar: FC = ({
)}
-
+
-
)
@@ -85,10 +87,10 @@ export default WithSidebar
const LinksWithHeaders: FC{children}
+ {children}
+
{x.versionLabel && (
-
+
{x.versionLabel}
)}
@@ -156,7 +158,7 @@ const SidebarItem: FC = ({ id, label, href, isActive, isSubitem, onClick, e
onClick={onClick || (() => {})}
icon={external && }
>
- {isSubitem ? {label} : label}
+ {isSubitem ? = ({ id, label, href, isActive, isSubitem, onClick, e
diff --git a/studio/components/layouts/BillingLayout.tsx b/studio/components/layouts/BillingLayout.tsx
index 932a064315d..14d830aac29 100644
--- a/studio/components/layouts/BillingLayout.tsx
+++ b/studio/components/layouts/BillingLayout.tsx
@@ -1,9 +1,9 @@
import Link from 'next/link'
import { FC, ReactNode } from 'react'
import { useStore, withAuth } from 'hooks'
-import { Button, IconX } from '@supabase/ui'
+import { observer } from 'mobx-react-lite'
+import { IconX } from '@supabase/ui'
-import { headWithTimeout } from 'lib/common/fetch'
import BaseLayout from 'components/layouts'
interface Props {
@@ -55,4 +55,4 @@ const BillingLayout: FC = ({ children }) => {
)
}
-export default withAuth(BillingLayout)
+export default withAuth(observer(BillingLayout))
diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/BreadcrumbsView.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/BreadcrumbsView.tsx
index 395dd1d379a..b51b4a12a1a 100644
--- a/studio/components/layouts/ProjectLayout/LayoutHeader/BreadcrumbsView.tsx
+++ b/studio/components/layouts/ProjectLayout/LayoutHeader/BreadcrumbsView.tsx
@@ -1,6 +1,5 @@
import { FC, Fragment } from 'react'
import { observer } from 'mobx-react-lite'
-import { Typography, IconChevronRight } from '@supabase/ui'
interface Props {
defaultValue: any
@@ -27,16 +26,15 @@ const BreadcrumbsView: FC = ({ defaultValue: breadcrumbs }) => {
-
- {})}
- className={`block px-2 py-1 text-xs leading-5 text-gray-1100 focus:outline-none focus:bg-gray-100 focus:text-gray-900 ${
- breadcrumb.onClick ? 'cursor-pointer hover:text-white' : ''
- }`}
- >
- {breadcrumb.label}
-
-
+
+ {})}
+ className={`text-gray-1100 block px-2 py-1 text-xs leading-5 focus:bg-gray-100 focus:text-gray-900 focus:outline-none ${
+ breadcrumb.onClick ? 'cursor-pointer hover:text-white' : ''
+ }`}
+ >
+ {breadcrumb.label}
+
))
: null}
diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx
index a96efd9c9f8..68db0e1ca2a 100644
--- a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx
+++ b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx
@@ -8,6 +8,7 @@ import OrgDropdown from './OrgDropdown'
import ProjectDropdown from './ProjectDropdown'
import FeedbackDropdown from './FeedbackDropdown'
import HelpPopover from './HelpPopover'
+import NotificationsPopover from './NotificationsPopover'
const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = true }: any) => {
const { ui } = useStore()
@@ -15,13 +16,11 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
return (
{label}
: label} ) } @@ -165,15 +167,18 @@ const SidebarItem: FC
- {/*
*/}
- {/* setProjectToRestart(undefined)}
+ onSelectConfirm={onConfirmProjectRestart}
+ />
+ >
+ )
+}
+
+export default NotificationsPopover
diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx
index 4afadd4974d..9d80016aedc 100644
--- a/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx
+++ b/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx
@@ -40,7 +40,7 @@ const ProjectDropdown = () => {
>
}
>
-
*/}
- = ({ project, availableActions, onSelectRestartProject }) => {
+ const router = useRouter()
+
+ const onSelectUpgradeProject = () => {
+ return router.push(`/project/${project.ref}/settings/billing/update/pro`)
+ }
+
+ return (
+ = ({ notification, onSelectRestartProject }) => {
+ const { app } = useStore()
+ const [project] = app.projects.list((project: Project) => project.id === notification.project_id)
+
+ const insertedAt = dayjs(notification.inserted_at).format('DD MMM YYYY, HH:mma')
+ const availableActions = notification.meta?.actions_available ?? []
+
+ return (
+ = () => {
+ const router = useRouter()
+ const { app, ui } = useStore()
+ const { notifications, refresh } = useNotifications()
+
+ const [projectToRestart, setProjectToRestart] = useState()
+ const [targetNotification, setTargetNotification] = useState()
+
+ if (!notifications) return
+
+ const hasNewNotifications = notifications?.some(
+ (notification) => notification.notification_status === NotificationStatus.New
+ )
+
+ const onOpenChange = async (open: boolean) => {
+ if (!open) {
+ // Mark notifications as seen
+ const notificationIds = notifications
+ .filter((notification) => notification.notification_status === NotificationStatus.New)
+ .map((notification) => notification.id)
+ if (notificationIds.length > 0) {
+ const { error } = await patch(`${API_URL}/notifications`, { ids: notificationIds })
+ if (error) console.error('Failed to update notifications', error)
+ refresh()
+ }
+ }
+ }
+
+ const onConfirmProjectRestart = async () => {
+ if (!projectToRestart || !targetNotification) return
+
+ const { id } = targetNotification
+ const { id: projectId, ref, region } = projectToRestart
+ const { error } = await post(`${API_URL}/projects/${ref}/restart-services`, {
+ restartRequest: {
+ region,
+ source_notification_id: id,
+ services: ['postgresql'],
+ },
+ })
+
+ if (error) {
+ ui.setNotification({
+ category: 'error',
+ message: `Failed to restart project: ${error.message}`,
+ error,
+ })
+ } else {
+ app.onProjectPostgrestStatusUpdated(projectId, 'OFFLINE')
+ ui.setNotification({ category: 'success', message: `Restarting project` })
+ router.push(`/project/${ref}`)
+ }
+
+ setProjectToRestart(undefined)
+ setTargetNotification(undefined)
+ }
+
+ return (
+ <>
+
+
+ }
+ >
+
+
diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationActions.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationActions.tsx
new file mode 100644
index 00000000000..1bf5f150b46
--- /dev/null
+++ b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationActions.tsx
@@ -0,0 +1,41 @@
+import { FC } from 'react'
+import { useRouter } from 'next/router'
+import { Button } from '@supabase/ui'
+
+import { Project } from 'types'
+
+interface Props {
+ project: Project
+ availableActions: any[]
+ onSelectRestartProject: () => void
+}
+
+const NotificationActions: FC
{/* Organization is selected */}
{selectedOrganization ? (
<>
@@ -54,7 +53,7 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
) : (
Supabase
@@ -63,10 +62,13 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
{/* Additional breadcrumbs are supplied */}
-
+
//
{customHeaderComponents && customHeaderComponents}
- {IS_PLATFORM && }
- {IS_PLATFORM && }
+ }
+ {IS_PLATFORM && }
+ {IS_PLATFORM && }
+
+ {IS_PLATFORM &&
+ {availableActions.map((action: any) => {
+ if (action.action_type === 'project.upgrade') {
+ return (
+
+ )
+ } else if (action.action_type === 'postgresql.restart') {
+ return (
+
+ )
+ }
+ })}
+
+ )
+}
+
+export default NotificationActions
diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx
new file mode 100644
index 00000000000..c14483f8722
--- /dev/null
+++ b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx
@@ -0,0 +1,49 @@
+import dayjs from 'dayjs'
+import { FC } from 'react'
+import { Notification, NotificationStatus } from '@supabase/shared-types/out/notifications'
+
+import { useStore } from 'hooks'
+import { Project } from 'types'
+import { formatNotificationCTAText, formatNotificationText } from './NotificationRows.utils'
+import NotificationActions from './NotificationActions'
+
+interface Props {
+ notification: Notification
+ onSelectRestartProject: (project: Project, notification: Notification) => void
+}
+
+const NotificationRow: FC
+
+ )
+}
+
+export default NotificationRow
diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRows.utils.ts b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRows.utils.ts
new file mode 100644
index 00000000000..3de594635e8
--- /dev/null
+++ b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRows.utils.ts
@@ -0,0 +1,71 @@
+import { Project } from 'types'
+import {
+ Action,
+ ActionType,
+ ExtensionsUpgrade,
+ Notification,
+ NotificationName,
+ PostgresqlUpgradeData,
+ ProjectExceedingTierLimitData,
+ ProjectUpdateData,
+ ServerUpgrade,
+ ServiceUpgrade,
+ ViolatedLimit,
+} from '@supabase/shared-types/out/notifications'
+
+export const formatNotificationText = (project: Project, notification: Notification) => {
+ const projectName = project.name
+ const notificationName = notification.notification_name
+
+ if (notificationName === NotificationName.ProjectExceedingTierLimit) {
+ const { violations } = notification.data as ProjectExceedingTierLimitData
+ const violationsText = violations
+ .map((violation: ViolatedLimit) => violation.dimension as string)
+ .reduce((a: string, b: string) => `${a}, ${b}`)
+
+ return `Your project "${projectName}" has exceeded its limits in the following areas: ${violationsText}.`
+ } else if (notificationName === NotificationName.PostgresqlUpgradeAvailable) {
+ const { upgrade_type, additional } = notification.data as PostgresqlUpgradeData
+
+ if (upgrade_type === 'postgresql-server') {
+ const { version_to } = additional as ServerUpgrade
+ return `New version of Postgres (${version_to}) is now available for project "${projectName}".`
+ } else if (upgrade_type === 'extensions') {
+ const { name, version_to } = additional as ExtensionsUpgrade
+ return `New version of "${name}" (${version_to}) is now available for project "${projectName}".`
+ }
+ return ''
+ } else if (notificationName === NotificationName.PostgresqlUpgradeCompleted) {
+ const { upgrade_type, additional } = notification.data as PostgresqlUpgradeData
+
+ if (upgrade_type === 'postgresql-server') {
+ const { version_to } = additional as ServerUpgrade
+ return `Postgres (${version_to}) has been successfully updated to ${version_to} for project "${projectName}".`
+ } else if (upgrade_type === 'extensions') {
+ const { name, version_to } = additional as ExtensionsUpgrade
+ return `The extension "${name}" has been successfully updated to ${version_to} for project "${projectName}`
+ }
+ } else if (notificationName === 'project.update-completed') {
+ const { upgrades } = notification.data as ProjectUpdateData
+ const upgradesText = upgrades
+ .map((upgrade: ServiceUpgrade) => upgrade.name)
+ .reduce((a: string, b: string) => `${a}, ${b}`)
+ return `The following services have been successfully updated for project "${projectName}": ${upgradesText}`
+ } else {
+ return `Unknown notification type: ${notification.notification_name} - Please reach out to support for more information`
+ }
+}
+
+export const formatNotificationCTAText = (availableActions: Action[]) => {
+ const [action] = availableActions
+ if (!action) return ''
+
+ switch (action.action_type) {
+ case ActionType.SchedulePostgresRestart:
+ return 'Restart your project to get the latest updates.'
+ case ActionType.UpgradeProjectToPro:
+ return 'Upgrade your project to ensure continued availability.'
+ default:
+ return ''
+ }
+}
diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx
new file mode 100644
index 00000000000..bdd18015adc
--- /dev/null
+++ b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx
@@ -0,0 +1,145 @@
+import { FC, Fragment, useState } from 'react'
+import { useRouter } from 'next/router'
+import { Button, IconBell, Popover } from '@supabase/ui'
+import { Notification, NotificationStatus } from '@supabase/shared-types/out/notifications'
+
+import { Project } from 'types'
+import { useNotifications, useStore } from 'hooks'
+import { patch, post } from 'lib/common/fetch'
+import { API_URL } from 'lib/constants'
+import NotificationRow from './NotificationRow'
+import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog'
+
+interface Props {}
+
+const NotificationsPopover: FC
+ {notification.notification_status !== NotificationStatus.Seen && (
+
+ )}
+
+
+
+
+
+ {formatNotificationText(project, notification)}
+{formatNotificationCTAText(availableActions)}
+{insertedAt}
+
+ {availableActions.length > 0 && (
+ onSelectRestartProject(project, notification)}
+ />
+ )}
+
+
+
+ Notifications
+ {/* Area for improvement: Paginate notifications and show in a side panel */} + {/*+ See all{' '} + {notifications.length > MAX_NOTIFICATIONS_TO_SHOW && `(${notifications.length})`} +
*/} +
+ {notifications.length === 0 ? (
+
+ {
+ setProjectToRestart(project)
+ setTargetNotification(notification)
+ }}
+ />
+ {i !== notifications.length - 1 && }
+
+ ))}
+ >
+ )}
+
+
+
+ ) : (
+ <>
+ {notifications.map((notification, i: number) => (
+ No notifications available
+
+ }
+ />
+ {hasNewNotifications && (
+
+
+
+
+
+
+
+ )}
+