diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index dfbd3ef9d71e9..6fca567215c6d 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2840,10 +2840,11 @@ export const self_hosting: NavMenuConstant = { { name: 'How-to Guides', items: [ - { name: 'Enabling MCP server', url: '/guides/self-hosting/enable-mcp' }, + { name: 'Self-Hosted Functions', url: '/guides/self-hosting/self-hosted-functions' }, { name: 'Restore from Platform', url: '/guides/self-hosting/restore-from-platform' }, { name: 'Configure S3 Storage', url: '/guides/self-hosting/self-hosted-s3' }, { name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' }, + { name: 'Enabling MCP server', url: '/guides/self-hosting/enable-mcp' }, ], }, { @@ -2896,6 +2897,11 @@ export const self_hosting: NavMenuConstant = { url: '/reference/self-hosting-functions/introduction', items: [], }, + { + name: 'Guide', + url: '/guides/self-hosting/self-hosted-functions', + items: [], + }, ], }, ], diff --git a/apps/docs/content/guides/database/replication/replication-setup.mdx b/apps/docs/content/guides/database/replication/replication-setup.mdx index e334a4ab02fb3..f0f3ce70a7eb6 100644 --- a/apps/docs/content/guides/database/replication/replication-setup.mdx +++ b/apps/docs/content/guides/database/replication/replication-setup.mdx @@ -149,6 +149,7 @@ First, create an analytics bucket to store your replicated data: 3. Fill in the bucket details: {' '} + Analytics bucket details <$CodeSample - path="/user-management/solid-user-management/.env.example" - lines={[[1, -1]]} - meta="name=.env" +path="/user-management/solid-user-management/.env.example" +lines={[[1, -1]]} +meta="name=.env" /> @@ -52,9 +52,9 @@ on the browser, and that's completely fine since you have [Row Level Security](/ <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/supabaseClient.tsx" - lines={[[1, -1]]} - meta="name=src/supabaseClient.tsx" +path="/user-management/solid-user-management/src/supabaseClient.tsx" +lines={[[1, -1]]} +meta="name=src/supabaseClient.tsx" /> @@ -71,9 +71,9 @@ Set up a SolidJS component to manage logins and sign ups using Magic Links, so u <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/Auth.tsx" - lines={[[1, -1]]} - meta="name=src/Auth.tsx" +path="/user-management/solid-user-management/src/Auth.tsx" +lines={[[1, -1]]} +meta="name=src/Auth.tsx" /> @@ -87,9 +87,9 @@ Create a new component for that called `Account.tsx`. <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/Account.tsx" - lines={[[1, 1], [3, 78], [87, -1]]} - meta="name=src/Account.tsx" +path="/user-management/solid-user-management/src/Account.tsx" +lines={[[1, 1], [3, 78], [87, -1]]} +meta="name=src/Account.tsx" /> @@ -101,9 +101,9 @@ Now that you have all the components in place, update `App.tsx`: <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/App.tsx" - lines={[[1, -1]]} - meta="name=src/App.tsx" +path="/user-management/solid-user-management/src/App.tsx" +lines={[[1, -1]]} +meta="name=src/App.tsx" /> @@ -129,9 +129,9 @@ Create an avatar for the user so that they can upload a profile photo. Start by <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/Avatar.tsx" - lines={[[1, -1]]} - meta="name=src/Avatar.tsx" +path="/user-management/solid-user-management/src/Avatar.tsx" +lines={[[1, -1]]} +meta="name=src/Avatar.tsx" /> @@ -143,9 +143,9 @@ And then add the widget to the Account page: <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/Account.tsx" - lines={[[1, 3], [76, 88]]} - meta="name=src/Account.tsx" +path="/user-management/solid-user-management/src/Account.tsx" +lines={[[1, 3], [76, 88]]} +meta="name=src/Account.tsx" /> diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index 500dafdef2953..74146dcdc8db2 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -145,9 +145,7 @@ To generate and apply all secrets at once you can run: sh ./utils/generate-keys.sh ``` -The script is experimental, so review the output before proceeding and also check `.env` after it's updated by the script. - -**Alternatively, configure all secrets manually as follows.** +Review the output before proceeding and also check `.env` after it's updated by the script. Alternatively, configure all secrets manually as follows. ### Configure database password @@ -161,10 +159,9 @@ Follow the [password guidelines](/docs/guides/database/postgres/roles#passwords) Use the key generator below to obtain and configure the following secure keys in `.env`: -- `JWT_SECRET`: Used by PostgREST and GoTrue to sign and verify JWTs. +- `JWT_SECRET`: Used by Auth, PostgREST, and other services to sign and verify JWTs. - `ANON_KEY`: Client-side API key with limited permissions (`anon` role). Use this in your frontend applications. -- `SERVICE_ROLE_KEY`: Server-side API key with full database access (`service_role` role). Never expose this in client code. - +- `SERVICE_ROLE_KEY`: Server-side API key with full database access (`service_role` role). **Never expose this in client code.** 1. Copy the generated value and update `JWT_SECRET` in the `.env` file. Do not share this secret publicly or commit it to version control. @@ -182,16 +179,16 @@ Edit the following settings in the `.env` file: - `PG_META_CRYPTO_KEY`: encryption key for securing connection strings used by Studio against postgres-meta. (Must be at least 32 characters; generate with `openssl rand -base64 24`) - `LOGFLARE_PUBLIC_ACCESS_TOKEN`: API token for log ingestion and querying. Used by Vector and Studio to send and query logs. (Must be at least 32 characters; generate with `openssl rand -base64 24`) - `LOGFLARE_PRIVATE_ACCESS_TOKEN`: API token for Logflare management operations. Used by Studio for administrative tasks. Never expose client-side. (Must be at least 32 characters; generate with `openssl rand -base64 24`) -- `S3_PROTOCOL_ACCESS_KEY_ID`: Access key ID (username-like) for authenticating API requests to S3-compatible storage. (Generate with `openssl rand -hex 16`) -- `S3_PROTOCOL_ACCESS_KEY_SECRET`: Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID to sign and authorize S3 storage operations. (Generate with `openssl rand -hex 32`) +- `S3_PROTOCOL_ACCESS_KEY_ID`: Access key ID (username-like) for [accessing](/docs/guides/self-hosting/self-hosted-s3) the S3 protocol endpoint in Storage. (Generate with `openssl rand -hex 16`) +- `S3_PROTOCOL_ACCESS_KEY_SECRET`: Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID. (Generate with `openssl rand -hex 32`) {/* supa-mdx-lint-disable-next-line Rule003Spelling */} -- `MINIO_ROOT_PASSWORD`: Root administrator password for the MinIO server. (Must be 8+ characters; generate with `openssl rand -hex 16`) +- `MINIO_ROOT_PASSWORD`: Root administrator password for the [MinIO server](/docs/guides/self-hosting/self-hosted-s3#using-minio). (Must be 8+ characters; generate with `openssl rand -hex 16`) Review and change URL environment variables: -- `SUPABASE_PUBLIC_URL`: the base URL for accessing your Supabase via the Internet, e.g, `http://example.com:8000` -- `API_EXTERNAL_URL`: the base URL for API requests, e.g., `http://example.com:8000` -- `SITE_URL`: the base URL of your site, e.g., `http://example.com:3000` +- `SUPABASE_PUBLIC_URL`: base URL for accessing Supabase from the Internet (Dashboard, API, Storage, etc.), e.g, `http://example.com:8000` +- `API_EXTERNAL_URL`: base URL of the Auth service as seen externally, e.g., `http://example.com:8000` +- `SITE_URL`: default [redirect URL](/docs/guides/auth/redirect-urls) for Auth, e.g., `http://example.com:3000` @@ -412,7 +409,7 @@ You can configure each Supabase service separately through environment variables #### Configuring an email server -You will need to use a production-ready SMTP server for sending emails. You can configure the SMTP server by updating the following environment variables: +You will need to use a production-ready SMTP server for sending emails. You can configure the SMTP server by updating the following environment variables in the `.env` file: ```sh .env SMTP_ADMIN_EMAIL= @@ -423,7 +420,7 @@ SMTP_PASS= SMTP_SENDER_NAME= ``` -We recommend using [AWS SES](https://aws.amazon.com/ses/). It's extremely cheap and reliable. Restart all services to pick up the new configuration. +We recommend using [AWS SES](https://aws.amazon.com/ses/). It's affordable and reliable. Restart all services to pick up the new configuration. #### Configuring S3 Storage {/* supa-mdx-lint-disable-next-line Rule003Spelling */} @@ -433,35 +430,18 @@ See the [Configure S3 Storage](/docs/guides/self-hosting/self-hosted-s3) guide f #### Configuring Supabase AI Assistant -Configuring the Supabase AI Assistant is optional. By adding your own `OPENAI_API_KEY`, you can enable AI services, which help with writing SQL queries, statements, and policies. - -<$CodeTabs> - -```yaml name=docker-compose.yml -services: - studio: - image: supabase/studio - environment: - OPENAI_API_KEY: ${OPENAI_API_KEY:-} -``` - -```bash name=.env -## Never check your secrets into version control -`${OPENAI_API_KEY}` -``` - - +Configuring the Supabase AI Assistant is optional. By adding **your own** `OPENAI_API_KEY` to `.env` you can enable AI services, which help with writing SQL queries, statements, and policies. #### Setting database's `log_min_messages` -By default, `docker compose` sets the database's `log_min_messages` configuration to `fatal` to prevent redundant logs generated by Realtime. You can configure `log_min_messages` using any of the Postgres [Severity Levels](https://www.postgresql.org/docs/current/runtime-config-logging.html#RUNTIME-CONFIG-SEVERITY-LEVELS). +By default, the database's `log_min_messages` configuration is set to `fatal` in [docker-compose.yml](https://github.com/supabase/supabase/blob/df8729a82b1847e2989c14ede27965612761d503/docker/docker-compose.yml#L466) to prevent redundant logs generated by Realtime. You can configure `log_min_messages` using any of the Postgres [Severity Levels](https://www.postgresql.org/docs/current/runtime-config-logging.html#RUNTIME-CONFIG-SEVERITY-LEVELS). #### Accessing Postgres through Supavisor By default, Postgres connections go through the Supavisor connection pooler for efficient connection management. Two ports are available: -- `POSTGRES_PORT` (default: 5432) – Session mode, behaves like a direct Postgres connection -- `POOLER_PROXY_PORT_TRANSACTION` (default: 6543) – Transaction mode, uses connection pooling +- `POSTGRES_PORT` (default: 5432) - Session mode, behaves like a direct Postgres connection +- `POOLER_PROXY_PORT_TRANSACTION` (default: 6543) - Transaction mode, uses connection pooling For more information on configuring and using Supavisor, see the [Supavisor documentation](https://supabase.github.io/supavisor/). @@ -505,7 +485,7 @@ The script generates a new password, updates all database roles, and modifies yo #### File storage backend on macOS -By default, Storage backend is set to `file`, which is to use local files as the storage backend. For macOS compatibility, you need to choose `VirtioFS` as the Docker container file sharing implementation (in Docker Desktop -> Preferences -> General). +By default, Storage backend is set to `file`, which is to use local files as the storage backend. If using Docker Desktop on a Mac, choose `VirtioFS` as the Docker container file sharing implementation (in **Preferences** > **General**). ## Managing your secrets diff --git a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx new file mode 100644 index 0000000000000..f8707dc48ac73 --- /dev/null +++ b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx @@ -0,0 +1,239 @@ +--- +title: 'Self-Hosted Functions' +description: 'Run and manage Edge Functions in your self-hosted Supabase instance.' +subtitle: 'Run and manage Edge Functions in your self-hosted Supabase instance.' +--- + +Edge Functions work out of the box in a self-hosted Supabase setup. The `functions` service, API gateway routing, and a `hello` example function are all [pre-configured](https://github.com/supabase/supabase/tree/master/docker). + + + +On managed Supabase platform, Edge Functions are deployed across multiple regions. Self-hosted standalone instance configuration resembles a standard serverless setup. + + + +## Invoke the default function + +The default `hello` function is located at `volumes/functions/hello/index.ts`. You can invoke it immediately after starting your stack: + +```bash +curl http://:8000/functions/v1/hello +``` + +This returns `"Hello from Edge Functions!"`. + +## Create a new function + +### Step 1: Create a new directory with an `index.ts` file in `volumes/functions/`: + +``` +mkdir -p volumes/functions/my-function && +touch volumes/functions/my-function/index.ts +``` + +add the following code to `index.ts`: + +```typescript +Deno.serve(async (req: Request) => { + const { name } = await req.json() + const message = `Hello, ${name}!` + + return new Response(JSON.stringify({ message }), { + headers: { 'Content-Type': 'application/json' }, + }) +}) +``` + +### Step 2: Restart the functions service to pick up the new function: + +```bash +docker compose restart functions --no-deps +``` + +### Step 3: Invoke your function: + +```bash +curl -X POST http://:8000/functions/v1/my-function \ + -H 'Content-Type: application/json' \ + -d '{"name": "World"}' +``` + +You should be able to see the response from `my-function`: + +``` +{"message":"Hello, World!"} +``` + +## Custom environment variables + +### Using an env file (recommended) + +For multiple variables or secrets, create a separate env file, e.g., `.env.functions` in your `docker/` directory: + +```bash +MY_CUSTOM_VAR=some-value +``` + +Add `env_file` to the `functions` service in `docker-compose.yml` (variables in `env_file` load first, then `environment` values take precedence): + +```yaml +functions: + env_file: + - .env.functions + environment: + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 +``` + + + +Don't commit `.env.functions` to version control if it contains secrets. Add it to your `.gitignore`. + + + +Restart the functions service: + +```bash +docker compose up -d --force-recreate --no-deps functions +``` + +### Using inline environment variables + +For one or two variables, you can add them directly under `environment` in `docker-compose.yml`: + +```yaml +functions: + environment: + # Custom variables + MY_CUSTOM_VAR: ${MY_CUSTOM_VAR} + # Required variables + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 +``` + +Then define `MY_CUSTOM_VAR` in your main `.env` file, or specify the value directly. + +### Accessing variables in functions + +All container environment variables are forwarded to function workers by `main/index.ts`. Access them with: + +```typescript +const customVar = Deno.env.get('MY_CUSTOM_VAR') +``` + +## Calling Supabase services from functions + +The functions service is pre-configured with the following environment variables: + +| Variable | Value | Purpose | +| --- | --- | --- | +| `SUPABASE_URL` | `http://kong:8000` | Internal API gateway URL | +| `SUPABASE_PUBLIC_URL` | `http://:8000` | Base URL for accessing Supabase from the Internet | +| `JWT_SECRET` | Your secret key | Legacy symmetric encryption key used to sign and verify JWTs | +| `SUPABASE_ANON_KEY` | Your anon key | Client-side API key with limited permissions (`anon` role). | +| `SUPABASE_SERVICE_ROLE_KEY` | Your service role key | Server-side API key with full database access (`service_role` role) | +| `SUPABASE_DB_URL` | Postgres connection string | Can be used for direct database access | + +Here's an example function that queries a table using `@supabase/supabase-js`: + +```typescript +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' + +Deno.serve(async () => { + const supabase = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + ) + + const { data, error } = await supabase.from('todos').select('*') + + return new Response(JSON.stringify({ data, error }), { + headers: { 'Content-Type': 'application/json' }, + }) +}) +``` + +### Internal vs external URLs + +This is a key distinction that affects how you build URLs in your functions: + +- **`SUPABASE_URL`** contains an internal Docker network hostname. Use it for server-side calls from your functions to other Supabase services (Auth, Storage, database via PostgREST). This is what the Supabase JS client should use inside functions. + +- **`SUPABASE_PUBLIC_URL`** is the externally-reachable URL of your Supabase instance (e.g., `:8000`). Use it if your function needs to build URLs that HTTP clients can reach from the outside. + +## Managing functions via dashboard + +Self-hosted Studio [mounts](https://github.com/supabase/supabase/blob/df8729a82b1847e2989c14ede27965612761d503/docker/docker-compose.yml#L66) the same `volumes/functions` directory as the functions service. You can check what functions are available using **Edge Functions** > **Functions** UI. + +## Deploying functions to a remote server + +To deploy a function to a remote server running self-hosted Supabase, copy the function directory with `scp`: + +```bash +scp -r ./my-function user@:/path/to/self-hosted/volumes/functions/ +``` + +Then restart the functions service on the remote host: + +```bash +ssh user@ 'cd /path/to/self-hosted && docker compose restart functions --no-deps' +``` + +## Copying functions from Supabase platform + +If you have existing functions on Supabase platform, you can download them and run them on your self-hosted instance. There are two ways to get the function source code: + +- **Dashboard** - open the function details in Dashboard and click **Download**. +- **Local development & CLI** - run `supabase functions download --project-ref ` to download the source. + +Use `scp` to copy the function into `volumes/functions//` on your self-hosted instance, then restart the functions service. + +For more details, see: + +- [Quick start - Download edge functions](/docs/guides/functions/quickstart-dashboard#download-edge-functions) +- [CLI commands - Download a function](/docs/reference/cli/supabase-functions-download) + +## Troubleshooting + +### 400 "missing function name in request" + +The request URL must include the function name after `/functions/v1/`. For example, `/functions/v1/hello` — not just `/functions/v1/`. + +### 500 error on invocation + +Check the functions service logs: + +```bash +docker compose logs functions +``` + +Common causes: syntax errors in your function code, invalid imports, or missing dependencies. + +### 401 "invalid JWT" + +- Check that `FUNCTIONS_VERIFY_JWT` matches your intent (`true` or `false`) in `.env` +- If verification is enabled, ensure you're passing a valid token: `Authorization: Bearer ` + +### Changes to function code not reflected after editing + +Restart the functions service: + +```bash +docker compose restart functions --no-deps +``` + +### Custom env vars not available in functions + +- Verify the variable is defined in `docker-compose.yml` (under `env_file` or `environment`) +- Recreate the functions container after changing configuration +- Check that the variable name matches exactly (case-sensitive) + +Use the following command to recreate the container, not just `restart`: + +```bash +docker compose up -d --force-recreate --no-deps functions +``` + +### Memory or timeout errors + +The default limits are 150 MB memory and 60 seconds timeout per function invocation. These are set in `volumes/functions/main/index.ts`. To adjust them, edit the `memoryLimitMb` and `workerTimeoutMs` values and restart the functions service. diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 4729acf5483ff..c8a6dbae32391 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -191,6 +191,7 @@ Sam Rose Sana Cordeaux Sara Read Sean Oliver +Sean Romberg Sean Thompson Sergio Cioban Filho Shane E diff --git a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx index 3b9cd6c2c1df4..6b6c61a501a05 100644 --- a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx @@ -1,10 +1,10 @@ +import { LOCAL_STORAGE_KEYS } from 'common' import { useRouter } from 'next/router' +import { TimestampInfo } from 'ui-patterns' +import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner' +import { InlineLink } from '@/components/ui/InlineLink' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { LOCAL_STORAGE_KEYS } from 'common' -import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' -import { InlineLink } from 'components/ui/InlineLink' -import { TimestampInfo } from 'ui-patterns' /** * Used to display urgent notices that apply for all users, such as maintenance windows. @@ -12,7 +12,7 @@ import { TimestampInfo } from 'ui-patterns' export const NoticeBanner = () => { const router = useRouter() - const [bannerAcknowledged, setBannerAcknowledge, { isSuccess }] = useLocalStorageQuery( + const [bannerAcknowledged, setBannerAcknowledged, { isSuccess }] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.MAINTENANCE_WINDOW_BANNER, false ) @@ -38,7 +38,7 @@ export const NoticeBanner = () => { } - onDismiss={() => setBannerAcknowledge(true)} + onDismiss={() => setBannerAcknowledged(true)} /> ) } diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx index 1254546603c62..f43caee3d6ce6 100644 --- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx @@ -1,7 +1,6 @@ -import { useFlag } from 'common' - import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner' import { InlineLink } from '@/components/ui/InlineLink' +import { useStatusPageBannerVisibility } from './useStatusPageBannerVisibility' const BANNER_DESCRIPTION = ( <> @@ -13,18 +12,9 @@ const BANNER_DESCRIPTION = ( * Used to display ongoing incidents */ export const StatusPageBanner = () => { - const showIncidentBanner = - useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' + const banner = useStatusPageBannerVisibility() - if (showIncidentBanner) { - return ( - - ) - } + if (!banner) return null - return null + return } diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts new file mode 100644 index 0000000000000..5b2618d773947 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest' + +import { shouldShowBanner } from './StatusPageBanner.utils' + +const noCache = { cache: null } as const +const noRestrictions = { + cache: { affected_regions: null, affects_project_creation: false }, +} +const affectsCreation = { + cache: { affected_regions: null, affects_project_creation: true }, +} +const usEast1Only = { + cache: { affected_regions: ['us-east-1'], affects_project_creation: false }, +} +const usEast1AndCreation = { + cache: { affected_regions: ['us-east-1'], affects_project_creation: true }, +} + +describe('shouldShowBanner', () => { + describe('no incidents', () => { + it('does not show when there are no incidents', () => { + expect( + shouldShowBanner({ incidents: [], hasProjects: true, userRegions: new Set(['us-east-1']) }) + ).toBe(false) + }) + }) + + describe('user has no projects', () => { + it('does not show when cache is absent', () => { + expect( + shouldShowBanner({ incidents: [noCache], hasProjects: false, userRegions: new Set() }) + ).toBe(false) + }) + + it('does not show when affects_project_creation is false', () => { + expect( + shouldShowBanner({ + incidents: [noRestrictions], + hasProjects: false, + userRegions: new Set(), + }) + ).toBe(false) + }) + + it('shows when affects_project_creation is true and no region restriction', () => { + expect( + shouldShowBanner({ + incidents: [affectsCreation], + hasProjects: false, + userRegions: new Set(), + }) + ).toBe(true) + }) + + it('shows when affects_project_creation is true even with a region restriction', () => { + expect( + shouldShowBanner({ + incidents: [usEast1AndCreation], + hasProjects: false, + userRegions: new Set(), + }) + ).toBe(true) + }) + }) + + describe('user has projects, no region restriction', () => { + it('shows when cache is absent', () => { + expect( + shouldShowBanner({ + incidents: [noCache], + hasProjects: true, + userRegions: new Set(['us-east-1']), + }) + ).toBe(true) + }) + + it('shows when affected_regions is null', () => { + expect( + shouldShowBanner({ + incidents: [noRestrictions], + hasProjects: true, + userRegions: new Set(['us-east-1']), + }) + ).toBe(true) + }) + + it('shows when affected_regions is an empty array', () => { + expect( + shouldShowBanner({ + incidents: [{ cache: { affected_regions: [], affects_project_creation: false } }], + hasProjects: true, + userRegions: new Set(['us-east-1']), + }) + ).toBe(true) + }) + }) + + describe('user has projects, with region restriction', () => { + it('shows when user has a primary database in an affected region', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only], + hasProjects: true, + userRegions: new Set(['us-east-1']), + }) + ).toBe(true) + }) + + it('shows when user has a read replica in an affected region', () => { + expect( + shouldShowBanner({ + incidents: [ + { cache: { affected_regions: ['eu-west-1'], affects_project_creation: false } }, + ], + hasProjects: true, + userRegions: new Set(['us-east-1', 'eu-west-1']), + }) + ).toBe(true) + }) + + it('shows when one of multiple affected regions matches', () => { + expect( + shouldShowBanner({ + incidents: [ + { + cache: { + affected_regions: ['us-east-1', 'ap-southeast-1'], + affects_project_creation: false, + }, + }, + ], + hasProjects: true, + userRegions: new Set(['ap-southeast-1']), + }) + ).toBe(true) + }) + + it('does not show when user has no databases in any affected region', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only], + hasProjects: true, + userRegions: new Set(['eu-west-1', 'ap-southeast-1']), + }) + ).toBe(false) + }) + + it('does not show when user has projects but no databases in affected region, even with affects_project_creation', () => { + expect( + shouldShowBanner({ + incidents: [usEast1AndCreation], + hasProjects: true, + userRegions: new Set(['eu-west-1']), + }) + ).toBe(false) + }) + }) + + describe('multiple incidents', () => { + it('shows when at least one incident matches even if others do not', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only, noRestrictions], + hasProjects: true, + userRegions: new Set(['eu-west-1']), + }) + ).toBe(true) + }) + + it('does not show when no incident matches', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only, usEast1AndCreation], + hasProjects: true, + userRegions: new Set(['eu-west-1']), + }) + ).toBe(false) + }) + + it('shows when any incident matches for a no-project user via affects_project_creation', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only, affectsCreation], + hasProjects: false, + userRegions: new Set(), + }) + ).toBe(true) + }) + }) + + describe('hasUnknownRegions', () => { + it('shows when regions are unknown and incident has a region restriction', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only], + hasProjects: true, + userRegions: new Set(), + hasUnknownRegions: true, + }) + ).toBe(true) + }) + + it('still applies no-projects check even when regions are unknown', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only], + hasProjects: false, + userRegions: new Set(), + hasUnknownRegions: true, + }) + ).toBe(false) + }) + + it('still shows for affects_project_creation with no projects even when regions are unknown', () => { + expect( + shouldShowBanner({ + incidents: [usEast1AndCreation], + hasProjects: false, + userRegions: new Set(), + hasUnknownRegions: true, + }) + ).toBe(true) + }) + }) +}) diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts new file mode 100644 index 0000000000000..d8fcf5f8f7b80 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts @@ -0,0 +1,44 @@ +import type { IncidentCache } from 'lib/api/incident-status' + +type BannerIncident = { cache?: IncidentCache | null } + +/** + * Determines whether the incident status banner should be shown to a given user, + * given all active incidents and the user's project state. + * + * Returns true if any incident matches the user's context. + * + * @param incidents - Active incidents from the incident-status endpoint + * @param hasProjects - Whether the user has any projects at all + * @param userRegions - Deduplicated set of regions of all databases (primary and read replicas) owned by the user + * @param hasUnknownRegions - True when region data is incomplete (org has >100 projects). + * When true, the region check is skipped and a match is assumed. + */ +export function shouldShowBanner({ + incidents, + hasProjects, + userRegions, + hasUnknownRegions = false, +}: { + incidents: Array + hasProjects: boolean + userRegions: Set + hasUnknownRegions?: boolean +}): boolean { + return incidents.some((incident) => { + const affectedRegions = incident.cache?.affected_regions ?? [] + const affectsProjectCreation = incident.cache?.affects_project_creation ?? false + + // Users with no projects only see the banner if the incident affects project creation + if (!hasProjects) return affectsProjectCreation + + // User has projects: if no region restriction, always show + if (affectedRegions.length === 0) return true + + // Region data is incomplete — assume the user has a database in an affected region + if (hasUnknownRegions) return true + + // Region restriction: only show if the user has a database in an affected region + return affectedRegions.some((region) => userRegions.has(region)) + }) +} diff --git a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts new file mode 100644 index 0000000000000..8975cba3b9a13 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts @@ -0,0 +1,61 @@ +import { useQueries } from '@tanstack/react-query' +import { useFlag } from 'common' + +import { shouldShowBanner } from './StatusPageBanner.utils' +import { useOrganizationsQuery } from '@/data/organizations/organizations-query' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import { projectKeys } from '@/data/projects/keys' +import { + getOrganizationProjects, + type OrgProject, +} from '@/data/projects/org-projects-infinite-query' + +export type StatusPageBannerData = { title: string } + +export function useStatusPageBannerVisibility(): StatusPageBannerData | null { + const showIncidentBannerOverride = + useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' + + const { data: allStatusPageEvents } = useIncidentStatusQuery() + const { incidents = [] } = allStatusPageEvents ?? {} + + const hasActiveIncidents = incidents.length > 0 + + const { data: organizations } = useOrganizationsQuery({ + enabled: !showIncidentBannerOverride && hasActiveIncidents, + }) + + const orgProjectsQueries = useQueries({ + queries: (organizations ?? []).map((org) => ({ + queryKey: projectKeys.bannerProjectsByOrg(org.slug), + queryFn: () => getOrganizationProjects({ slug: org.slug, limit: 100 }), + staleTime: 5 * 60 * 1000, + enabled: !showIncidentBannerOverride && hasActiveIncidents, + })), + }) + + const isProjectsFetched = + organizations !== undefined && + (organizations.length === 0 || orgProjectsQueries.every((q) => q.isFetched)) + + const allProjects = orgProjectsQueries.flatMap((q) => q.data?.projects ?? []) + const hasProjects = allProjects.length > 0 + const userRegions = new Set( + allProjects.flatMap((project: OrgProject) => project.databases.map((db) => db.region)) + ) + const hasUnknownRegions = orgProjectsQueries.some( + (q) => q.isError || (q.data !== undefined && q.data.pagination.count > q.data.projects.length) + ) + + if (showIncidentBannerOverride) return { title: 'We are investigating a technical issue' } + + if (!hasActiveIncidents || !isProjectsFetched) return null + + if (!shouldShowBanner({ incidents, hasProjects, userRegions, hasUnknownRegions })) return null + + return { + title: hasProjects + ? 'We are investigating a technical issue' + : 'Project creation may be impacted in some regions', + } +} diff --git a/apps/studio/components/ui/Charts/Charts.utils.test.ts b/apps/studio/components/ui/Charts/Charts.utils.test.ts new file mode 100644 index 0000000000000..7ff56eddc3700 --- /dev/null +++ b/apps/studio/components/ui/Charts/Charts.utils.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' + +import { computeYAxisDomain } from './Charts.utils' + +const IOPS_DATA = [ + { timestamp: 1, disk_iops_write: 1200, disk_iops_read: 24203, disk_iops_max: 25000 }, + { timestamp: 2, disk_iops_write: 400, disk_iops_read: 3200, disk_iops_max: 25000 }, + { timestamp: 3, disk_iops_write: 100, disk_iops_read: 900, disk_iops_max: 25000 }, +] + +const IOPS_VISIBLE = ['disk_iops_write', 'disk_iops_read'] + +describe('computeYAxisDomain', () => { + describe('percentage charts with max line hidden', () => { + it('returns [0, yMaxFromVisible] to zoom in on the data', () => { + expect( + computeYAxisDomain({ + isPercentage: true, + showMaxValue: false, + yMaxFromVisible: 75, + maxAttributeKey: 'cpu_usage_max', + showMaxLine: false, + data: [{ cpu_busy: 75, cpu_usage_max: 100 }], + visibleAttributeNames: ['cpu_busy'], + }) + ).toEqual([0, 75]) + }) + + it('still zooms in even when a maxAttributeKey is present', () => { + expect( + computeYAxisDomain({ + isPercentage: true, + showMaxValue: false, + yMaxFromVisible: 60, + maxAttributeKey: 'cpu_usage_max', + showMaxLine: true, + data: [{ cpu_busy: 60, cpu_usage_max: 100 }], + visibleAttributeNames: ['cpu_busy'], + }) + ).toEqual([0, 60]) + }) + }) + + describe('no max reference line', () => { + it('returns auto when maxAttributeKey is undefined', () => { + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: false, + yMaxFromVisible: 5000, + maxAttributeKey: undefined, + showMaxLine: false, + data: IOPS_DATA, + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual(['auto', 'auto']) + }) + + it('returns auto when showMaxLine is false', () => { + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 5000, + maxAttributeKey: 'disk_iops_max', + showMaxLine: false, + data: IOPS_DATA, + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual(['auto', 'auto']) + }) + }) + + describe('max reference line not yet loaded (value is 0)', () => { + it('returns auto when diskConfig has not loaded and reference line value is 0', () => { + const dataWithZeroMax = IOPS_DATA.map((p) => ({ ...p, disk_iops_max: 0 })) + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 5000, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: dataWithZeroMax, + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual(['auto', 'auto']) + }) + }) + + describe('explicit domain with reference line', () => { + it('uses the reference line value when bars stay below it', () => { + // All stacked bar totals (1000, 500) are well below maxRefValue (25000) + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 800, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: [ + { disk_iops_write: 400, disk_iops_read: 600, disk_iops_max: 25000 }, + { disk_iops_write: 200, disk_iops_read: 300, disk_iops_max: 25000 }, + ], + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual([0, 25000]) + }) + + it('uses the stacked bar total when it exceeds the reference line', () => { + // Stacked total at first point: 24203 + 1200 = 25403 > 25000 + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 24203, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: IOPS_DATA, + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual([0, 25403]) + }) + + it('domain min is always 0', () => { + const [min] = computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 100, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: IOPS_DATA, + visibleAttributeNames: IOPS_VISIBLE, + }) as [number, number] + expect(min).toBe(0) + }) + + it('works for database connections chart (single bar series, no stacking)', () => { + const data = [ + { pg_stat_database_num_backends: 45, max_db_connections: 60 }, + { pg_stat_database_num_backends: 52, max_db_connections: 60 }, + ] + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 52, + maxAttributeKey: 'max_db_connections', + showMaxLine: true, + data, + visibleAttributeNames: ['pg_stat_database_num_backends'], + }) + ).toEqual([0, 60]) + }) + + it('handles non-numeric values in data gracefully', () => { + const data = [ + { disk_iops_write: 'bad', disk_iops_read: null, disk_iops_max: 25000 }, + { disk_iops_write: 500, disk_iops_read: 1000, disk_iops_max: 25000 }, + ] + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 1000, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: data as Record[], + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual([0, 25000]) + }) + }) +}) diff --git a/apps/studio/components/ui/Charts/Charts.utils.tsx b/apps/studio/components/ui/Charts/Charts.utils.tsx index 152fe2466c7d7..c3645cca40e7f 100644 --- a/apps/studio/components/ui/Charts/Charts.utils.tsx +++ b/apps/studio/components/ui/Charts/Charts.utils.tsx @@ -58,6 +58,21 @@ export const precisionFormatter = (num: number, precision: number): string => { } } +/** + * Formats a number compactly for Y-axis ticks by abbreviating large values. + * Prevents long numbers like 1,000,000 from overflowing the Y-axis width. + * + * @example + * compactNumberFormatter(999) // "999" + * compactNumberFormatter(1000) // "1K" + * compactNumberFormatter(1500) // "1.5K" + * compactNumberFormatter(1000000) // "1M" + * compactNumberFormatter(2500000) // "2.5M" + */ +export const compactNumberFormatter = (num: number): string => { + return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(num) +} + /** * Formats a percentage, trimming decimals at 100. * @@ -99,6 +114,66 @@ export const timestampFormatter = ( return dayjs(value).format(format) } +/** + * Computes the Y-axis domain for a ComposedChart that may contain stacked Bar components + * and an optional max-value reference Line. + * + * Recharts' `['auto', 'auto']` domain does not correctly include a Line component's values + * when stacked Bars are present — the domain is derived only from the bar data, so the + * reference line (e.g. Max IOPS) and any bars that exceed it get visually clipped. + * This function returns an explicit `[0, max]` domain when a visible reference line exists. + * + * @example + * // Max IOPS reference line at 25 000, bars reach up to 25 403 + * computeYAxisDomain({ maxAttributeKey: 'disk_iops_max', showMaxLine: true, ... }) + * // → [0, 25403] + * + * // Percentage chart zoomed in (no max line toggle) + * computeYAxisDomain({ isPercentage: true, showMaxValue: false, yMaxFromVisible: 75, ... }) + * // → [0, 75] + * + * // No max reference line — let Recharts auto-scale + * computeYAxisDomain({ maxAttributeKey: undefined, ... }) + * // → ['auto', 'auto'] + */ +export function computeYAxisDomain({ + isPercentage, + showMaxValue, + yMaxFromVisible, + maxAttributeKey, + showMaxLine, + data, + visibleAttributeNames, +}: { + isPercentage: boolean + showMaxValue: boolean + yMaxFromVisible: number + maxAttributeKey: string | undefined + showMaxLine: boolean + data: Record[] + visibleAttributeNames: string[] +}): [number, number] | ['auto', 'auto'] { + if (isPercentage && !showMaxValue) return [0, yMaxFromVisible] + if (!maxAttributeKey || !showMaxLine) return ['auto', 'auto'] + + const maxRefValue = data.reduce((max, point) => { + const val = point[maxAttributeKey] + return typeof val === 'number' ? Math.max(max, val) : max + }, 0) + + if (maxRefValue <= 0) return ['auto', 'auto'] + + const maxStackedTotal = data.reduce((max, point) => { + const total = visibleAttributeNames.reduce((sum, name) => { + const val = point[name] + return sum + (typeof val === 'number' ? val : 0) + }, 0) + return Math.max(max, total) + }, 0) + + return [0, Math.max(maxRefValue, maxStackedTotal)] +} + /** * Hook to create common wrapping components, perform data transformations * returns a Container component and the minHeight set diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 10483b9705fb3..c2d9872dabe09 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -1,11 +1,12 @@ import dayjs from 'dayjs' import { formatBytes } from 'lib/helpers' import { useTheme } from 'next-themes' -import { ComponentProps, useEffect, useState } from 'react' +import { ComponentProps, useEffect, useMemo, useState } from 'react' import { Area, Bar, CartesianGrid, + Customized, Label, Line, ComposedChart as RechartComposedChart, @@ -14,11 +15,10 @@ import { Tooltip, XAxis, YAxis, - Customized, } from 'recharts' - import { CategoricalChartState } from 'recharts/types/chart/types' import { cn } from 'ui' + import { ChartHeader } from './ChartHeader' import { ChartHighlightAction, ChartHighlightActions } from './ChartHighlightActions' import { @@ -29,12 +29,12 @@ import { updateStackedChartColors, } from './Charts.constants' import { CommonChartProps, Datum } from './Charts.types' -import { formatPercentage, numberFormatter, useChartSize } from './Charts.utils' +import { computeYAxisDomain, formatPercentage, numberFormatter, useChartSize } from './Charts.utils' import { + calculateTotalChartAggregate, CustomLabel, CustomTooltip, MultiAttribute, - calculateTotalChartAggregate, } from './ComposedChart.utils' import NoDataPlaceholder from './NoDataPlaceholder' import { ChartHighlight } from './useChartHighlight' @@ -338,6 +338,28 @@ export function ComposedChart({ ) const yDomain = [0, yMaxFromVisible] + const yAxisDomain = useMemo( + () => + computeYAxisDomain({ + isPercentage, + showMaxValue, + yMaxFromVisible, + maxAttributeKey: maxAttribute?.attribute, + showMaxLine: _showMaxValue, + data, + visibleAttributeNames: visibleAttributes.map((a) => a.name), + }), + [ + isPercentage, + showMaxValue, + yMaxFromVisible, + maxAttribute, + _showMaxValue, + data, + visibleAttributes, + ] + ) + if (data.length === 0) { return ( ['projects', projectRef, 'clone-status'] as const, + + // Banner-specific: first-page snapshot used by the status page banner hook + bannerProjectsByOrg: (slug: string) => ['banner', 'org-projects', slug] as const, } diff --git a/apps/studio/data/projects/org-projects-infinite-query.ts b/apps/studio/data/projects/org-projects-infinite-query.ts index d13b328d006b1..02825fb27389d 100644 --- a/apps/studio/data/projects/org-projects-infinite-query.ts +++ b/apps/studio/data/projects/org-projects-infinite-query.ts @@ -24,7 +24,7 @@ interface GetOrgProjectsInfiniteVariables { export type OrgProjectsResponse = components['schemas']['OrganizationProjectsResponse'] export type OrgProject = OrgProjectsResponse['projects'][number] -async function getOrganizationProjects( +export async function getOrganizationProjects( { slug, limit = DEFAULT_LIMIT, diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index 77e096470ebb3..852be9ae43b78 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -1,8 +1,9 @@ -import { numberFormatter } from 'components/ui/Charts/Charts.utils' +import { compactNumberFormatter, numberFormatter } from 'components/ui/Charts/Charts.utils' import { ReportAttributes } from 'components/ui/Charts/ComposedChart.utils' import { DOCS_URL } from 'lib/constants' import { formatBytes } from 'lib/helpers' import type { Organization } from 'types' + import { DiskAttributesData } from '../config/disk-attributes-query' import { MaxConnectionsData } from '../database/max-connections-query' import { Project } from '../projects/project-detail-query' @@ -148,8 +149,8 @@ export const getReportAttributesV2: ( showGrid: true, showMaxValue: true, YAxisProps: { - width: 35, - tickFormatter: (value: any) => numberFormatter(value, 0), + width: 55, + tickFormatter: (value: any) => compactNumberFormatter(value), }, defaultChartStyle: 'bar', attributes: [ diff --git a/apps/studio/lib/api/incident-status.ts b/apps/studio/lib/api/incident-status.ts index b552e27ead45b..7b19ee53c40f8 100644 --- a/apps/studio/lib/api/incident-status.ts +++ b/apps/studio/lib/api/incident-status.ts @@ -3,12 +3,25 @@ import z from 'zod' import { IS_PLATFORM } from 'common' import { InternalServerError } from 'lib/api/apiHelpers' +export type IncidentCache = { + affected_regions: Array | null + affects_project_creation: boolean +} + +export type IncidentMetadata = { + dashboard_metadata?: { + show_banner?: boolean + } +} + export type IncidentInfo = { id: string name: string status: string impact: string active_since: string + metadata: IncidentMetadata + cache?: IncidentCache | null } const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1' @@ -27,6 +40,16 @@ const StatusPageIncidentsSchema = z.array( created_at: z.string(), scheduled_for: z.string().nullable(), impact: z.string(), + metadata: z + .object({ + dashboard_metadata: z + .object({ + show_banner: z.boolean().optional(), + }) + .optional(), + }) + .optional() + .default({}), }) ) @@ -114,5 +137,6 @@ export async function getActiveIncidents(): Promise { status: incident.status, impact: incident.impact, active_since: incident.scheduled_for ?? incident.created_at, + metadata: incident.metadata, })) } diff --git a/apps/studio/lib/api/supabase-admin.ts b/apps/studio/lib/api/supabase-admin.ts new file mode 100644 index 0000000000000..e6a19f7008b7b --- /dev/null +++ b/apps/studio/lib/api/supabase-admin.ts @@ -0,0 +1,9 @@ +import { createClient } from '@supabase/supabase-js' + +/** + * Creates a Supabase client using the secret key. + * For use in server-side API routes only. + */ +export function createAdminClient() { + return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.LIVE_SUPABASE_SECRET_KEY!) +} diff --git a/apps/studio/pages/api/incident-status.ts b/apps/studio/pages/api/incident-status.ts index e925a92e6a332..c075bbdd38ad4 100644 --- a/apps/studio/pages/api/incident-status.ts +++ b/apps/studio/pages/api/incident-status.ts @@ -1,8 +1,13 @@ +import { IS_PLATFORM } from 'common' import { NextApiRequest, NextApiResponse } from 'next' -import { IS_PLATFORM } from 'common' -import { InternalServerError } from 'lib/api/apiHelpers' -import { getActiveIncidents, type IncidentInfo } from 'lib/api/incident-status' +import { InternalServerError } from '@/lib/api/apiHelpers' +import { + getActiveIncidents, + type IncidentCache, + type IncidentInfo, +} from '@/lib/api/incident-status' +import { createAdminClient } from '@/lib/api/supabase-admin' /** * Cache on browser for 5 minutes @@ -11,11 +16,41 @@ import { getActiveIncidents, type IncidentInfo } from 'lib/api/incident-status' */ const CACHE_CONTROL_SETTINGS = 'public, max-age=300, s-maxage=300, stale-while-revalidate=60' +async function fetchIncidentCache(incidentIds: Array): Promise> { + const cacheMap = new Map() + + if (incidentIds.length === 0) return cacheMap + + const supabase = createAdminClient() + + try { + const { data, error } = await supabase + .from('incident_status_cache') + .select('incident_id, affected_regions, affects_project_creation') + .in('incident_id', incidentIds) + + if (error) { + console.error('Failed to fetch incident_status_cache: %O', error) + return cacheMap + } + + for (const row of data ?? []) { + cacheMap.set(row.incident_id, { + affected_regions: row.affected_regions ?? null, + affects_project_creation: row.affects_project_creation, + }) + } + } catch (error) { + console.error('Unexpected error fetching incident_status_cache: %O', error) + } + + return cacheMap +} + // Default export needed by Next.js convention -// eslint-disable-next-line no-restricted-exports export default async function handler( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse | { error: string }> ) { if (!IS_PLATFORM) { return res.status(404).end() @@ -34,11 +69,24 @@ export default async function handler( } try { - const incidents = await getActiveIncidents() + const allIncidents = await getActiveIncidents() + + const bannerIncidents = allIncidents.filter( + (incident) => + incident.impact !== 'maintenance' && + incident.metadata?.dashboard_metadata?.show_banner === true + ) + + const cacheMap = await fetchIncidentCache(bannerIncidents.map((i) => i.id)) + + const enrichedIncidents = bannerIncidents.map((incident) => ({ + ...incident, + cache: cacheMap.get(incident.id) ?? null, + })) res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS) - return res.status(200).json(incidents) + return res.status(200).json(enrichedIncidents) } catch (error) { if (error instanceof InternalServerError) { console.error('Failed to fetch active StatusPage incidents: %O', { diff --git a/apps/studio/tests/components/ui/Charts/Charts.utils.test.ts b/apps/studio/tests/components/ui/Charts/Charts.utils.test.ts index 135d445f93bbe..f059ba53b24dc 100644 --- a/apps/studio/tests/components/ui/Charts/Charts.utils.test.ts +++ b/apps/studio/tests/components/ui/Charts/Charts.utils.test.ts @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react' import { + compactNumberFormatter, formatPercentage, isFloat, numberFormatter, @@ -105,6 +106,37 @@ describe('formatPercentage', () => { }) }) +describe('compactNumberFormatter', () => { + it('returns the number as-is below 1000', () => { + expect(compactNumberFormatter(0)).toBe('0') + expect(compactNumberFormatter(1)).toBe('1') + expect(compactNumberFormatter(999)).toBe('999') + }) + + it('formats thousands with K suffix', () => { + expect(compactNumberFormatter(1000)).toBe('1K') + expect(compactNumberFormatter(1500)).toBe('1.5K') + expect(compactNumberFormatter(64000)).toBe('64K') + expect(compactNumberFormatter(999999)).toBe('1M') // rounds up + }) + + it('formats millions with M suffix', () => { + expect(compactNumberFormatter(1_000_000)).toBe('1M') + expect(compactNumberFormatter(1_500_000)).toBe('1.5M') + expect(compactNumberFormatter(2_500_000)).toBe('2.5M') + }) + + it('formats billions with B suffix', () => { + expect(compactNumberFormatter(1_000_000_000)).toBe('1B') + expect(compactNumberFormatter(2_500_000_000)).toBe('2.5B') + }) + + it('handles negative numbers', () => { + expect(compactNumberFormatter(-1000)).toBe('-1K') + expect(compactNumberFormatter(-1_500_000)).toBe('-1.5M') + }) +}) + test('useStacked', () => { const { result } = renderHook(() => useStacked({ diff --git a/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx b/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx index 79a789db6ac76..462622a418f25 100644 --- a/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx +++ b/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx @@ -18,9 +18,9 @@ company: categories: - webinar main_cta: - url: 'https://zoom.us/webinar/register/WN_cMSVDLRSRKS5cH8EmmUUQg' - target: _blank - label: Register now + url: '#recording' + target: _self + label: Watch the recording speakers: 'dventimi,jian_jie' hosts: - name: Supabase @@ -38,7 +38,7 @@ In this 45-minute session, Supabase engineers and Mobbin CEO Liau Jian Jie break This session is built for startup founders and technical leads who want to stay fast without giving up control. -
+