feat(docs): main troubleshooting page (#29120)

* feat(docs): main troubleshooting page

* feat(docs): global troubleshooting page layout

* feat(docs): global troubleshooting page filter logic

* enhance(docs): troubleshooting page ui & filters

Improve UX and accessibility of global troubleshooting search page.

- Implement TroubleshootingFilterStateProvider for managing filter state
- Add TroubleshootingFilterEmptyState component
- Improve TroubleshootingFilter UI with applied filters display
- Update TroubleshootingPreview component with better layout and accessibility
- Add TroubleshootingEntryAssociatedErrors component for displaying related errors
- Refactor Troubleshooting utils and schemas
- Update troubleshooting content template

* feat(docs): individual troubleshooting pages

Add individual pages for detailed information on troubleshooting
entries. Small fixes to UI for troubleshooting displays.

- Wrap GlobalTroubleshootingPage with SidebarSkeleton
- Add TroubleshootingErrorListDetailed component for detailed error display
- Implement TroubleshootingBackLink component for navigation
- Update TroubleshootingPreview to use Next.js Link and include parent page
- Refactor Footer component to accept className prop
- Add hideFooter option to SidebarSkeleton
- Minor updates to TopNavBar and other utility functions

* fix: minor styling issues

* tweak(troubleshooting docs): use breadcrumbs

* tweak(troubleshooting docs): use sidebar for filter

* enhance(troubleshooting docs): navigation, keywords

Better navigation for individual troubleshooting entries, using a
version of the sidebar + keywords handling via search params for good
linking behavior

Details:

- Add TroubleshootingSidebar to TroubleshootingPage
- Implement query state management for keywords using nuqs
- Improve accessibility and styling of troubleshooting components
- Update empty state handling and related keywords display
- Upgrade nuqs package to version 1.19.1

* fix(docs): wrap useSearchParams in Suspense

* seo(docs): add canonical link to troubleshooting

* enhance(docs,troubleshooting): auto-reveal errors

On troubleshooting search pages, if the searched term matches a hidden
error, auto-reveal it when searching. When the search term is cleared,
hide programmatically-opened errors while preserving user-opened ones.

* enhance(docs,troubleshooting): search bar

Add search bar to individual troubleshooting pages

* fix(docs): breadcrumb links

Fix breadcrumb linking so it uses next/link instead of reloading entire
app on navigation.

* fix(docs): typo

* enh(docs): new troubleshooting design

* enh(docs): use new multi-select

* enh(docs): troubleshooting responsive styles

* Update apps/docs/content/troubleshooting/monitor-supavisor-postgres-connections.mdx
This commit is contained in:
Charis authored and GitHub committed 2024-10-02 17:10:12 +00:00
1 parent 78e10c8989
commit a29dc5de4b
38 files changed
+1405 -78

No files matched your search

@@ -0,0 +1,37 @@
import { notFound } from 'next/navigation'
import TroubleshootingPage from '~/features/docs/Troubleshooting.page'
import { getAllTroubleshootingEntries, getArticleSlug } from '~/features/docs/Troubleshooting.utils'
import { PROD_URL } from '~/lib/constants'
export default async function TroubleshootingEntryPage({
params: { slug },
}: {
params: { slug: string }
}) {
const allTroubleshootingEntries = await getAllTroubleshootingEntries()
const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry.data) === slug)
if (!entry) {
notFound()
}
return <TroubleshootingPage entry={entry} />
}
export const generateMetadata = async ({ params: { slug } }: { params: { slug: string } }) => {
const allTroubleshootingEntries = await getAllTroubleshootingEntries()
const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry.data) === slug)
return {
title: 'Supabase Docs | Troubleshooting' + (entry ? ` | ${entry.data.title}` : ''),
alternates: {
canonical: `${PROD_URL}/guides/troubleshooting/${slug}`,
},
}
}
export const generateStaticParams = async () => {
const allTroubleshootingEntries = await getAllTroubleshootingEntries()
return allTroubleshootingEntries.map((entry) => ({ slug: getArticleSlug(entry.data) }))
}
@@ -0,0 +1,61 @@
import { type Metadata } from 'next'
import { TroubleshootingPreview } from '~/features/docs/Troubleshooting.ui'
import {
TroubleshootingFilter,
TroubleshootingFilterEmptyState,
TroubleshootingListController,
} from '~/features/docs/Troubleshooting.ui.client'
import {
getAllTroubleshootingEntries,
getAllTroubleshootingErrors,
getAllTroubleshootingKeywords,
getAllTroubleshootingProducts,
} from '~/features/docs/Troubleshooting.utils'
import { TROUBLESHOOTING_CONTAINER_ID } from '~/features/docs/Troubleshooting.utils.shared'
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
import { PROD_URL } from '~/lib/constants'
export default async function GlobalTroubleshootingPage() {
const troubleshootingEntries = await getAllTroubleshootingEntries()
const keywords = await getAllTroubleshootingKeywords()
const products = await getAllTroubleshootingProducts()
const errors = await getAllTroubleshootingErrors()
return (
<SidebarSkeleton className="w-full max-w-screen-lg mx-auto">
<div className="py-8 px-5">
<h1 className="text-4xl tracking-tight mb-7">Troubleshooting</h1>
<p className="text-lg text-foreground-light">
Search or browse our troubleshooting guides for solutions to common Supabase issues.
</p>
<hr className="my-7" aria-hidden />
<TroubleshootingFilter
keywords={keywords}
products={products}
errors={errors}
className="mb-8"
/>
<TroubleshootingListController />
<TroubleshootingFilterEmptyState />
<div id={TROUBLESHOOTING_CONTAINER_ID}>
<h2 className="sr-only">Matching troubleshooting entries</h2>
<ul>
{troubleshootingEntries.map((entry) => (
<li key={entry.data.database_id}>
<TroubleshootingPreview entry={entry} />
</li>
))}
</ul>
</div>
</div>
</SidebarSkeleton>
)
}
export const metadata: Metadata = {
title: 'Supabase Docs | Troubleshooting',
alternates: {
canonical: `${PROD_URL}/guides/troubleshooting`,
},
}
+48 -17
View File
@@ -1,8 +1,8 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import React, { Fragment } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import React, { Fragment, Suspense } from 'react'
import { useBreakpoint } from 'common'
import {
@@ -29,19 +29,36 @@ import {
import * as NavItems from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
import { getMenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu.utils'
const Breadcrumbs = ({ className }: { className?: string }) => {
const pathname = usePathname()
const menuId = getMenuId(pathname)
const menu = NavItems[menuId]
const breadcrumbs = findMenuItemByUrl(menu, pathname, [])
interface BreadcrumbsProps extends React.HTMLAttributes<HTMLDivElement> {
minLength?: number
forceDisplayOnMobile?: boolean
}
export default function Breadcrumbs(props: BreadcrumbsProps) {
return (
<Suspense>
<BreadcrumbsInternal {...props} />
</Suspense>
)
}
const BreadcrumbsInternal = ({
className,
minLength = 2,
forceDisplayOnMobile = false,
}: BreadcrumbsProps) => {
const breadcrumbs = useBreadcrumbs()
const [open, setOpen] = React.useState(false)
const isMobile = useBreakpoint('md')
const ITEMS_TO_DISPLAY = isMobile ? 4 : 3
if (!breadcrumbs?.length || breadcrumbs?.length === 1) return null
if (!breadcrumbs?.length || breadcrumbs?.length < minLength) return null
const appendedBreadcrumbs = breadcrumbs?.slice(-ITEMS_TO_DISPLAY + 1, isMobile ? -1 : undefined)
const appendedBreadcrumbs = breadcrumbs?.slice(
-ITEMS_TO_DISPLAY + 1,
isMobile && !forceDisplayOnMobile ? -1 : undefined
)
return (
<Breadcrumb className={cn(className)}>
@@ -50,8 +67,10 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
<>
<BreadcrumbItem>
{breadcrumbs[0].url ? (
<BreadcrumbLink href={`/docs${breadcrumbs[0].url}`}>
{breadcrumbs[0].title || breadcrumbs[0].name}
<BreadcrumbLink asChild>
<Link href={breadcrumbs[0].url}>
{breadcrumbs[0].title || breadcrumbs[0].name}
</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPage>{breadcrumbs[0].title || breadcrumbs[0].name}</BreadcrumbPage>
@@ -75,7 +94,7 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
className={cn(!crumb.url && 'pointer-events-none')}
>
{crumb.url ? (
<Link href={`/docs${crumb.url}`}>{crumb.title || crumb.name}</Link>
<Link href={crumb.url}>{crumb.title || crumb.name}</Link>
) : (
crumb.title || crumb.name
)}
@@ -94,7 +113,7 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
.slice(1, -2)
.map((crumb) =>
crumb.url ? (
<Link href={`/docs${crumb.url}`}>{crumb.title || crumb.name}</Link>
<Link href={crumb.url}>{crumb.title || crumb.name}</Link>
) : (
crumb.title || crumb.name
)
@@ -121,8 +140,8 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
)}
>
{crumb.url ? (
<BreadcrumbLink href={`/docs${crumb.url}`}>
{crumb.title || crumb.name}
<BreadcrumbLink asChild>
<Link href={crumb.url}>{crumb.title || crumb.name}</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPage>{crumb.title || crumb.name}</BreadcrumbPage>
@@ -138,6 +157,20 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
)
}
function useBreadcrumbs() {
const pathname = usePathname()
const isTroubleshootingPage = pathname.startsWith('/guides/troubleshooting')
if (isTroubleshootingPage) {
const breadcrumbs = [{ name: 'Troubleshooting', url: '/guides/troubleshooting' }]
return breadcrumbs
}
const menuId = getMenuId(pathname)
const menu = NavItems[menuId]
return findMenuItemByUrl(menu, pathname, [])
}
function findMenuItemByUrl(menu, targetUrl, parents = []) {
// If the menu has items, recursively search through them
if (menu.items) {
@@ -157,5 +190,3 @@ function findMenuItemByUrl(menu, targetUrl, parents = []) {
// If the URL is not found, return null
return null
}
export default Breadcrumbs
+4 -2
View File
@@ -1,3 +1,5 @@
'use client'
import { useSupabaseClient } from '@supabase/auth-helpers-react'
import { Check, MessageSquareQuote, X } from 'lucide-react'
import { usePathname } from 'next/navigation'
@@ -69,7 +71,7 @@ function reducer(state: State, action: Action) {
}
}
function Feedback() {
function Feedback({ className }: { className?: string }) {
const [state, dispatch] = useReducer(reducer, initialState)
const [modalOpen, setModalOpen] = useState(false)
const feedbackButtonRef = useRef<HTMLButtonElement>(null)
@@ -132,7 +134,7 @@ function Feedback() {
}
return (
<section className="@container px-5 mb-6" aria-labelledby="feedback-title">
<section className={cn('@container px-5 mb-6', className)} aria-labelledby="feedback-title">
<h3 id="feedback-title" className="block font-mono text-xs uppercase text-foreground mb-4">
Is this helpful?
</h3>
+196
View File
@@ -0,0 +1,196 @@
'use client'
import { ChevronsUpDown } from 'lucide-react'
import {
createContext,
forwardRef,
useCallback,
useContext,
useId,
useState,
PropsWithChildren,
useRef,
useEffect,
} from 'react'
import {
cn,
Badge,
Checkbox_Shadcn_ as Checkbox,
Popover_Shadcn_ as Popover,
PopoverContent_Shadcn_ as PopoverContent,
PopoverTrigger_Shadcn_ as PopoverTrigger,
} from 'ui'
interface Item {
value: string
label: string
}
const MultiSelectContext = createContext<{
selected: Item[]
handleSelect: (item: Item) => void
setOpen: (open: boolean) => void
}>({
selected: [],
handleSelect: () => {},
setOpen: () => {},
})
function MultiSelectProvider({
selected,
onSelectedChange,
setOpen,
children,
}: PropsWithChildren<{
selected: Item[]
onSelectedChange: (selected: Item[] | ((selected: Item[]) => Item[])) => void
setOpen: (open: boolean) => void
}>) {
const handleSelect = useCallback(
(item: Item) => {
onSelectedChange((current: Item[]) => {
const isSelected = current.some((currItem) => currItem.value === item.value)
if (isSelected) {
return current.filter((currItem) => currItem.value !== item.value)
} else {
return [...current, item]
}
})
},
[onSelectedChange]
)
return (
<MultiSelectContext.Provider value={{ selected, handleSelect, setOpen }}>
{children}
</MultiSelectContext.Provider>
)
}
function useMultiSelect() {
const context = useContext(MultiSelectContext)
if (!context) {
throw new Error('useMultiSelect must be used within a MultiSelectProvider')
}
return context
}
export function MultiSelect({
open: _controlledOpen,
setOpen: _setControlledOpen,
selected,
onSelectedChange,
children,
...props
}: PropsWithChildren<
{
open?: boolean
setOpen?: React.Dispatch<React.SetStateAction<boolean>>
selected?: Item[]
onSelectedChange: (selected: Item[] | ((selected: Item[]) => Item[])) => void
} & React.ComponentProps<typeof Popover>
>) {
const [_internalOpen, _setInternalOpen] = useState(false)
const open = _controlledOpen ?? _internalOpen
const setOpen = _setControlledOpen ?? _setInternalOpen
return (
<MultiSelectProvider selected={selected} onSelectedChange={onSelectedChange} setOpen={setOpen}>
<Popover open={open} onOpenChange={setOpen} {...props}>
{children}
</Popover>
</MultiSelectProvider>
)
}
const Trigger = forwardRef<
HTMLButtonElement,
{ label?: string; className?: string } & React.ComponentProps<typeof PopoverTrigger>
>(({ label, className, ...props }, ref) => {
const { selected } = useMultiSelect()
const [measuredWidth, setMeasuredWidth] = useState(0)
const measuredRef = (node: HTMLElement) => {
if (node) {
setMeasuredWidth(node.getBoundingClientRect().width)
}
}
const badgeWidth = 100 // Approximate width of a badge in pixels
const maxBadges = Math.max(1, Math.floor((measuredWidth || 200) / badgeWidth))
const visibleBadges = selected.slice(0, maxBadges)
const extraBadgesCount = Math.max(0, selected.length - maxBadges)
const badgeClasses = 'bg-200'
return (
<PopoverTrigger asChild ref={ref}>
<button
role="combobox"
className={cn(
'flex w-full min-w-[200px] items-center justify-between rounded-md border',
'border-alternative bg-foreground/[.026] px-3 py-2 text-sm',
'ring-offset-background placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'hover:border-primary transition-colors duration-200',
className
)}
{...props}
>
{selected.length === 0 ? (
// Leading prevents shift when switching to badges
<span className="text-muted-foreground leading-[1.375rem]">{label}</span>
) : (
<div ref={measuredRef} className="flex gap-1 overflow-hidden">
{visibleBadges.map((item) => (
<Badge key={item.value} className={cn('shrink-0', badgeClasses)}>
{item.label}
</Badge>
))}
{extraBadgesCount > 0 && <Badge className={badgeClasses}>+{extraBadgesCount}</Badge>}
</div>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
)
})
Trigger.displayName = 'Trigger'
MultiSelect.Trigger = Trigger
const Content = forwardRef<
HTMLDivElement,
PropsWithChildren<{ className?: string } & React.ComponentProps<typeof PopoverContent>>
>(({ children, className, ...props }, ref) => {
return (
<PopoverContent ref={ref} className={cn('w-full p-4 space-y-4', className)} {...props}>
{children}
</PopoverContent>
)
})
Content.displayName = 'Content'
MultiSelect.Content = Content
export function Item({ item, className }: { item: Item; className?: string }) {
const id = useId()
const { selected, handleSelect } = useMultiSelect()
return (
<div className={cn('flex items-center space-x-2', className)}>
<Checkbox
id={`${id}-checkbox-${item.value}`}
checked={selected.some((sel) => sel.value === item.value)}
onCheckedChange={() => handleSelect(item)}
/>
<label
htmlFor={`${id}-checkbox-${item.value}`}
className="text-sm leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{item.label}
</label>
</div>
)
}
MultiSelect.Item = Item
+3 -3
View File
@@ -1,10 +1,10 @@
import Link from 'next/link'
import { Button } from 'ui'
import { Button, cn } from 'ui'
import { primaryLinks, secondaryLinks } from '~/data/footer'
import { LayoutMainContent } from '~/layouts/DefaultLayout'
const Footer = () => (
<LayoutMainContent className="pt-0">
const Footer = ({ className }: { className?: string }) => (
<LayoutMainContent className={cn('pt-0', className)}>
<footer role="contentinfo" aria-label="footer">
<div className="mt-16">
<ul className="flex flex-col gap-2">
@@ -172,6 +172,11 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
label: 'Resources',
menuItems: [
[
/* {
label: 'Troubleshooting',
icon: 'contributing',
href: '/guides/troubleshooting',
}, */
{
label: 'Migration guides',
icon: 'resources',
@@ -30,12 +30,12 @@ cli = ["supabase-migration-repair"]
# Optionally, list the error messages associated with this issue.
[[errors]]
httpStatusCode = 404
http_status_code = 404
code = "not_found"
message = "The resource was not found"
[[errors]]
httpStatusCode = 403
http_status_code = 403
code = "forbidden"
message = "Not allowed to access this resource"
---
@@ -0,0 +1,112 @@
---
title = "How to monitor Postgres and Supavisor connections"
topics = [
"database",
"supavisor",
]
keywords = [
"Grafana",
"connections",
"performance",
"pooler",
]
---
This guide explains how connections impact your Supabase database's performance and how to optimize them for better resource utilization.
## Installing Supabase Grafana
Supabase has an [open-source Grafana Repo](https://github.com/supabase/supabase-grafana) that displays real-time metrics of your database. Although the [Reports Dashboard ](/dashboard/project/_/reports) provides similar metrics, it averages the data by the hour or day. Having visuals of your connection usage can help you better allocate resources.
_Visual of Grafana Dashboard_
![image](https://github.com/supabase/supabase/assets/91111415/18ed2c88-332e-4e66-b9b4-c37e99a39104)
It can be run locally within Docker. Alternatively, you can deploy it to fly.io or Grafana Cloud, which are better for long-term data collection.
Installation instructions can be found in it the [metrics docs ](/docs/guides/platform/metrics#deploying-supabase-grafana)
## Observing Connections
In Supabase Grafana, the "Client Connections" graph shows connections to both Supavisor and Postgres
![image](https://github.com/supabase/supabase/assets/91111415/141b8e01-9aae-4544-818c-acb47371ef3b)
- **Yellow**: The yellow line represents the number of actively querying and idle connections to the Supavisor Pooler.
- **Green**: The green line represents the total number of actively querying and idle direct connections to the database.
## Investigating Connection Sources
`pg_stat_activity` is a `VIEW` that keeps track of processes being run by your database, including connections. It's particularly useful for determining if idle clients are hogging connection slots.
This is a query you can use to observe the database roles and servers connecting to your database:
```sql
SELECT
pg_stat_activity.pid,
ssl AS ssl_connection,
datname AS database,
usename AS connected_role,
application_name,
client_addr,
query,
query_start,
state,
backend_start
FROM pg_stat_ssl
JOIN pg_stat_activity
ON pg_stat_ssl.pid = pg_stat_activity.pid;
```
Interpreting the query:
| Column | Description |
| ---------------- | ------------------------------------------------- |
| pid | connection id |
| ssl | Indicates if SSL is in use |
| datname | Name of the connected database (usually postgres) |
| usename | Role of the connected user |
| application_name | Name of the connecting application |
| client_addr | IP address of the connecting server |
| query | Last query executed by the connection |
| query_start | Time when the last query was executed |
| state | Querying state: active or idle |
| backend_start | Timestamp of the connection's establishment |
- Note: If you are unfamiliar with the Supabase database roles, check this [reference](https://gist.github.com/TheOtherBrian1/d6e862a65e03049eb4f102f6ca809401)
If you believe a connection should be killed, you can do so by running the following query:
```sql
select pg_terminate_backend(pid)
from pg_stat_activity
where pid = <connection_id>;
```
## Managing the Supavisor Pooler:
The Supavisor Pooler is an intermediary between your clients (application servers) and the database. In transaction mode (port 6543), it can enable Postgres to share single connections with many clients, only allowing access when a query is pending. This prevents idle clients from hogging a direct connection and allows for more throughput.
In cases where you see significantly more pooler connections than direct connections, if you can, you should consider increasing how many direct connections the pooler is allowed to manage in the [Dashboard's Database Settings](/dashboard/project/_/settings/database):
![image](https://github.com/supabase/supabase/assets/91111415/8e0cc80a-4d46-44b5-915b-8d7549f352d3).
The general rule is that if you are using the PostgREST database API, you should avoid raising your pool size past 40%. Otherwise, you can commit 80% to the pool. This leaves adequate room for the Authentication server and other utilities.
These numbers are generalizations and assume a certain level of activity from all connected servers. The actual values depend on your peak connection usage. For instance, if you were only using 80 connections in a week period and your database could support 500 connections, then realistically you could allocate the remaining 420 (minus a reasonable buffer) to service more demand.
## Secondary Issues:
When managing Postgres, outside of connections, there are generally 3 likely bottlenecks (links to address each):
- [Disk/IO](https://github.com/orgs/supabase/discussions/27003)
- [Memory](https://github.com/orgs/supabase/discussions/27021)
- [CPU](https://github.com/orgs/supabase/discussions/27022)
They're all intertwined to some extent. If IO, CPU, or Memory are constrained, this can cause queries to slow down. Your application servers and Supavisor may compensate by creating more database connections or letting queries wait longer in their respective queues. Sometimes, by addressing or optimizing other factors of the database, you can better address connection issues.
## Other helpful resources:
- [Supavisor FAQ](https://github.com/orgs/supabase/discussions/21566)
- [Using SQLAlchemy with Supabase](https://github.com/orgs/supabase/discussions/27071)
- [Supabase and IPv4/IPv6 compatibility ](https://github.com/orgs/supabase/discussions/27034)
- [Addressing Max Client Errors](https://github.com/orgs/supabase/discussions/22305)
- [Connecting to your database](/docs/guides/database/connecting-to-postgres#integrations)
- [How to Change Max Database Connections](https://github.com/orgs/supabase/discussions/27197)
@@ -0,0 +1,60 @@
---
title = "Interpreting Supabase Grafana Memory Charts"
topics = [
"database",
]
keywords = [
"Grafana",
"performance",
"memory"
]
---
> [Supabase Grafana Installation Guide](/docs/guides/platform/metrics#deploying-supabase-grafana)
Here are examples of unhealthy memory usage:
![image](https://github.com/supabase/supabase/assets/91111415/baebfc74-642d-4988-992c-bb0f473a05ad)
![image](https://github.com/supabase/supabase/assets/91111415/b95c83cf-aa98-4b50-8e07-29f57aaa676c)
- **Yellow**: represents active memory
- **Red**: represents SWAP, which is disk storage that the system treats as if it were actually memory
- **Green**: it is unclaimed (the system will always leave some memory unclaimed)
- **Blue**: it is cached data and a buffer
The cache in PostgreSQL is important because the database will store frequently accessed data in it for rapid retrieval. If too much active memory is needed, it runs the risk of excessively displacing cache. This will force queries to check disk, which is slow.
Most data in a database is idle, but in cases where there is little available memory or uncached data is rapidly accessed, [thrashing](<https://en.wikipedia.org/wiki/Thrashing_(computer_science)>) can occur.
Ideally, you want queries to hit the cache 99% of the time. You can use the [Supabase CLI](/docs/guides/database/inspect) `inspect db cache hit` command to check your cache hit rate. Alternatively, you can run the [query](https://github.com/supabase/cli/blob/c9cce58025fded16b4c332747f819a44f45c3b83/internal/inspect/bloat/bloat.go#L17) found in the CLI's GitHub repo in the [SQL Editor](/dashboard/project/_/sql/)
```sh
# login to the CLI
npx supabase login
# initlize a local supabase directory
npx supabase init
#link your project
npx supabase link
# find cache hit rate
npx supabase inspect db cache-hit --linked
```
If the cache hit rate begins to drop below the ideal amount, one should consider taking the following actions:
Optimizing:
1. [Apply indexes](https://github.com/orgs/supabase/discussions/22449): can reduce the amount of data pulled from disk into memory
2. [Increasing the compute size](/docs/guides/platform/compute-add-ons)
3. [Distribute load by using read-replicas](/dashboard/project/_/settings/infrastructure)
4. [Partitions](/docs/guides/database/partitions): Generally should be used on _very_ large tables to minimize data pulled from disk into memory
5. [Remove bloat](/docs/guides/database/bloat): Bloat can fragment data across pages, causing redundant data to be pulled from disk.
6. Table refactoring: Split tables to isolate columns that are less frequently accessed, so they are not redundantly pulled into memory while accessing hotter data
### Other useful Supabase Grafana guides:
- [Connections](https://github.com/orgs/supabase/discussions/27141)
- [Disk](https://github.com/orgs/supabase/discussions/27003)
- [CPU](https://github.com/orgs/supabase/discussions/27022)
@@ -0,0 +1,103 @@
import { cn } from 'ui'
import Breadcrumbs from '~/components/Breadcrumbs'
import { Feedback } from '~/components/Feedback'
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
import { MDXRemoteBase } from './MdxBase'
import { type ITroubleshootingEntry } from './Troubleshooting.utils'
import Link from 'next/link'
import { formatError, serializeTroubleshootingSearchParams } from './Troubleshooting.utils.shared'
export default function TroubleshootingPage({ entry }: { entry: ITroubleshootingEntry }) {
return (
<SidebarSkeleton className="@container/troubleshooting-entry-layout w-full max-w-screen-lg mx-auto lg:py-8 lg:px-5">
<div className="px-5 py-8 lg:px-0 lg:py-0">
<Breadcrumbs minLength={1} forceDisplayOnMobile />
<article className="prose max-w-none mt-4">
<h1>{entry.data.title}</h1>
{entry.data.updated_at && <p>Last edited: {entry.data.updated_at.toLocaleString()}</p>}
<hr className="my-7" aria-hidden />
<div className="grid gap-10 @3xl/troubleshooting-entry-layout:grid-cols-[1fr,250px]">
<div>
<MDXRemoteBase source={entry.content} />
</div>
<aside aria-labelledby="heading--metadata" className="not-prose mt-5">
<h2
id="heading--metadata"
className="text-foreground-lighter uppercase text-sm tracking-wide @3xl/troubleshooting-entry-layout:sr-only"
>
Metadata
</h2>
<hr className="my-6 @3xl/troubleshooting-entry-layout:hidden" aria-hidden />
{entry.data.topics?.length > 0 && (
<>
<h3 className="text-sm text-foreground-lighter mb-3">Products</h3>
<div className="flex flex-wrap gap-0.5">
{entry.data.topics.map((topic) => (
<Link
key={topic}
href={`/guides/troubleshooting${serializeTroubleshootingSearchParams({ products: [topic] })}`}
>
<PillTag className="hover:bg-200 focus-visible:bg-foreground-muted hover:border-control focus-visible:border-control transition-colors">
{topic[0].toUpperCase() + topic.slice(1)}
</PillTag>
</Link>
))}
</div>
<hr className="my-6" aria-hidden />
</>
)}
{entry.data.errors?.length > 0 && (
<>
<h3 className="text-sm text-foreground-lighter mb-3">Related error codes</h3>
<div className="flex flex-wrap gap-0.5">
{entry.data.errors.map((error, index) => (
<Link
key={index}
href={`/guides/troubleshooting${serializeTroubleshootingSearchParams({ errorCodes: [formatError(error)] })}`}
>
<PillTag className="hover:bg-200 focus-visible:bg-foreground-muted hover:border-control focus-visible:border-control transition-colors">
{formatError(error)}
</PillTag>
</Link>
))}
</div>
<hr className="my-6" aria-hidden />
</>
)}
{entry.data.keywords?.length > 0 && (
<>
<h3 className="text-sm text-foreground-lighter mb-3">Tags</h3>
<div className="flex flex-wrap gap-0.5">
{entry.data.keywords.map((keyword) => (
<Link
key={keyword}
href={`/guides/troubleshooting${serializeTroubleshootingSearchParams({ tags: [keyword] })}`}
>
<PillTag className="hover:bg-200 focus-visible:bg-foreground-muted hover:border-control focus-visible:border-control transition-colors">
{keyword}
</PillTag>
</Link>
))}
</div>
<hr className="my-6" aria-hidden />
</>
)}
<Feedback className="px-0" />
</aside>
</div>
</article>
</div>
</SidebarSkeleton>
)
}
function PillTag({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<span
className={cn('px-2 py-px border rounded-full inline-flex items-center text-xs', className)}
>
{children}
</span>
)
}
@@ -0,0 +1,448 @@
'use client'
import { ChevronDown, RotateCw, Search, X } from 'lucide-react'
import { useQueryStates } from 'nuqs'
import { useEffect, useRef, useState, Suspense, useCallback } from 'react'
import {
Input_Shadcn_,
cn,
Button_Shadcn_,
Collapsible_Shadcn_ as Collapsible,
CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
CollapsibleContent_Shadcn_ as CollapsibleContent,
} from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { MultiSelect } from '~/components/MultiSelect.client'
import { type ITroubleshootingMetadata } from './Troubleshooting.utils'
import {
formatError,
TROUBLESHOOTING_CONTAINER_ID,
TROUBLESHOOTING_DATA_ATTRIBUTES,
troubleshootingSearchParams,
} from './Troubleshooting.utils.shared'
import { useBreakpoint } from 'common'
function useTroubleshootingSearchState() {
const [_state, _setState] = useQueryStates(troubleshootingSearchParams)
const setSelectedProducts = useCallback(
(products: string[]) => {
_setState({
products: products.length === 0 ? null : products,
})
},
[_setState]
)
const setSelectedErrorCodes = useCallback(
(errorCodes: string[]) => {
_setState({
errorCodes: errorCodes.length === 0 ? null : errorCodes,
})
},
[_setState]
)
const setSelectedTags = useCallback(
(tags: string[]) => {
_setState({
tags: tags.length === 0 ? null : tags,
})
},
[_setState]
)
const setSearchState = useCallback(
(search: string) => {
_setState({
search: search.length === 0 ? null : search,
})
},
[_setState]
)
const reset = useCallback(() => {
setSearchState('')
setSelectedTags([])
setSelectedProducts([])
setSelectedErrorCodes([])
}, [setSearchState, setSelectedTags, setSelectedProducts, setSelectedErrorCodes])
return {
selectedProducts: _state.products,
selectedErrorCodes: _state.errorCodes,
selectedTags: _state.tags,
searchState: _state.search,
setSelectedProducts,
setSelectedErrorCodes,
setSelectedTags,
setSearchState,
reset,
}
}
function entryMatchesFilter(
entry: HTMLElement,
selectedProducts: string[],
selectedErrorCodes: string[],
selectedTags: string[],
searchState: string
) {
const content = entry.textContent ?? ''
const dataKeywords = entry.getAttribute(TROUBLESHOOTING_DATA_ATTRIBUTES.KEYWORDS_LIST_ATTRIBUTE)
const dataProducts = entry.getAttribute(TROUBLESHOOTING_DATA_ATTRIBUTES.PRODUCTS_LIST_ATTRIBUTE)
const dataErrors = entry.getAttribute('data-errors')?.split(',') ?? []
const productsMatch =
selectedProducts.length === 0 ||
selectedProducts.some((product) => dataProducts?.includes(product))
const tagsMatch =
selectedTags.length === 0 || selectedTags.some((tag) => dataKeywords?.includes(tag))
const errorsMatch =
selectedErrorCodes.length === 0 ||
selectedErrorCodes.some((error) => dataErrors.includes(error))
const searchMatch =
searchState === '' || content.toLowerCase().includes(searchState.toLowerCase())
return productsMatch && errorsMatch && tagsMatch && searchMatch
}
interface TroubleshootingFilterProps {
products: string[]
errors: ITroubleshootingMetadata['errors']
keywords: string[]
className?: string
}
export function TroubleshootingFilter(props: TroubleshootingFilterProps) {
return (
<Suspense fallback={<ShimmeringLoader className="h-7 py-0" />}>
<TroubleshootingFilterMobileCollapsed {...props} />
</Suspense>
)
}
function TroubleshootingFilterMobileCollapsed(props: TroubleshootingFilterProps) {
const isBelowSmallScreen = useBreakpoint('sm')
const { selectedProducts, selectedErrorCodes, selectedTags, searchState } =
useTroubleshootingSearchState()
const numberFiltersApplied =
(selectedProducts.length > 0 ? 1 : 0) +
(selectedErrorCodes.length > 0 ? 1 : 0) +
(selectedTags.length > 0 ? 1 : 0) +
(searchState ? 1 : 0)
if (isBelowSmallScreen) {
return (
<Collapsible className="border-b">
<CollapsibleTrigger className="group w-full pb-6 text-foreground-light">
<div className="flex items-center justify-between gap-2">
<span>Filters</span>
<ChevronDown
size={16}
className="group-data-[state=open]:rotate-180 transition-transform"
/>
</div>
{numberFiltersApplied > 0 && (
<div className="group-data-[state=open]:hidden text-sm text-left">
{numberFiltersApplied} filter{numberFiltersApplied > 1 ? 's' : ''} applied
</div>
)}
</CollapsibleTrigger>
<CollapsibleContent className="flex flex-col gap-2">
<TroubleshootingFilterInternal {...props} />
</CollapsibleContent>
</Collapsible>
)
}
return <TroubleshootingFilterInternal {...props} />
}
function TroubleshootingFilterInternal({
keywords,
products,
errors,
className,
}: TroubleshootingFilterProps) {
const {
selectedProducts,
selectedErrorCodes,
selectedTags,
searchState,
setSelectedProducts,
setSelectedErrorCodes,
setSelectedTags,
setSearchState,
reset,
} = useTroubleshootingSearchState()
const [productsOpen, _setProductsOpen] = useState(false)
const [errorsOpen, _setErrorsOpen] = useState(false)
const [tagsOpen, _setTagsOpen] = useState(false)
const searchInputRef = useRef<HTMLInputElement | null>(null)
const allEntries = useRef<HTMLElement[]>([])
useEffect(() => {
const container = document.getElementById(TROUBLESHOOTING_CONTAINER_ID)
if (!container) return
const entries = Array.from(
container.querySelectorAll(
`[${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE}="${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY}"]`
)
) as HTMLElement[]
allEntries.current = entries
}, [])
const setProductsOpen = useCallback(
(open: boolean) => {
_setProductsOpen(open)
if (open) {
allEntries.current?.forEach((entry) => (entry.inert = true))
} else {
allEntries.current?.forEach((entry) => (entry.inert = false))
}
},
[_setProductsOpen]
)
const setErrorsOpen = useCallback(
(open: boolean) => {
_setErrorsOpen(open)
if (open) {
allEntries.current?.forEach((entry) => (entry.inert = true))
} else {
allEntries.current?.forEach((entry) => (entry.inert = false))
}
},
[_setErrorsOpen]
)
const setTagsOpen = useCallback(
(open: boolean) => {
_setTagsOpen(open)
if (open) {
allEntries.current?.forEach((entry) => (entry.inert = true))
} else {
allEntries.current?.forEach((entry) => (entry.inert = false))
}
},
[_setTagsOpen]
)
const convertToItem = useCallback(
(arr: string[], { capitalize = false }: { capitalize?: boolean } = {}) =>
arr.map((item) => ({
value: item,
label: capitalize ? item[0].toUpperCase() + item.slice(1) : item,
})),
[]
)
return (
<>
<h2 className="sr-only">Search and filter</h2>
<div className={cn('flex flex-wrap gap-2 items-center', className)}>
<MultiSelect
open={productsOpen}
onOpenChange={setProductsOpen}
selected={convertToItem(selectedProducts, { capitalize: true })}
onSelectedChange={(newItemsOrCreateNewItems) => {
const newItems =
typeof newItemsOrCreateNewItems === 'function'
? newItemsOrCreateNewItems(convertToItem(selectedProducts, { capitalize: true }))
: newItemsOrCreateNewItems
setSelectedProducts(newItems.map((item) => item.value))
}}
>
<MultiSelect.Trigger className="w-48" label="Products" />
<MultiSelect.Content sameWidthAsTrigger>
{products.map((product) => (
<MultiSelect.Item
item={{ value: product, label: product[0].toUpperCase() + product.slice(1) }}
/>
))}
</MultiSelect.Content>
</MultiSelect>
<MultiSelect
open={errorsOpen}
onOpenChange={setErrorsOpen}
selected={convertToItem(selectedErrorCodes, { capitalize: true })}
onSelectedChange={(newItemsOrCreateNewItems) => {
const newItems =
typeof newItemsOrCreateNewItems === 'function'
? newItemsOrCreateNewItems(convertToItem(selectedErrorCodes, { capitalize: true }))
: newItemsOrCreateNewItems
setSelectedErrorCodes(newItems.map((item) => item.value))
}}
>
<MultiSelect.Trigger className="w-48" label="Error codes" />
<MultiSelect.Content sameWidthAsTrigger>
{errors.map((error) => (
<MultiSelect.Item item={{ value: formatError(error), label: formatError(error) }} />
))}
</MultiSelect.Content>
</MultiSelect>
<MultiSelect
open={tagsOpen}
onOpenChange={setTagsOpen}
selected={convertToItem(selectedTags, { capitalize: true })}
onSelectedChange={(newItemsOrCreateNewItems) => {
const newItems =
typeof newItemsOrCreateNewItems === 'function'
? newItemsOrCreateNewItems(convertToItem(selectedTags, { capitalize: true }))
: newItemsOrCreateNewItems
setSelectedTags(newItems.map((item) => item.value))
}}
>
<MultiSelect.Trigger className="w-48" label="Tags" />
<MultiSelect.Content sameWidthAsTrigger>
{keywords.map((keyword) => (
<MultiSelect.Item item={{ value: keyword, label: keyword }} />
))}
</MultiSelect.Content>
</MultiSelect>
<div className="relative">
<Input_Shadcn_
id="troubleshooting-search"
ref={searchInputRef}
type="text"
placeholder="Search by keyword"
className="pl-8 pr-8 h-[40px] w-60 rounded-md border-alternative placeholder:text-foreground-light"
value={searchState}
onChange={(e) => setSearchState(e.target.value)}
/>
<Search
aria-hidden
className="absolute left-2 top-1/2 -translate-y-1/2 text-foreground-light"
size={16}
/>
{searchState && (
<button
className="absolute right-1 top-1/2 -translate-y-1/2 text-foreground-light border-alternative hover:border-stronger rounded-md p-1 transition-colors"
onClick={() => {
setSearchState('')
searchInputRef.current?.focus()
}}
>
<span className="sr-only">Clear search</span>
<X size={16} />
</button>
)}
</div>
<Button_Shadcn_
variant="outline"
className="rounded-md text-foreground-light h-[40px] w-[40px] p-0"
onClick={reset}
>
<RotateCw size={16} />
<span className="sr-only">Reset filters</span>
</Button_Shadcn_>
</div>
</>
)
}
export function TroubleshootingFilterEmptyState() {
return (
<Suspense>
<TroubleshootingFilterEmptyStateInternal />
</Suspense>
)
}
function TroubleshootingFilterEmptyStateInternal() {
const allEntries = useRef<HTMLElement[] | undefined>(undefined)
const { selectedProducts, selectedErrorCodes, selectedTags, searchState, reset } =
useTroubleshootingSearchState()
const [numberResults, setNumberResults] = useState<number | undefined>(undefined)
useEffect(() => {
const container = document.getElementById(TROUBLESHOOTING_CONTAINER_ID)
if (!container) return
const entries = Array.from(
container.querySelectorAll(
`[${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE}="${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY}"]`
)
) as HTMLElement[]
allEntries.current = entries
}, [])
useEffect(() => {
if (!allEntries.current) return
const numberEntries = allEntries.current.filter((entry) => !entry.hidden).length
setNumberResults(numberEntries)
}, [searchState, selectedProducts, selectedErrorCodes, selectedTags])
return numberResults === 0 ? (
<span className="flex items-center gap-4 text-foreground-light">
No results found.
<Button_Shadcn_ variant="outline" className="flex items-center gap-2" onClick={reset}>
<RotateCw size={16} /> Reset filters
</Button_Shadcn_>
</span>
) : null
}
/**
* This component is used to control the visibility of the list of
* troubleshooting entries.
*
* Filtering is done wth imperative DOM manipulation rather than mapping and
* filtering the target list in React, in order to opt the full troubleshooting
* list into server-side rendering.
*/
export function TroubleshootingListController() {
return (
<Suspense>
<TroubleshootingListControllerInternal />
</Suspense>
)
}
function TroubleshootingListControllerInternal() {
const allEntries = useRef<HTMLElement[]>([])
const { selectedProducts, selectedErrorCodes, selectedTags, searchState } =
useTroubleshootingSearchState()
useEffect(() => {
const container = document.getElementById(TROUBLESHOOTING_CONTAINER_ID)
if (!container) return
const entries = Array.from(
container.querySelectorAll(
`[${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE}="${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY}"]`
)
) as HTMLElement[]
allEntries.current = entries
}, [])
useEffect(() => {
if (
!searchState &&
selectedProducts.length === 0 &&
selectedErrorCodes.length === 0 &&
selectedTags.length === 0
) {
allEntries.current.forEach((entry) => {
entry.hidden = false
})
} else {
allEntries.current.forEach((entry) => {
entry.hidden = !entryMatchesFilter(
entry,
selectedProducts,
selectedErrorCodes,
selectedTags,
searchState
)
})
}
}, [searchState, selectedProducts, selectedErrorCodes, selectedTags])
return null
}
@@ -0,0 +1,75 @@
import { Wrench } from 'lucide-react'
import Link from 'next/link'
import { type PropsWithChildren, useCallback } from 'react'
import { type ITroubleshootingEntry, getArticleSlug } from './Troubleshooting.utils'
import { TroubleshootingFilter } from './Troubleshooting.ui.client'
import { formatError, TROUBLESHOOTING_DATA_ATTRIBUTES } from './Troubleshooting.utils.shared'
import { cn } from 'ui'
export function TroubleshootingPreview({ entry }: { entry: ITroubleshootingEntry }) {
const keywords = [...entry.data.topics, ...(entry.data.keywords ?? [])]
const attributes = {
[TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE]:
TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY,
[TROUBLESHOOTING_DATA_ATTRIBUTES.PRODUCTS_LIST_ATTRIBUTE]: entry.data.topics.join(','),
[TROUBLESHOOTING_DATA_ATTRIBUTES.KEYWORDS_LIST_ATTRIBUTE]: keywords.join(','),
[TROUBLESHOOTING_DATA_ATTRIBUTES.ERRORS_LIST_ATTRIBUTE]: entry.data.errors
?.map((error) => formatError(error))
.join(','),
}
return (
<div
className="relative border-b py-4"
aria-labelledby={`troubleshooting-entry-title-${entry.data.database_id}`}
{...attributes}
>
<div className="flex flex-wrap items-start gap-x-2 gap-y-4 justify-between">
<div className="w-full flex flex-col gap-2">
<Link
href={`/guides/troubleshooting/${getArticleSlug(entry.data)}`}
className={cn('visited:text-foreground-lighter', 'before:absolute before:inset-0')}
>
<h3
id={`troubleshooting-entry-title-${entry.data.database_id}`}
className="text-lg truncate"
>
{entry.data.title}
</h3>
</Link>
{entry.data.errors?.length > 0 && (
<span className="text-xs text-foreground-lighter">
{entry.data.errors.map((error, index) => (
<>
<code key={index}>{formatError(error)}</code>
{index < entry.data.errors.length - 1 && ', '}
</>
))}
</span>
)}
</div>
<div className="basis-60 flex-shrink-0 flex-grow-0 flex flex-row gap-2">
<div className="basis-40 flex-shrink-0 flex-grow-0 flex flex-wrap gap-0.5 text-xs">
{entry.data.topics.map((topic) => (
<span key={topic} className="px-2 border rounded-full inline-flex items-center">
{topic[0].toUpperCase() + topic.slice(1)}
</span>
))}
</div>
<div className="basis-l8 flex-shrink-0 flex-grow-0 truncate text-sm text-foreground-lighter">
{entry.data.updated_at &&
(() => {
const date = new Date(entry.data.updated_at)
const options = { month: 'short', day: 'numeric' } as Intl.DateTimeFormatOptions
if (date.getFullYear() !== new Date().getFullYear()) {
options.year = 'numeric'
}
return date.toLocaleDateString(undefined, options)
})()}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,24 @@
import { createSerializer, parseAsArrayOf, parseAsString } from 'nuqs'
import { ITroubleshootingEntry } from './Troubleshooting.utils'
export const TROUBLESHOOTING_CONTAINER_ID = 'sb-docs-troubleshooting-container'
export const TROUBLESHOOTING_DATA_ATTRIBUTES = {
QUERY_ATTRIBUTE: 'data-sb-docs-troubleshooting',
QUERY_VALUE_ENTRY: 'entry',
PRODUCTS_LIST_ATTRIBUTE: 'data-products',
KEYWORDS_LIST_ATTRIBUTE: 'data-keywords',
ERRORS_LIST_ATTRIBUTE: 'data-errors',
}
export function formatError(error: ITroubleshootingEntry['data']['errors'][number]) {
return `${error.http_status_code ?? ''}${!!error.http_status_code && !!error.code && ' '}${error.code ?? ''}`
}
export const troubleshootingSearchParams = {
search: parseAsString.withDefault(''),
products: parseAsArrayOf(parseAsString).withDefault([]),
tags: parseAsArrayOf(parseAsString).withDefault([]),
errorCodes: parseAsArrayOf(parseAsString).withDefault([]),
}
export const serializeTroubleshootingSearchParams = createSerializer(troubleshootingSearchParams)
+184 -37
View File
@@ -1,45 +1,192 @@
import matter from 'gray-matter'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { mdxFromMarkdown } from 'mdast-util-mdx'
import { toMarkdown } from 'mdast-util-to-markdown'
import { mdxjs } from 'micromark-extension-mdxjs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import toml from 'toml'
import { visit } from 'unist-util-visit'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
const TroubleshootingSchema = z.object({
title: z.string(),
topics: z.array(
z.enum([
'ai',
'auth',
'branching',
'cli',
'database',
'functions',
'platform',
'realtime',
'self-hosting',
'storage',
'studio',
'supavisor',
'terraform',
])
),
keywords: z.array(z.string()).optional(),
api: z
.object({
sdk: z.array(z.string()).optional(),
management_api: z.array(z.string()).optional(),
cli: z.array(z.string()).optional(),
})
.optional(),
errors: z
.array(
z.object({
httpStatusCode: z.number().optional(),
code: z.string().optional(),
message: z.string().optional(),
import { DOCS_DIRECTORY } from 'lib/docs'
import { cache_fullProcess_withDevCacheBust } from '~/features/helpers.fs'
import { formatError } from './Troubleshooting.utils.shared'
const TROUBLESHOOTING_DIRECTORY = join(DOCS_DIRECTORY, 'content/troubleshooting')
const TroubleshootingSchema = z
.object({
title: z.string(),
topics: z.array(
z.enum([
'ai',
'auth',
'branching',
'cli',
'database',
'functions',
'platform',
'realtime',
'self-hosting',
'storage',
'studio',
'supavisor',
'terraform',
])
),
keywords: z.array(z.string()).optional(),
api: z
.object({
sdk: z.array(z.string()).optional(),
management_api: z.array(z.string()).optional(),
cli: z.array(z.string()).optional(),
})
)
.optional(),
})
.strict()
.optional(),
errors: z
.array(
z
.object({
http_status_code: z.number().optional(),
code: z.string().optional(),
message: z.string().optional(),
})
.strict()
)
.optional(),
database_id: z.string().uuid().default(uuidv4()),
created_at: z.date({ coerce: true }).optional(),
updated_at: z.date({ coerce: true }).optional(),
})
.strict()
export type ITroubleshootingMetadata = z.infer<typeof TroubleshootingSchema>
export const validateTroubleshootingMetadata = (troubleshootingMetadata: unknown) => {
const validateTroubleshootingMetadata = (troubleshootingMetadata: unknown) => {
return TroubleshootingSchema.safeParse(troubleshootingMetadata)
}
async function getAllTroubleshootingEntriesInternal() {
const troubleshootingDirectoryContents = await readdir(TROUBLESHOOTING_DIRECTORY, {
recursive: true,
})
const troubleshootingFiles = troubleshootingDirectoryContents.map(async (entry) => {
const isHidden = entry.startsWith('_')
if (isHidden) return null
const isFile = (await stat(join(TROUBLESHOOTING_DIRECTORY, entry))).isFile()
if (!isFile) return null
const fileContents = await readFile(join(TROUBLESHOOTING_DIRECTORY, entry), 'utf-8')
const { content, data: frontmatter } = matter(fileContents, {
language: 'toml',
engines: { toml: toml.parse.bind(toml) },
})
const parseResult = validateTroubleshootingMetadata(frontmatter)
if ('error' in parseResult) {
console.error(`Error validating troubleshooting metadata for ${entry}:`, parseResult.error)
return null
}
const mdxTree = fromMarkdown(content, {
extensions: [mdxjs()],
mdastExtensions: [mdxFromMarkdown()],
})
visit(mdxTree, (node) => {
if ('children' in node) {
node.children = node.children.filter(
(child) =>
![
'mdxJsxFlowExpression',
'mdxJsxTextExpression',
'mdxFlowExpression',
'mdxTextExpression',
'mdxJsxFlowElement',
'mdxJsxTextElement',
'mdxJsxExpressionAttribute',
'mdxJsxAttribute',
'mdxJsxAttributeValueExpression',
'mdxjsEsm',
].includes(child.type)
)
}
})
const contentWithoutJsx = toMarkdown(mdxTree)
return {
content,
contentWithoutJsx,
data: parseResult.data,
}
})
return (await Promise.all(troubleshootingFiles)).filter(Boolean)
}
export const getAllTroubleshootingEntries = cache_fullProcess_withDevCacheBust(
getAllTroubleshootingEntriesInternal,
TROUBLESHOOTING_DIRECTORY,
() => JSON.stringify([])
)
export type ITroubleshootingEntry = Awaited<ReturnType<typeof getAllTroubleshootingEntries>>[number]
export async function getAllTroubleshootingKeywords() {
const entries = await getAllTroubleshootingEntries()
const keywords = new Set<string>()
for (const entry of entries) {
for (const topic of entry.data.topics) {
keywords.add(topic)
}
for (const keyword of entry.data.keywords ?? []) {
keywords.add(keyword)
}
}
return Array.from(keywords).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
}
export async function getAllTroubleshootingProducts() {
const entries = await getAllTroubleshootingEntries()
const products = new Set<string>()
for (const entry of entries) {
for (const topic of entry.data.topics) {
products.add(topic)
}
}
return Array.from(products).sort((a, b) => a.localeCompare(b))
}
export async function getAllTroubleshootingErrors() {
const entries = await getAllTroubleshootingEntries()
const allErrors = new Set(
entries
.flatMap((entry) => entry.data.errors)
.filter((error) => error?.http_status_code || error?.code)
)
const seen = new Set<string>()
for (const error of allErrors) {
const key = formatError(error)
if (seen.has(key)) {
allErrors.delete(error)
}
seen.add(key)
}
function sortErrors(
a: ITroubleshootingMetadata['errors'][number],
b: ITroubleshootingMetadata['errors'][number]
) {
return formatError(a).localeCompare(formatError(b))
}
return Array.from(allErrors).sort(sortErrors)
}
export function getArticleSlug(entry: ITroubleshootingMetadata) {
const slugifiedTitle = entry.title.toLowerCase().replace(/\s+/g, '-')
const escapedTitle = encodeURIComponent(slugifiedTitle)
return escapedTitle
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { type FC, type PropsWithChildren } from 'react'
import { cn } from 'ui'
export const LayoutMainContent: FC<PropsWithChildren<{ className?: string }>> = ({
className,
children,
}) => <div className={['max-w-7xl px-5 mx-auto py-8', className].join(' ')}>{children}</div>
}) => <div className={cn('max-w-7xl px-5 mx-auto py-8', className)}>{children}</div>
+25 -10
View File
@@ -157,9 +157,10 @@ const levelsData = {
},
}
const MobileHeader = memo(function MobileHeader({ menuId }: { menuId: MenuId }) {
type MobileHeaderProps = { menuId: MenuId } | { menuName: string }
const MobileHeader = memo(function MobileHeader(props: MobileHeaderProps) {
const mobileMenuOpen = useMenuMobileOpen()
const menuLevel = menuId
return (
<div
@@ -208,9 +209,9 @@ const MobileHeader = memo(function MobileHeader({ menuId }: { menuId: MenuId })
>
{mobileMenuOpen
? 'Close'
: menuLevel
? levelsData[menuLevel]?.name
: levelsData['home'].name}
: 'menuId' in props
? levelsData[props.menuId]?.name ?? levelsData['home'].name
: props.menuName}
</span>
</div>
)
@@ -326,7 +327,10 @@ const NavContainer = memo(function NavContainer({ children }: PropsWithChildren)
interface SkeletonProps extends PropsWithChildren {
menuId?: MenuId
menuName?: string
NavigationMenu?: ReactNode
hideFooter?: boolean
className?: string
}
function TopNavSkeleton({ children }) {
@@ -340,12 +344,19 @@ function TopNavSkeleton({ children }) {
)
}
function SidebarSkeleton({ children, menuId, NavigationMenu }: SkeletonProps) {
function SidebarSkeleton({
children,
menuId,
menuName,
NavigationMenu,
hideFooter = false,
className,
}: SkeletonProps) {
const mobileMenuOpen = useMenuMobileOpen()
const hideSideNav = !menuId
const hideSideNav = !menuId && !NavigationMenu
return (
<div className="flex flex-row h-full relative">
<div className={cn('flex flex-row h-full relative', className)}>
{!hideSideNav && (
<NavContainer>{NavigationMenu ?? <DefaultNavigationMenu menuId={menuId} />}</NavContainer>
)}
@@ -366,11 +377,15 @@ function SidebarSkeleton({ children, menuId, NavigationMenu }: SkeletonProps) {
'backdrop-blur backdrop-filter bg-background'
)}
>
{!hideSideNav && <MobileHeader menuId={menuId} />}
{hideSideNav ? null : menuName ? (
<MobileHeader menuName={menuName} />
) : (
<MobileHeader menuId={menuId} />
)}
</div>
<div className="grow">
{children}
<Footer />
{!hideFooter && <Footer />}
</div>
<MobileMenuBackdrop />
</Container>
+1
View File
@@ -10,3 +10,4 @@ export const IS_PREVIEW = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
export const IS_PRODUCTION = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
export const LOCAL_SUPABASE = process.env.NEXT_PUBLIC_LOCAL_SUPABASE === 'true'
export const MISC_URL = process.env.NEXT_PUBLIC_MISC_URL ?? ''
export const PROD_URL = `https://supabase.com${BASE_PATH}`
+1 -1
View File
@@ -14,7 +14,7 @@ import codeHikeTheme from 'config/code-hike.theme.json' assert { type: 'json' }
// MUST be process.cwd() here, not import.meta.url, or files that are added
// with outputFileTracingIncludes (not auto-traced) will not be found at
// runtime.
const DOCS_DIRECTORY = process.cwd()
export const DOCS_DIRECTORY = process.cwd()
export const EXAMPLES_DIRECTORY = join(DOCS_DIRECTORY, '..', '..', 'examples')
export const GUIDES_DIRECTORY = join(DOCS_DIRECTORY, 'content/guides')
export const REF_DOCS_DIRECTORY = join(DOCS_DIRECTORY, 'docs/ref')
+1 -2
View File
@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+1
View File
@@ -76,6 +76,7 @@
"next-plugin-yaml": "^1.0.1",
"next-seo": "^6.5.0",
"next-themes": "^0.3.0",
"nuqs": "^1.19.1",
"openai": "^4.20.1",
"openapi-fetch": "^0.8.2",
"react": "^18.2.0",
+7
View File
@@ -342,3 +342,10 @@ th code {
background-position: 1000px 0;
}
}
.skip-link {
@apply sr-only;
}
.skip-link:focus {
@apply not-sr-only;
}
+5 -3
View File
@@ -907,6 +907,7 @@
"next-plugin-yaml": "^1.0.1",
"next-seo": "^6.5.0",
"next-themes": "^0.3.0",
"nuqs": "^1.19.1",
"openai": "^4.20.1",
"openapi-fetch": "^0.8.2",
"react": "^18.2.0",
@@ -32723,9 +32724,9 @@
"license": "MIT"
},
"node_modules/nuqs": {
"version": "1.17.8",
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-1.17.8.tgz",
"integrity": "sha512-JqsnzO+hJyjJE7ebuhpHMLA2iGY48e2xr0oJQFhj7kjUmDABL2XOup47rxF5TL/5b9jEsmU2t0lAKin1VdK1/A==",
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-1.19.1.tgz",
"integrity": "sha512-oixldNThB1wbu6B5K961++7wpTz/EZFPWnraGmIQhibDT+YxRJNplWMIoPJgL4dlsiSDVI5bbUWKpzsIWVh3Pg==",
"dependencies": {
"mitt": "^3.0.1"
},
@@ -43333,6 +43334,7 @@
"@babel/preset-env": "^7.23.6",
"@jest/globals": "^29.7.0",
"@types/common-tags": "^1.8.4",
"api-types": "*",
"babel-jest": "^29.7.0",
"babel-plugin-transform-import-meta": "^2.2.1",
"chalk": "^5.3.0",