diff --git a/apps/design-system/.env.local.example b/apps/design-system/.env.local.example new file mode 100644 index 00000000000..23bcb415cb3 --- /dev/null +++ b/apps/design-system/.env.local.example @@ -0,0 +1,2 @@ +# Copy this file in the same folder, rename it to .env.local and fill out the env vars +NEXT_PUBLIC_BASE_PATH=/design-system diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index e3bda291b96..4064fbd6d04 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -389,11 +389,6 @@ export const docsConfig: DocsConfig = { href: '/docs/components/textarea', items: [], }, - { - title: 'Toast', - href: '/docs/components/toast', - items: [], - }, { title: 'Toggle', href: '/docs/components/toggle', diff --git a/apps/design-system/package.json b/apps/design-system/package.json index d353b9fefea..7a262a1a1a0 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -6,9 +6,8 @@ "scripts": { "preinstall": "npx only-allow pnpm", "dev": "next dev --turbopack --port 3003", - "build": "pnpm run content:build && pnpm run build:registry && pnpm run build:llms && next build", + "build": "pnpm run content:build && pnpm run build:registry && next build", "build:registry": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache", - "build:llms": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-llms-txt.ts", "start": "next start", "lint": "next lint", "content:build": "contentlayer2 build", @@ -18,6 +17,7 @@ "dependencies": { "@hookform/resolvers": "^3.1.1", "contentlayer2": "0.4.6", + "date-fns": "^2.30.0", "eslint-config-supabase": "workspace:*", "icons": "workspace:*", "jotai": "^2.8.0", diff --git a/apps/design-system/public/llms.txt b/apps/design-system/public/llms.txt deleted file mode 100644 index 34725799b8c..00000000000 --- a/apps/design-system/public/llms.txt +++ /dev/null @@ -1,158 +0,0 @@ -# Supabase Design System -Last updated: 2025-04-24T06:00:07.374Z - -## Overview -The Design System used in Supabase Studio. - -## Docs -- [Changelog](https://supabase-design-system.vercel.app/design-system/docs/changelog) - - Latest updates and announcements. -- [Color usage](https://supabase-design-system.vercel.app/design-system/docs/color-usage) - - Colors system breakdown with best practise -- [Accordion](https://supabase-design-system.vercel.app/design-system/docs/components/accordion) - - A vertically stacked set of interactive headings that each reveal a section of content. -- [Alert](https://supabase-design-system.vercel.app/design-system/docs/components/alert copy) - - Displays a callout for user attention. -- [Alert Dialog](https://supabase-design-system.vercel.app/design-system/docs/components/alert-dialog) - - A modal dialog that interrupts the user with important content and expects a response. -- [Alert](https://supabase-design-system.vercel.app/design-system/docs/components/alert) - - Displays a callout for user attention. -- [Aspect Ratio](https://supabase-design-system.vercel.app/design-system/docs/components/aspect-ratio) - - Displays content within a desired ratio. -- [Atom components](https://supabase-design-system.vercel.app/design-system/docs/components/atom-components) -- [Avatar](https://supabase-design-system.vercel.app/design-system/docs/components/avatar) - - An image element with a fallback for representing the user. -- [Badge](https://supabase-design-system.vercel.app/design-system/docs/components/badge) - - Displays a badge or a component that looks like a badge. -- [Breadcrumb](https://supabase-design-system.vercel.app/design-system/docs/components/breadcrumb) - - Displays the path to the current resource using a hierarchy of links. -- [Button](https://supabase-design-system.vercel.app/design-system/docs/components/button) - - Displays a button or a component that looks like a button. -- [Calendar](https://supabase-design-system.vercel.app/design-system/docs/components/calendar) - - A date field component that allows users to enter and edit date. -- [Card](https://supabase-design-system.vercel.app/design-system/docs/components/card) - - Displays a card with header, content, and footer. -- [Carousel](https://supabase-design-system.vercel.app/design-system/docs/components/carousel) - - A carousel with motion and swipe built using Embla. -- [Chart](https://supabase-design-system.vercel.app/design-system/docs/components/chart) - - Beautiful charts. Built using Recharts. Copy and paste into your apps. -- [Checkbox](https://supabase-design-system.vercel.app/design-system/docs/components/checkbox) - - A control that allows the user to toggle between checked and not checked. -- [Collapsible](https://supabase-design-system.vercel.app/design-system/docs/components/collapsible) - - An interactive component which expands/collapses a panel. -- [Combobox](https://supabase-design-system.vercel.app/design-system/docs/components/combobox) - - Autocomplete input and command palette with a list of suggestions. -- [Command](https://supabase-design-system.vercel.app/design-system/docs/components/command) - - Fast, composable, unstyled command menu for React. -- [Command Menu (cmdk)](https://supabase-design-system.vercel.app/design-system/docs/components/commandmenu) - - A central command menu that acts as a control center with searchable actions. -- [Context Menu](https://supabase-design-system.vercel.app/design-system/docs/components/context-menu) - - Displays a menu to the user — such as a set of actions or functions — triggered by a button. -- [Data Table](https://supabase-design-system.vercel.app/design-system/docs/components/data-table) - - Powerful table and datagrids built using TanStack Table. -- [Date Picker](https://supabase-design-system.vercel.app/design-system/docs/components/date-picker) - - A date picker component with range and presets. -- [Dialog](https://supabase-design-system.vercel.app/design-system/docs/components/dialog) - - A window overlaid on either the primary window or another dialog window, rendering the content underneath inert. -- [Drawer](https://supabase-design-system.vercel.app/design-system/docs/components/drawer) - - A drawer component for React. -- [Dropdown Menu](https://supabase-design-system.vercel.app/design-system/docs/components/dropdown-menu) - - Displays a menu to the user — such as a set of actions or functions — triggered by a button. -- [Expanding Textarea](https://supabase-design-system.vercel.app/design-system/docs/components/expanding-textarea) - - Displays a textarea which autoexpands (or shrinks) based on its content. -- [React Hook Form](https://supabase-design-system.vercel.app/design-system/docs/components/form) - - Building forms with React Hook Form and Zod. -- [Fragment components](https://supabase-design-system.vercel.app/design-system/docs/components/fragment-components) - - '' -- [Hover Card](https://supabase-design-system.vercel.app/design-system/docs/components/hover-card) - - For sighted users to preview content available behind a link. -- [Input OTP](https://supabase-design-system.vercel.app/design-system/docs/components/input-otp) - - Accessible one-time password component with copy paste functionality. -- [Input](https://supabase-design-system.vercel.app/design-system/docs/components/input) - - Displays a form input field or a component that looks like an input field. -- [Label](https://supabase-design-system.vercel.app/design-system/docs/components/label) - - Renders an accessible label associated with controls. -- [Menubar](https://supabase-design-system.vercel.app/design-system/docs/components/menubar) - - A visually persistent menu common in desktop applications that provides quick access to a consistent set of commands. -- [Navigation Menu](https://supabase-design-system.vercel.app/design-system/docs/components/navigation-menu) - - A collection of links for navigating websites. -- [Pagination](https://supabase-design-system.vercel.app/design-system/docs/components/pagination) - - Pagination with page navigation, next and previous links. -- [Popover](https://supabase-design-system.vercel.app/design-system/docs/components/popover) - - Displays rich content in a portal, triggered by a button. -- [Progress](https://supabase-design-system.vercel.app/design-system/docs/components/progress) - - Displays an indicator showing the completion progress of a task, typically displayed as a progress bar. -- [Radio Group Card](https://supabase-design-system.vercel.app/design-system/docs/components/radio-group-card) - - A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time. -- [Radio Group Stacked](https://supabase-design-system.vercel.app/design-system/docs/components/radio-group-stacked) - - A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time. -- [Radio Group](https://supabase-design-system.vercel.app/design-system/docs/components/radio-group) - - A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time. -- [Resizable](https://supabase-design-system.vercel.app/design-system/docs/components/resizable) - - Accessible resizable panel groups and layouts with keyboard support. -- [Scroll-area](https://supabase-design-system.vercel.app/design-system/docs/components/scroll-area) - - Augments native scroll functionality for custom, cross-browser styling. -- [Select](https://supabase-design-system.vercel.app/design-system/docs/components/select) - - Displays a list of options for the user to pick from—triggered by a button. -- [Separator](https://supabase-design-system.vercel.app/design-system/docs/components/separator) - - Visually or semantically separates content. -- [Sheet](https://supabase-design-system.vercel.app/design-system/docs/components/sheet) - - Extends the Dialog component to display content that complements the main content of the screen. -- [Sidebar](https://supabase-design-system.vercel.app/design-system/docs/components/sidebar) - - A composable, themeable and customizable sidebar component. -- [Skeleton](https://supabase-design-system.vercel.app/design-system/docs/components/skeleton) - - Use to show a placeholder while content is loading. -- [Slider](https://supabase-design-system.vercel.app/design-system/docs/components/slider) - - An input where the user selects a value from within a given range. -- [Sonner](https://supabase-design-system.vercel.app/design-system/docs/components/sonner) - - An opinionated toast component for React. -- [Switch](https://supabase-design-system.vercel.app/design-system/docs/components/switch) - - A control that allows the user to toggle between checked and not checked. -- [Table](https://supabase-design-system.vercel.app/design-system/docs/components/table) - - A responsive table component. -- [Tabs](https://supabase-design-system.vercel.app/design-system/docs/components/tabs) - - A set of layered sections of content—known as tab panels—that are displayed one at a time. -- [Textarea](https://supabase-design-system.vercel.app/design-system/docs/components/textarea) - - Displays a form textarea or a component that looks like a textarea. -- [Toggle Group](https://supabase-design-system.vercel.app/design-system/docs/components/toggle-group) - - A set of two-state buttons that can be toggled on or off. -- [Toggle](https://supabase-design-system.vercel.app/design-system/docs/components/toggle) - - A two-state button that can be either on or off. -- [Tooltip](https://supabase-design-system.vercel.app/design-system/docs/components/tooltip) - - A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it. -- [Tree View](https://supabase-design-system.vercel.app/design-system/docs/components/tree-view) - - A tree view that assembles all the functionalities of the Accordion component to create a tree view. -- [Typography](https://supabase-design-system.vercel.app/design-system/docs/components/typography) - - Styles for headings, paragraphs, lists...etc -- [Figma](https://supabase-design-system.vercel.app/design-system/docs/figma) - - Every component recreated in Figma. With customizable props, typography and icons. -- [Admonition](https://supabase-design-system.vercel.app/design-system/docs/fragments/admonition) - - Displays a callout for user attention. -- [Assistant Chat](https://supabase-design-system.vercel.app/design-system/docs/fragments/assistant-chat) - - Bespoke Assistant chat text area, with support for a commands Popover. -- [Filter Bar](https://supabase-design-system.vercel.app/design-system/docs/fragments/filter-bar) - - An advanced filtering component with support for multiple conditions and operators. -- [Form Item Layout](https://supabase-design-system.vercel.app/design-system/docs/fragments/form-item-layout) - - A helper component that provides a layout for form items. -- [Info Tooltip](https://supabase-design-system.vercel.app/design-system/docs/fragments/info-tooltip) - - InfoTooltip provides a tooltip with an information icon. -- [Inner Side Menu](https://supabase-design-system.vercel.app/design-system/docs/fragments/inner-side-menu) - - InnerSideMenu is a component that provides a collapsible side menu with multiple sections. -- [Logs Bar Chart](https://supabase-design-system.vercel.app/design-system/docs/fragments/logs-bar-chart) - - A stacked bar chart that displays logs errors and successes. -- [Modal](https://supabase-design-system.vercel.app/design-system/docs/fragments/modal) - - A window overlaid on either the primary window or another dialog window, rendering the content underneath inert. -- [Multi Select](https://supabase-design-system.vercel.app/design-system/docs/fragments/multi-select) - - Multiple selection component. -- [Text Confirm Dialog](https://supabase-design-system.vercel.app/design-system/docs/fragments/text-confirm-dialog) - - A modal dialog that interrupts the user with important content and expects a response. -- [Table of Contents (TOC)](https://supabase-design-system.vercel.app/design-system/docs/fragments/toc) - - List of page anchors for the current page. -- [Icons](https://supabase-design-system.vercel.app/design-system/docs/icons) - - Icons system breakdown. Copy values of Icons. -- [Introduction](https://supabase-design-system.vercel.app/design-system/docs/index) - - Components and patterns that you can copy and paste into Supabase apps. Accessible. Customizable. Open Source. -- [Tailwind Classes](https://supabase-design-system.vercel.app/design-system/docs/tailwind-classes) - - Icons system breakdown. Copy values of Icons. -- [Themes](https://supabase-design-system.vercel.app/design-system/docs/theming) - - Themes used in Supabase diff --git a/apps/design-system/registry/default/example/chart-tooltip-demo.tsx b/apps/design-system/registry/default/example/chart-tooltip-demo.tsx index 89f338fda2c..3d42a84badb 100644 --- a/apps/design-system/registry/default/example/chart-tooltip-demo.tsx +++ b/apps/design-system/registry/default/example/chart-tooltip-demo.tsx @@ -17,7 +17,7 @@ export default function Component() { fill="none" className="absolute left-[5px] top-[50px] z-10" > - + - + - + - - - Documentation - - + + Documentation + diff --git a/apps/design-system/registry/default/example/navigation-menu-responsive.tsx b/apps/design-system/registry/default/example/navigation-menu-responsive.tsx index 4764bf931b1..4d56c2d5a50 100644 --- a/apps/design-system/registry/default/example/navigation-menu-responsive.tsx +++ b/apps/design-system/registry/default/example/navigation-menu-responsive.tsx @@ -144,11 +144,12 @@ export default function NavigationMenuDemo() { - - - Documentation - - + + Documentation + diff --git a/apps/design-system/registry/default/example/scroll-area-demo.tsx b/apps/design-system/registry/default/example/scroll-area-demo.tsx index 253881b5938..b2212a40d0b 100644 --- a/apps/design-system/registry/default/example/scroll-area-demo.tsx +++ b/apps/design-system/registry/default/example/scroll-area-demo.tsx @@ -8,15 +8,12 @@ const tags = Array.from({ length: 50 }).map((_, i, a) => `v1.2.0-beta.${a.length export default function ScrollAreaDemo() { return ( -
+

Tags

{tags.map((tag) => ( - <> -
- {tag} -
- - +
+ {tag} +
))}
diff --git a/apps/design-system/registry/default/example/sheet-demo.tsx b/apps/design-system/registry/default/example/sheet-demo.tsx index e5264180f25..8dd0044aadb 100644 --- a/apps/design-system/registry/default/example/sheet-demo.tsx +++ b/apps/design-system/registry/default/example/sheet-demo.tsx @@ -28,13 +28,13 @@ export default function SheetDemo() { Name - +
Username - +
diff --git a/apps/design-system/scripts/build-llms-txt.ts b/apps/design-system/scripts/build-llms-txt.ts deleted file mode 100644 index 85d19ba5089..00000000000 --- a/apps/design-system/scripts/build-llms-txt.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { registry } from '@/registry/registry' -import fs from 'fs' -import path from 'path' - -const BASE_URL = 'https://supabase-design-system.vercel.app/design-system/docs' - -interface DocMeta { - title: string - description?: string - path: string -} - -console.log('🤖 Building llms.txt') - -// Function to extract frontmatter from MDX files -function extractFrontmatter(content: string): { title?: string; description?: string } { - const frontmatterRegex = /---\n([\s\S]*?)\n---/ - const match = content.match(frontmatterRegex) - if (!match) return {} - - const frontmatter = match[1] - const titleMatch = frontmatter.match(/title:\s*(.*)/) - const descriptionMatch = frontmatter.match(/description:\s*(.*)/) - - return { - title: titleMatch?.[1], - description: descriptionMatch?.[1], - } -} - -// Function to recursively get all MDX files -function getMdxFiles(dir: string): string[] { - const files: string[] = [] - const entries = fs.readdirSync(dir, { withFileTypes: true }) - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - files.push(...getMdxFiles(fullPath)) - } else if (entry.name.endsWith('.mdx')) { - files.push(fullPath) - } - } - - return files -} - -// Function to get all MDX files and their metadata -function getDocFiles(): DocMeta[] { - const docsDir = path.join(process.cwd(), 'content/docs') - const mdxFiles = getMdxFiles(docsDir) - - const docs: DocMeta[] = [] - - for (const fullPath of mdxFiles) { - const content = fs.readFileSync(fullPath, 'utf-8') - const { title, description } = extractFrontmatter(content) - - if (title) { - // Get relative path and convert to URL path - const relativePath = path.relative(docsDir, fullPath) - const urlPath = relativePath - .replace(/\.mdx$/, '') - .replace(/\/index$/, '') - .replace(/\\/g, '/') - - docs.push({ - title, - description, - path: urlPath, - }) - } - } - - return docs -} - -// Generate the llms.txt content -const docs = getDocFiles() -let content = `# Supabase Design System -Last updated: ${new Date().toISOString()} - -## Overview -The Design System used in Supabase Studio. - -## Docs -` - -// Add documentation links -for (const doc of docs) { - const url = `${BASE_URL}/${doc.path}` - content += `- [${doc.title}](${url})` - if (doc.description) { - content += `\n - ${doc.description}` - } - content += '\n' -} - -// Write the file -const publicDir = path.join(process.cwd(), 'public') -if (!fs.existsSync(publicDir)) { - fs.mkdirSync(publicDir, { recursive: true }) -} - -fs.writeFileSync(path.join(publicDir, 'llms.txt'), content) -console.log('✅ Generated llms.txt in public directory') diff --git a/apps/design-system/tsconfig.json b/apps/design-system/tsconfig.json index 9e21bf5ea57..f4adac07463 100644 --- a/apps/design-system/tsconfig.json +++ b/apps/design-system/tsconfig.json @@ -20,7 +20,8 @@ "paths": { "@/*": ["./*"], "@ui/*": ["./../../packages/ui/src/*"], // handle ui package paths - "contentlayer/generated": ["./.contentlayer/generated"] + "contentlayer/generated": ["./.contentlayer/generated"], + "icons/*": ["./../../packages/icons/*"] }, "plugins": [ { diff --git a/apps/docs/components/Breadcrumbs.tsx b/apps/docs/components/Breadcrumbs.tsx index 5eaee5cb0ac..2f04f9c1869 100644 --- a/apps/docs/components/Breadcrumbs.tsx +++ b/apps/docs/components/Breadcrumbs.tsx @@ -107,7 +107,7 @@ const BreadcrumbsInternal = ({ - +
{breadcrumbs.slice(1, -2).map((crumb, index) => crumb.url ? ( diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index baa5678f569..87102c911f3 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2193,7 +2193,7 @@ export const platform: NavMenuConstant = { url: '/guides/platform/project-transfer', }, { - name: 'Duplicate Project', + name: 'Restore to a new project', url: '/guides/platform/clone-project', }, { diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index d650c5db263..26e1fc9b6ff 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -165,7 +165,32 @@ supabase --experimental \ postgres-config delete --config shared_buffers,work_mem ``` -By default, changing the configuration, whether by updating or deleting, causes the database and all associated read replicas to restart. You can use the `--no-restart` flag to prevent this behavior, and attempt to reload the updated configuration without a restart. Refer to the Postgres documentation to determine if a given parameter can be reloaded without a restart. +By default, CLI v2 (≥ 2.0.0) checks the parameter’s context and requests the correct action (reload or restart): + +- If the setting can be reloaded (`pg_settings.context = 'sighup'`), then the Management API will detect this and apply the change with a configuration reload. +- If the setting requires a restart (`pg_settings.context = 'postmaster'`), then both the primary and any read replicas will restart to apply the change. + +To check whether a parameter can be reloaded without a restart, see the [Postgres docs](https://www.postgresql.org/docs/current/runtime-config.html). + +You can verify whether changes have been applied with the following checks: + +```bash +supabase --version; +``` + +```sql +-- Check whether the parameters were updated (and if a restart is pending): +select name, setting, context, pending_restart +from pg_settings +where name in ('max_slot_wal_keep_size', 'shared_buffers', 'max_connections'); +``` + +```sql +-- If the timestamp hasn’t changed, no restart occurred +select pg_postmaster_start_time(); +``` + +You can also pass the `--no-restart` flag to attempt a reload-only apply. If the parameter cannot be reloaded, the change stays pending until the next restart. diff --git a/apps/docs/content/guides/platform/clone-project.mdx b/apps/docs/content/guides/platform/clone-project.mdx index 8a67d30ac43..f2399770ae5 100644 --- a/apps/docs/content/guides/platform/clone-project.mdx +++ b/apps/docs/content/guides/platform/clone-project.mdx @@ -1,9 +1,29 @@ --- -title: Duplicate project +title: Restore to a new project subtitle: How to clone your existing Supabase project --- -You can clone your Supabase project by restoring your data from an existing project into a completely new one. Note that this only duplicates your database, not your project configuration. + + +You can clone your Supabase project by restoring your data from an existing project into a completely new one. This process creates a database-only copy and requires manual reconfiguration to fully replicate your original project. + + + +**What will be transferred?** + +- Database schema (tables, views, procedures) +- All data and indexes +- Database roles, permissions and users +- Auth user data (user accounts, hashed passwords, and authentication records from the auth schema) + +**What needs manual reconfiguration?** + +- Storage objects & settings (Your S3/storage files and bucket configurations are **NOT** copied) +- Edge Functions +- Auth settings & API keys +- Realtime settings +- Database extensions and settings +- Read replicas Whether you're using physical backups or Point-in-Time recovery (PITR), this feature allows you to duplicate project data with ease, perform testing safely, or recover data for analysis. Access to this feature is exclusive to users on paid plans and requires that physical backups are enabled for the source project. diff --git a/apps/docs/content/guides/realtime/authorization.mdx b/apps/docs/content/guides/realtime/authorization.mdx index 3dd1d59b336..9fb4f3aa8aa 100644 --- a/apps/docs/content/guides/realtime/authorization.mdx +++ b/apps/docs/content/guides/realtime/authorization.mdx @@ -28,7 +28,7 @@ To enforce private channels you need to disable the 'Allow public access' settin Realtime uses the `messages` table in your database's `realtime` schema to generate access policies for your clients when they connect to a Channel topic. -By creating RLS polices on the `realtime.messages` table you can control the access users have to a Channel topic, and features within a Channel topic. +By creating RLS policies on the `realtime.messages` table you can control the access users have to a Channel topic, and features within a Channel topic. The validation is done when the user connects. When their WebSocket connection is established and a Channel topic is joined, their permissions are calculated based on: @@ -373,9 +373,9 @@ When using Postgres Changes with RLS, database records are sent only to clients ## Updating RLS policies -Client access polices are cached for the duration of the connection. Your database is not queried for every Channel message. +Client access policies are cached for the duration of the connection. Your database is not queried for every Channel message. -Realtime updates the access policy cache for a client based on your RLS polices when: +Realtime updates the access policy cache for a client based on your RLS policies when: - A client connects to Realtime and subscribes to a Channel - A new JWT is sent to Realtime from a client via the [`access_token` message](/docs/guides/realtime/protocol#access-token) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index e6b97587952..dab101fd1c2 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -45,6 +45,7 @@ Eduardo Gurgel Egor Romanov Eleftheria Trivyzaki Emmett Folger +Eric Kharitonashvili Etienne Stalmans Fabrizio Fenoglio Felipe Stival @@ -123,6 +124,7 @@ Taryn King Terry Sutton Thomas E Thor Schaeff +Timothy Lim Tom Ashley Tom G Tyler Hillery diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 92aa3043d0b..5f6ae0cf5fa 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -214,10 +214,14 @@ export function useLoadTableEditorStateFromLocalStorageIntoUrl({ } export const handleCopyCell = ( - { column, row }: { column: CalculatedColumn; row: any }, + { + mode, + column, + row, + }: { mode: 'SELECT' | 'EDIT'; column: CalculatedColumn; row: any }, event: CellKeyboardEvent ) => { - if (event.code === 'KeyC' && (event.metaKey || event.ctrlKey)) { + if (mode === 'SELECT' && event.code === 'KeyC' && (event.metaKey || event.ctrlKey)) { const colKey = column.key const cellValue = row[colKey] ?? '' const value = formatClipboardValue(cellValue) diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index ffc35913bae..1818e82ebba 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -128,9 +128,7 @@ export const Grid = memo( } } - const removeAllFilters = () => { - onApplyFilters([]) - } + const removeAllFilters = () => onApplyFilters([]) return (
fileKey(f.name) === activeFileKey + const currentFile = currentBody.files.find( + (f: EdgeFunctionBodyData['files'][number]) => fileKey(f.name) === activeFileKey ) - const mainFile = mainBody.find( - (f: EdgeFunctionBodyData[number]) => fileKey(f.name) === activeFileKey + const mainFile = mainBody.files.find( + (f: EdgeFunctionBodyData['files'][number]) => fileKey(f.name) === activeFileKey ) const language = useMemo(() => { @@ -189,7 +194,7 @@ const EdgeFunctionsDiffPanel = ({ key={slug} functionSlug={slug} currentBody={diffResults.addedBodiesMap[slug]!} - mainBody={EMPTY_ARR} + mainBody={EMPTY_FUNCTION_BODY} currentBranchRef={currentBranchRef} fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR} /> diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx index f76fe3ab7d6..22f464b6bfe 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx @@ -162,7 +162,7 @@ export const CreateNewProjectDialog = ({ { disabled={isCreating || isUpdating} loading={isCreating || isUpdating} > - Confirm + {isEditing ? 'Save' : 'Create'} function
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx index 3d62d5f710a..c3e4ca8117e 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx @@ -293,11 +293,6 @@ export const ReplicationPipelineStatus = () => {
{statusConfig.description}
- {'lag' in table.state && ( -
- Lag: {table.state.lag}ms -
- )} {table.state.name === 'error' && ( diff --git a/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx index 1a002944dbd..9e4f0d3b2e1 100644 --- a/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx @@ -61,7 +61,7 @@ export const PostgresVersionSelector = ({ form, type = 'create', layout = 'horizontal', - label = 'Postgres Version', + label = 'Postgres version', }: PostgresVersionSelectorProps) => { const { data: project } = useSelectedProjectQuery() diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts index c1e11c59e66..47d4233c511 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts @@ -15,6 +15,7 @@ export function getAvailableRegions(cloudProvider: CloudProvider): Region { switch (cloudProvider) { case 'AWS': case 'AWS_K8S': + case 'AWS_NIMBUS': return AWS_REGIONS case 'FLY': return FLY_REGIONS diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 678dcd776d5..144f72ff090 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -87,9 +87,14 @@ export const RegionSelector = ({ <>

Select the region closest to your users for the best performance.

{showNonProdFields && ( -

- Note: Only US (NV), Frankfurt and SG are supported for local/staging projects -

+
+

Only these regions are supported for local/staging projects:

+
    +
  • East US (North Virginia)
  • +
  • Central EU (Frankfurt)
  • +
  • Southeast Asia (Singapore)
  • +
+
)} } diff --git a/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx index 90ffe223822..3a92bbad827 100644 --- a/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx @@ -94,7 +94,7 @@ export const SecurityOptions = ({ control={form.control} render={({ field }) => ( <> - + { +} + +/** + * A wrapper component that uses the useChartData hook to fetch data for a chart + * and then passes the data and loading state to the ComposedChartHandler. + * + * This component acts as a bridge between the data-fetching logic and the + * presentational chart component. + */ +export const ReportChart = ({ + chart, + startDate, + endDate, + interval, + updateDateRange, + functionIds, + isLoading, +}: ReportChartProps) => { const { data: org } = useSelectedOrganizationQuery() const { plan: orgPlan } = useCurrentOrgPlan() const orgPlanId = orgPlan?.id - const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false) const isAvailable = chart.availableIn === undefined || (orgPlanId && chart.availableIn.includes(orgPlanId)) @@ -87,77 +79,8 @@ const ReportChart = ({ ? filledData : chartDataArray - const getExpDemoChartData = () => - new Array(20).fill(0).map((_, index) => ({ - period_start: new Date(startDate).getTime() + index * 1000, - demo: Math.floor(Math.pow(1.25, index) * 10), - max_demo: 1000, - })) - - const getDemoChartData = () => - new Array(20).fill(0).map((_, index) => ({ - period_start: new Date(startDate).getTime() + index * 1000, - demo: Math.floor(Math.random() * 10) + 1, - max_demo: 1000, - })) - - // [Jordi] useRef to prevent re-rendering making the chart change. - const demoChartData = useRef(getDemoChartData()) - const exponentialChartData = useRef(getExpDemoChartData()) - - const chartData = isHoveringUpgrade ? exponentialChartData.current : demoChartData.current - if (!isAvailable && !isLoading) { - return ( - {chart.label}

} - className={cn('h-[260px] relative', className)} - > -
-

{chart.label}

-

- This chart is available from{' '} - - {!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'} - {' '} - plan and above -

- -
-
- -
-
- ) + return } return ( @@ -171,4 +94,3 @@ const ReportChart = ({ /> ) } -export default ReportChart diff --git a/apps/studio/components/interfaces/Reports/Reports.utils.test.ts b/apps/studio/components/interfaces/Reports/Reports.utils.test.ts new file mode 100644 index 00000000000..8884c0e8a70 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/Reports.utils.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { formatTimestamp } from './Reports.utils' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' + +dayjs.extend(utc) + +describe('formatTimestamp', () => { + it('formats milliseconds timestamp correctly', () => { + const timestamp = 1640995200000 // 2022-01-01 00:00:00 UTC in milliseconds + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) + + it('formats microseconds timestamp correctly', () => { + const timestamp = 1640995200000000 // 2022-01-01 00:00:00 UTC in microseconds + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) + + it('formats seconds timestamp correctly', () => { + const timestamp = 1640995200 // 2022-01-01 00:00:00 UTC in seconds + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) + + it('handles string timestamp input', () => { + const timestamp = '1640995200000' + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) + + it('handles invalid string timestamp', () => { + const timestamp = 'invalid-timestamp' + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Invalid Date') + }) + + it('handles zero timestamp', () => { + const timestamp = 0 + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) +}) diff --git a/apps/studio/components/interfaces/Reports/Reports.utils.tsx b/apps/studio/components/interfaces/Reports/Reports.utils.tsx index eb2337e6ce1..f0eef8b93ad 100644 --- a/apps/studio/components/interfaces/Reports/Reports.utils.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.utils.tsx @@ -1,3 +1,5 @@ +import dayjs from 'dayjs' + import useDbQuery, { DbQueryHook } from 'hooks/analytics/useDbQuery' import useLogsQuery, { LogsQueryHook } from 'hooks/analytics/useLogsQuery' import type { BaseQueries, PresetConfig, ReportQuery } from './Reports.types' @@ -9,10 +11,13 @@ export const queryParamsToObject = (params: string) => { return Object.fromEntries(new URLSearchParams(params)) } -// generate hooks based on preset config export type PresetHookResult = LogsQueryHook | DbQueryHook type PresetHooks = Record PresetHookResult> - +/** + * @deprecated + * Queries are hooks, avoid generating hooks dynamically + * Generate fetch functions instead, and pass it to a hook inside the component + */ export const queriesFactory = ( queries: BaseQueries, projectRef: string @@ -35,3 +40,36 @@ export const queriesFactory = ( ) return hooks } + +/** + * Formats a timestamp to a human readable format in UTC + * + * @param timestamp - The timestamp to format + * @param returnUtc - Whether to return the timestamp in UTC + * @param format - The format to use for the timestamp + * @returns The formatted timestamp string + */ +export const formatTimestamp = ( + timestamp: number | string, + { returnUtc = false, format = 'MMM D, h:mma' }: { returnUtc?: boolean; format?: string } = {} +) => { + try { + const isSeconds = String(timestamp).length === 10 + const isMicroseconds = String(timestamp).length === 16 + + const timestampInMs = isSeconds + ? Number(timestamp) * 1000 + : isMicroseconds + ? Number(timestamp) / 1000 + : Number(timestamp) + + if (returnUtc) { + return dayjs.utc(timestampInMs).format(format) + } else { + return dayjs(timestampInMs).format(format) + } + } catch (error) { + console.error(error) + return 'Invalid Date' + } +} diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx new file mode 100644 index 00000000000..5a31fa5a00e --- /dev/null +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx @@ -0,0 +1,81 @@ +import Link from 'next/link' +import { useRef, useState } from 'react' + +import { LogChartHandler } from 'components/ui/Charts/LogChartHandler' +import { ReportConfig } from 'data/reports/v2/reports.types' +import { Button, Card, cn } from 'ui' + +export function ReportChartUpsell({ report, orgSlug }: { report: ReportConfig; orgSlug: string }) { + const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false) + + const startDate = '2025-01-01' + const endDate = '2025-01-02' + + const getExpDemoChartData = () => + new Array(20).fill(0).map((_, index) => ({ + period_start: new Date(startDate).getTime() + index * 1000, + demo: Math.floor(Math.pow(1.25, index) * 10), + max_demo: 1000, + })) + + const getDemoChartData = () => + new Array(20).fill(0).map((_, index) => ({ + period_start: new Date(startDate).getTime() + index * 1000, + demo: Math.floor(Math.random() * 10) + 1, + max_demo: 1000, + })) + + const demoChartData = useRef(getDemoChartData()) + const exponentialChartData = useRef(getExpDemoChartData()) + + const demoData = isHoveringUpgrade ? exponentialChartData.current : demoChartData.current + + return ( + +
+

{report.label}

+

+ This chart is available from{' '} + + {!!report.availableIn?.length ? report.availableIn[0] : 'Pro'} + {' '} + plan and above +

+ +
+
+ {}} + /> +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx new file mode 100644 index 00000000000..36538a71681 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx @@ -0,0 +1,139 @@ +import { useQuery } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { useState } from 'react' + +import { ComposedChart } from 'components/ui/Charts/ComposedChart' +import type { AnalyticsInterval } from 'data/analytics/constants' +import type { ReportConfig } from 'data/reports/v2/reports.types' +import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' +import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { Card, CardContent, cn } from 'ui' +import { ReportChartUpsell } from './ReportChartUpsell' + +export interface ReportChartV2Props { + report: ReportConfig + projectRef: string + startDate: string + endDate: string + interval: AnalyticsInterval + updateDateRange: (from: string, to: string) => void + functionIds?: string[] + edgeFnIdToName?: (id: string) => string | undefined + className?: string + syncId?: string +} + +export const ReportChartV2 = ({ + report, + projectRef, + startDate, + endDate, + interval, + updateDateRange, + functionIds, + edgeFnIdToName, + className, + syncId, +}: ReportChartV2Props) => { + const { data: org } = useSelectedOrganizationQuery() + const { plan: orgPlan } = useCurrentOrgPlan() + const orgPlanId = orgPlan?.id + + const isAvailable = + report.availableIn === undefined || (orgPlanId && report.availableIn.includes(orgPlanId)) + + const canFetch = orgPlanId !== undefined + + const { + data: queryResult, + isLoading: isLoadingChart, + error, + } = useQuery( + ['projects', projectRef, 'report-v2', report.id, { startDate, endDate, interval, functionIds }], + async () => { + return await report.dataProvider( + projectRef, + startDate, + endDate, + interval, + functionIds, + edgeFnIdToName + ) + }, + { + enabled: Boolean(projectRef && canFetch && isAvailable), + refetchOnWindowFocus: false, + } + ) + + const chartData = queryResult?.data || [] + const dynamicAttributes = queryResult?.attributes || [] + + const { data: filledChartData, isError: isFillError } = useFillTimeseriesSorted( + chartData, + 'timestamp', + (dynamicAttributes as any[]).map((attr: any) => attr.attribute), + 0, + startDate, + endDate, + undefined, + interval + ) + + const finalChartData = + filledChartData && filledChartData.length > 0 && !isFillError ? filledChartData : chartData + + const [chartStyle, setChartStyle] = useState(report.defaultChartStyle) + + if (!isAvailable && !isLoadingChart) { + return + } + + const isErrorState = error && !isLoadingChart + const showEmptyState = (!finalChartData || finalChartData.length === 0) && !isLoadingChart + + return ( + + + {isLoadingChart ? ( + + ) : showEmptyState ? ( +

+ No data available for the selected time range +

+ ) : isErrorState ? ( +

+ Error loading chart data +

+ ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 12383978073..52f5444c228 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -676,7 +676,7 @@ export const SQLEditor = () => { direction="vertical" autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE} > - +
{isLoading ? (
@@ -793,7 +793,7 @@ export const SQLEditor = () => { - + {isLoading ? (
@@ -811,7 +811,7 @@ export const SQLEditor = () => { )} -
+
{results?.rows !== undefined && !isExecuting && ( diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index d7704a00b44..8e933e22d5d 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -78,7 +78,7 @@ const UtilityActions = ({
+ {sql ? ( + + + + + + ) : null} + {!hideChartType && onChartStyleChange && ( - - -
) } - -export default ChartHeader diff --git a/apps/studio/components/ui/Charts/Charts.utils.tsx b/apps/studio/components/ui/Charts/Charts.utils.tsx index 43082aad54e..31463e0adc6 100644 --- a/apps/studio/components/ui/Charts/Charts.utils.tsx +++ b/apps/studio/components/ui/Charts/Charts.utils.tsx @@ -1,13 +1,10 @@ import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' import { FC, PropsWithChildren, useMemo } from 'react' import { ResponsiveContainer } from 'recharts' import { DateTimeFormats } from './Charts.constants' import type { CommonChartProps, StackedChartProps } from './Charts.types' -dayjs.extend(utc) - /** * Auto formats a number to a default precision if it is a float * diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index fb3a7172be8..89aabbe6f76 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -1,10 +1,7 @@ -'use client' - import dayjs from 'dayjs' import { formatBytes } from 'lib/helpers' import { useTheme } from 'next-themes' import { ComponentProps, useEffect, useState } from 'react' -import { useChartHoverState } from './useChartHoverState' import { Area, Bar, @@ -18,9 +15,10 @@ import { XAxis, YAxis, } from 'recharts' + import { CategoricalChartState } from 'recharts/types/chart/types' import { cn } from 'ui' -import ChartHeader from './ChartHeader' +import { ChartHeader } from './ChartHeader' import ChartHighlightActions from './ChartHighlightActions' import { CHART_COLORS, @@ -38,6 +36,7 @@ import { } from './ComposedChart.utils' import NoDataPlaceholder from './NoDataPlaceholder' import { ChartHighlight } from './useChartHighlight' +import { useChartHoverState } from './useChartHoverState' export interface ComposedChartProps extends CommonChartProps { attributes: MultiAttribute[] @@ -66,9 +65,10 @@ export interface ComposedChartProps extends CommonChartProps { hideHighlightArea?: boolean syncId?: string docsUrl?: string + sql?: string } -export default function ComposedChart({ +export function ComposedChart({ data, attributes, yAxisKey, @@ -104,6 +104,7 @@ export default function ComposedChart({ hideHighlightArea = false, syncId, docsUrl, + sql, }: ComposedChartProps) { const { resolvedTheme } = useTheme() const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( @@ -331,6 +332,7 @@ export default function ComposedChart({ shouldFormatBytes={shouldFormatBytes} isNetworkChart={isNetworkChart} attributes={attributes} + sql={sql} /> { * * Provided data must be in the expected chart format. */ -const LogChartHandler = ({ +export const LogChartHandler = ({ label, attributes, customDateFormat, @@ -107,6 +108,7 @@ const LogChartHandler = ({ valuePrecision, titleTooltip, id, + syncId, ...otherProps }: PropsWithChildren) => { const [chartStyle, setChartStyle] = useState(defaultChartStyle) @@ -143,7 +145,7 @@ const LogChartHandler = ({ data={data as any} format={format} xAxisKey="period_start" - yAxisKey={attributes[0].attribute} + yAxisKey={attributes[0]?.attribute} highlightedValue={highlightedValue} title={label} customDateFormat={customDateFormat} @@ -158,6 +160,7 @@ const LogChartHandler = ({ valuePrecision={valuePrecision} hideChartType={hideChartType} titleTooltip={titleTooltip} + syncId={syncId} {...otherProps} /> diff --git a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx index f50878890a7..31ff9b9f669 100644 --- a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx +++ b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx @@ -1,7 +1,7 @@ import { BarChart2 } from 'lucide-react' import { cn } from 'ui' -import ChartHeader from './ChartHeader' +import { ChartHeader } from './ChartHeader' import { useChartSize } from './Charts.utils' interface NoDataPlaceholderProps { diff --git a/apps/studio/components/ui/Charts/StackedBarChart.tsx b/apps/studio/components/ui/Charts/StackedBarChart.tsx index 0ef800be4b3..ac12386f3b2 100644 --- a/apps/studio/components/ui/Charts/StackedBarChart.tsx +++ b/apps/studio/components/ui/Charts/StackedBarChart.tsx @@ -1,9 +1,8 @@ +import dayjs from 'dayjs' import { useState } from 'react' import { Bar, BarChart, Cell, Legend, Tooltip, XAxis } from 'recharts' -import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' -import { useChartHoverState } from './useChartHoverState' -import ChartHeader from './ChartHeader' + +import { ChartHeader } from './ChartHeader' import { CHART_COLORS, DateTimeFormats, @@ -20,7 +19,7 @@ import { useStacked, } from './Charts.utils' import NoDataPlaceholder from './NoDataPlaceholder' -dayjs.extend(utc) +import { useChartHoverState } from './useChartHoverState' interface Props extends CommonChartProps { xAxisKey: string diff --git a/apps/studio/components/ui/Panel.tsx b/apps/studio/components/ui/Panel.tsx index e17362b66fb..bf802d991b1 100644 --- a/apps/studio/components/ui/Panel.tsx +++ b/apps/studio/components/ui/Panel.tsx @@ -15,6 +15,9 @@ interface PanelProps { titleClasses?: string } +/** + * @deprecated Use Card component from ui package instead + */ function Panel(props: PropsWithChildren) { const content = (
Generate a password + .

) diff --git a/apps/studio/data/edge-functions/edge-function-body-query.ts b/apps/studio/data/edge-functions/edge-function-body-query.ts index d604b96395f..ddd91121faf 100644 --- a/apps/studio/data/edge-functions/edge-function-body-query.ts +++ b/apps/studio/data/edge-functions/edge-function-body-query.ts @@ -15,6 +15,7 @@ export type EdgeFunctionFile = { } export type EdgeFunctionBodyResponse = { + version: number files: EdgeFunctionFile[] } @@ -51,11 +52,14 @@ export async function getEdgeFunctionBody( ) } - const { files } = await parseResponse.json() - return files as EdgeFunctionFile[] + const response = (await parseResponse.json()) as EdgeFunctionBodyResponse + return response } catch (error) { handleError(error) - return [] + return { + version: 0, + files: [], + } as EdgeFunctionBodyResponse } } diff --git a/apps/studio/data/organizations/organization-available-regions-query.ts b/apps/studio/data/organizations/organization-available-regions-query.ts index 93eed9a4f0e..01bcd9af28f 100644 --- a/apps/studio/data/organizations/organization-available-regions-query.ts +++ b/apps/studio/data/organizations/organization-available-regions-query.ts @@ -6,7 +6,7 @@ import { organizationKeys } from './keys' export type OrganizationAvailableRegionsVariables = { slug?: string - cloudProvider: 'AWS' | 'FLY' | 'AWS_K8S' + cloudProvider: 'AWS' | 'FLY' | 'AWS_K8S' | 'AWS_NIMBUS' } export async function getOrganizationAvailableRegions( diff --git a/apps/studio/data/projects/new-project.constants.ts b/apps/studio/data/projects/new-project.constants.ts index 58f55cf9faa..accac2b6757 100644 --- a/apps/studio/data/projects/new-project.constants.ts +++ b/apps/studio/data/projects/new-project.constants.ts @@ -31,7 +31,12 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.01344, priceMonthly: 10, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, small: { label: 'Small', @@ -39,7 +44,12 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.0206, priceMonthly: 15, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, medium: { label: 'Medium', @@ -47,7 +57,12 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.0822, priceMonthly: 60, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, large: { label: 'Large', @@ -55,7 +70,12 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.1517, priceMonthly: 110, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, xlarge: { label: 'XL', @@ -63,7 +83,12 @@ export const instanceSizeSpecs: Record< cpu: '4-core', priceHourly: 0.2877, priceMonthly: 210, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, '2xlarge': { label: '2XL', @@ -71,7 +96,12 @@ export const instanceSizeSpecs: Record< cpu: '8-core', priceHourly: 0.562, priceMonthly: 410, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, '4xlarge': { label: '4XL', @@ -79,7 +109,12 @@ export const instanceSizeSpecs: Record< cpu: '16-core', priceHourly: 1.32, priceMonthly: 960, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, '8xlarge': { label: '8XL', @@ -87,7 +122,7 @@ export const instanceSizeSpecs: Record< cpu: '32-core', priceHourly: 2.562, priceMonthly: 1870, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '12xlarge': { label: '12XL', @@ -95,7 +130,7 @@ export const instanceSizeSpecs: Record< cpu: '48-core', priceHourly: 3.836, priceMonthly: 2800, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '16xlarge': { label: '16XL', @@ -103,7 +138,7 @@ export const instanceSizeSpecs: Record< cpu: '64-core', priceHourly: 5.12, priceMonthly: 3730, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '24xlarge': { label: '24XL', @@ -111,7 +146,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 9.73, priceMonthly: 7100, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '24xlarge_optimized_cpu': { label: '24XL - Optimized CPU', @@ -119,7 +154,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 8.9, priceMonthly: 6500, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '24xlarge_optimized_memory': { label: '24XL - Optimized Memory', @@ -127,7 +162,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 13.84, priceMonthly: 10100, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '24xlarge_high_memory': { label: '24XL - High Memory', @@ -135,7 +170,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 21.91, priceMonthly: 16000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '48xlarge': { label: '48XL', @@ -143,7 +178,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 19.47, priceMonthly: 14200, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '48xlarge_optimized_cpu': { label: '48XL - Optimized CPU', @@ -151,7 +186,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 17.8, priceMonthly: 13000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '48xlarge_optimized_memory': { label: '48XL - Optimized Memory', @@ -159,7 +194,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 27.68, priceMonthly: 20200, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '48xlarge_high_memory': { label: '48XL - High Memory', @@ -167,6 +202,6 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 43.84, priceMonthly: 32000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, } diff --git a/apps/studio/data/reports/edgefn-charts.ts b/apps/studio/data/reports/edgefn-charts.ts deleted file mode 100644 index dbcd204e53d..00000000000 --- a/apps/studio/data/reports/edgefn-charts.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ReportAttributes } from 'components/ui/Charts/ComposedChart.utils' - -export const getEdgeFunctionReportAttributes = (): ReportAttributes[] => [ - { - id: 'execution-status-codes', - label: 'Edge Function Status Codes', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of edge function executions by status code.', - availableIn: ['free', 'pro', 'team', 'enterprise'], - attributes: [ - { - attribute: 'ExecutionStatusCodes', - provider: 'logs', - label: 'Execution Status Codes', - }, - ], - }, - { - id: 'execution-time', - label: 'Edge Function Execution Time', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - titleTooltip: 'Average execution time for edge functions.', - availableIn: ['free', 'pro', 'team', 'enterprise'], - format: 'ms', - YAxisProps: { - width: 50, - tickFormatter: (value: number) => `${value}ms`, - }, - attributes: [ - { - attribute: 'ExecutionTime', - label: 'Avg. Execution Time (ms)', - provider: 'logs', - enabled: true, - }, - ], - }, - { - id: 'invocations-by-region', - label: 'Edge Function Invocations by Region', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of edge function invocations by region.', - availableIn: ['pro', 'team', 'enterprise'], - attributes: [ - { - attribute: 'InvocationsByRegion', - provider: 'logs', - label: 'Invocations by Region', - }, - ], - }, -] diff --git a/apps/studio/data/reports/edgefn-query.ts b/apps/studio/data/reports/edgefn-query.ts deleted file mode 100644 index 1671f7e4ec4..00000000000 --- a/apps/studio/data/reports/edgefn-query.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { get } from 'data/fetchers' -import { AnalyticsInterval } from 'data/analytics/constants' -import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import { getHttpStatusCodeInfo } from 'lib/http-status-codes' -import { analyticsIntervalToGranularity, useEdgeFnIdToName } from './report.utils' -import { REPORT_STATUS_CODE_COLORS } from './report.utils' - -/** - * METRICS - * Each chart in the UI has a corresponding metric key. - */ - -const METRIC_KEYS = [ - 'TotalInvocations', - 'ExecutionStatusCodes', - 'InvocationsByRegion', - 'ExecutionTime', -] - -type MetricKey = (typeof METRIC_KEYS)[number] - -/** - * SQL - * Each metric has a corresponding SQL query. - */ - -const METRIC_SQL: Record< - MetricKey, - (interval: AnalyticsInterval, functionIds?: string[]) => string -> = { - TotalInvocations: (interval, functionIds) => { - return ` ---edgefn-report-invocations -select - timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, - function_id, - count(*) as count -from - function_edge_logs - CROSS JOIN UNNEST(metadata) AS m - CROSS JOIN UNNEST(m.request) AS request - CROSS JOIN UNNEST(m.response) AS response - ${ - functionIds && functionIds.length > 0 - ? `WHERE function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` - : '' - } -group by - timestamp, - function_id -order by - timestamp desc; - - ` - }, - ExecutionStatusCodes: (interval, functionIds) => { - return ` ---edgefn-report-execution-status-codes -select - timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, - response.status_code AS status_code, - COUNT(*) AS count -FROM - function_edge_logs - CROSS JOIN UNNEST(metadata) AS m - CROSS JOIN UNNEST(m.response) AS response - CROSS JOIN UNNEST(m.request) AS request - ${ - functionIds && functionIds.length > 0 - ? `WHERE function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` - : '' - } -group by - timestamp, - status_code -order by - timestamp desc; - ` - }, - InvocationsByRegion: (interval, functionIds) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` ---edgefn-report-invocations-by-region -select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - h.x_sb_edge_region as region, - count(*) as count -from - function_edge_logs - cross join unnest(metadata) as m - cross join unnest(m.response) as r - cross join unnest(r.headers) as h - where h.x_sb_edge_region is not null - ${ - functionIds && functionIds.length > 0 - ? `and function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` - : '' - } -group by - timestamp, - region -order by - timestamp desc - ` - }, - ExecutionTime: (interval, functionIds) => { - const granularity = analyticsIntervalToGranularity(interval) - const hasFunctions = functionIds && functionIds.length > 0 - return ` ---edgefn-report-execution-time -select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${hasFunctions ? 'function_id,' : ''} - avg(m.execution_time_ms) as avg_execution_time -from - function_edge_logs - cross join unnest(metadata) as m - cross join unnest(m.request) as request - ${hasFunctions ? `where function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` : ''} -group by - timestamp - ${hasFunctions ? ', function_id' : ''} -order by - timestamp desc - ` - }, -} - -/** - * FORMATTERS. - * Metrics need to be formatted before being passed on to the UI charts. - */ - -function defaultFormatter(rawData: any, attributes: MultiAttribute[]) { - const chartAttributes = attributes - if (!rawData) return { data: undefined, chartAttributes } - const result = rawData.result || [] - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[attributes[0].attribute] = p.count - }) - return point - }) - return { data, chartAttributes } -} - -const METRIC_FORMATTER: Record< - MetricKey, - ( - rawData: any, - attributes: MultiAttribute[], - logsMetric: string, - functionIds?: string[], - edgeFnIdToName?: (id: string) => string | undefined - ) => { data: any; chartAttributes: any } -> = { - TotalInvocations: (rawData, attributes, logsMetric, functionIds, edgeFnIdToName) => { - // Always use dynamic attributes, so the chart can show per-function stats. - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - - const functionIdsInData = Array.from(new Set(result.map((p: any) => p.function_id))) as string[] - - const chartFunctionIds = functionIds && functionIds.length > 0 ? functionIds : functionIdsInData - - if (chartFunctionIds.length === 0) { - return { data: [], chartAttributes: [] } // No data, empty chart - } - - const chartAttributes = chartFunctionIds.map((id: string) => ({ - attribute: id, - label: edgeFnIdToName?.(id) ?? id, - provider: 'logs', - enabled: true, - })) - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[p.function_id as string] = p.count - }) - return point - }) - - return { data, chartAttributes } - }, - ExecutionStatusCodes: (rawData, attributes) => { - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - - const statusCodes = Array.from(new Set(result.map((p: any) => p.status_code))) - - const chartAttributes = statusCodes.map((statusCode) => { - const statusCodeInfo = getHttpStatusCodeInfo(Number(statusCode)) - const color = - REPORT_STATUS_CODE_COLORS[String(statusCode)] || REPORT_STATUS_CODE_COLORS.default - - return { - attribute: `status_${statusCode}`, - label: `${statusCode} ${statusCodeInfo.label}`, - provider: 'logs', - enabled: true, - color: color, - statusCode: String(statusCode), - } - }) - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[`status_${p.status_code}`] = p.count - }) - return point - }) - - return { data, chartAttributes } - }, - InvocationsByRegion: (rawData, attributes) => { - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - - const regions = Array.from(new Set(result.map((p: any) => p.region))).filter(Boolean) - - if (regions.length === 0) { - return { data: [], chartAttributes: [] } // No data, empty chart - } - - const chartAttributes = regions.map((region) => { - return { - attribute: region, - label: region, - provider: 'logs', - enabled: true, - } - }) - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute as string] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[p.region] = p.count - }) - return point - }) - - return { data, chartAttributes } - }, - ExecutionTime: (rawData, attributes, logsMetric, functionIds, edgeFnIdToName) => { - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - const hasFunctions = functionIds && functionIds.length > 0 - - if (hasFunctions) { - const chartAttributes = functionIds.map((id: string) => ({ - attribute: id, - label: edgeFnIdToName?.(id) ?? id, - provider: 'logs', - enabled: true, - })) - - if (result.length === 0) { - return { data: [], chartAttributes } - } - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[p.function_id as string] = p.avg_execution_time - }) - return point - }) - - return { data, chartAttributes } - } else { - const chartAttributes = [ - { - attribute: 'avg_execution_time', - label: 'Avg. execution time (ms)', - provider: 'logs', - enabled: true, - }, - ] - - const data = result - .map((p: any) => ({ - period_start: p.timestamp, - avg_execution_time: p.avg_execution_time, - })) - .sort( - (a: { period_start: string }, b: { period_start: string }) => - new Date(a.period_start).getTime() - new Date(b.period_start).getTime() - ) - - return { data, chartAttributes } - } - }, -} - -/** - * REPORT QUERY. - * Fetching and state management for the report. - */ - -export function useEdgeFunctionReport({ - projectRef, - attributes, - startDate, - endDate, - interval, - enabled = true, - functionIds, -}: { - projectRef: string - attributes: MultiAttribute[] - startDate: string - endDate: string - interval: AnalyticsInterval - enabled?: boolean - functionIds?: string[] -}) { - const logsMetric = attributes.length > 0 ? attributes[0].attribute : '' - const { edgeFnIdToName } = useEdgeFnIdToName({ projectRef }) - const isEdgeFnMetric = METRIC_KEYS.includes(logsMetric) - - const sql = isEdgeFnMetric ? METRIC_SQL[logsMetric as MetricKey](interval, functionIds) : '' - - const { - data: rawData, - error, - isLoading, - } = useQuery( - [ - 'edge-function-report', - projectRef, - logsMetric, - startDate, - endDate, - interval, - sql, - functionIds, - ], - async () => { - const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { - params: { - path: { ref: projectRef }, - query: { - sql, - iso_timestamp_start: startDate, - iso_timestamp_end: endDate, - }, - }, - }) - if (error) throw error - return data - }, - { - enabled: Boolean(projectRef && sql && enabled && isEdgeFnMetric), - refetchOnWindowFocus: false, - } - ) - - // Use formatter if available - const formatter = - (isEdgeFnMetric ? METRIC_FORMATTER[logsMetric as MetricKey] : undefined) || defaultFormatter - const { data, chartAttributes } = formatter( - rawData, - attributes, - logsMetric, - functionIds, - edgeFnIdToName - ) - - return { - data, - attributes: chartAttributes, - isLoading, - error, - } -} diff --git a/apps/studio/data/reports/v2/edge-functions.config.ts b/apps/studio/data/reports/v2/edge-functions.config.ts new file mode 100644 index 00000000000..2e65c925232 --- /dev/null +++ b/apps/studio/data/reports/v2/edge-functions.config.ts @@ -0,0 +1,395 @@ +import dayjs from 'dayjs' + +import { + isUnixMicro, + unixMicroToIsoTimestamp, +} from 'components/interfaces/Settings/Logs/Logs.utils' +import type { AnalyticsInterval } from 'data/analytics/constants' +import { get } from 'data/fetchers' +import { + analyticsIntervalToGranularity, + REPORT_STATUS_CODE_COLORS, +} from 'data/reports/report.utils' +import { getHttpStatusCodeInfo } from 'lib/http-status-codes' +import { ReportConfig } from './reports.types' + +const METRIC_SQL: Record string> = + { + TotalInvocations: (interval, functionIds) => { + return ` +--edgefn-report-invocations +select + timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, + function_id, + count(*) as count +from + function_edge_logs + CROSS JOIN UNNEST(metadata) AS m + CROSS JOIN UNNEST(m.request) AS request + CROSS JOIN UNNEST(m.response) AS response + ${ + functionIds && functionIds.length > 0 + ? `WHERE function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` + : '' + } +group by + timestamp, + function_id +order by + timestamp desc; +` + }, + ExecutionStatusCodes: (interval, functionIds) => { + return ` +--edgefn-report-execution-status-codes +select + timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, + response.status_code as status_code, + count(response.status_code) as count +from + function_edge_logs + cross join unnest(metadata) as m + cross join unnest(m.response) as response + ${ + functionIds && functionIds.length > 0 + ? `where function_id in (${functionIds.map((id) => `'${id}'`).join(',')})` + : '' + } +group by + timestamp, + status_code +order by + timestamp desc +` + }, + InvocationsByRegion: (interval, functionIds) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` +--edgefn-report-invocations-by-region +select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + h.x_sb_edge_region as region, + count(*) as count +from + function_edge_logs + cross join unnest(metadata) as m + cross join unnest(m.response) as r + cross join unnest(r.headers) as h + where h.x_sb_edge_region is not null + ${ + functionIds && functionIds.length > 0 + ? `and function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` + : '' + } +group by + timestamp, + region +order by + timestamp desc +` + }, + ExecutionTime: (interval, functionIds) => { + const granularity = analyticsIntervalToGranularity(interval) + const hasFunctions = functionIds && functionIds.length > 0 + return ` +--edgefn-report-execution-time +select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + ${hasFunctions ? 'function_id,' : ''} + avg(m.execution_time_ms) as avg_execution_time +from + function_edge_logs + cross join unnest(metadata) as m + cross join unnest(m.request) as request + ${hasFunctions ? `where function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` : ''} +group by + timestamp + ${hasFunctions ? ', function_id' : ''} +order by + timestamp desc +` + }, + } + +async function runQuery(projectRef: string, sql: string, startDate: string, endDate: string) { + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + }, + }, + }) + if (error) throw error + return data +} + +export function extractStatusCodesFromData(data: any[]): string[] { + const statusCodes = new Set() + + data.forEach((item: any) => { + if (item.status_code) { + statusCodes.add(String(item.status_code)) + } + }) + + return Array.from(statusCodes).sort() +} + +export function generateStatusCodeAttributes(statusCodes: string[]) { + return statusCodes.map((code) => ({ + attribute: code, + label: `${code} ${getHttpStatusCodeInfo(parseInt(code)).label}`, + color: REPORT_STATUS_CODE_COLORS[code] || REPORT_STATUS_CODE_COLORS.default, + })) +} + +/** + * Converts a list of { timestamp, status_code, count } + * to a list of { timestamp, [status_code]: count } + * That we can pass to the chart for rendering + */ +export function transformStatusCodeData(data: any[], statusCodes: string[]) { + const pivotedData = data.reduce((acc: Record, d: any) => { + const timestamp = isUnixMicro(d.timestamp) + ? unixMicroToIsoTimestamp(d.timestamp) + : dayjs.utc(d.timestamp).toISOString() + if (!acc[timestamp]) { + acc[timestamp] = { timestamp } + statusCodes.forEach((code) => { + acc[timestamp][code] = 0 + }) + } + acc[timestamp][d.status_code] = d.count + return acc + }, {}) + + return Object.values(pivotedData) +} + +/** + * Transforms raw invocation data by normalizing timestamps and adding function names + * @param data - Raw data from the database + * @param functions - Array of function objects with id and name + * @returns Transformed data with normalized timestamps and function names + */ +export function transformInvocationData(data: any[], functions: { id: string; name: string }[]) { + return data.map((log: any) => ({ + ...log, + timestamp: isUnixMicro(log.timestamp) + ? unixMicroToIsoTimestamp(log.timestamp) + : dayjs.utc(log.timestamp).toISOString(), + function_name: functions.find((f) => f.id === log.function_id)?.name ?? log.function_id, + })) +} + +/** + * Aggregates invocation data by timestamp, summing counts for each timestamp + * @param data - Transformed invocation data + * @returns Aggregated data with one entry per timestamp + */ +export function aggregateInvocationsByTimestamp(data: any[]) { + const aggregatedData = data.reduce((acc: Record, item: any) => { + const timestamp = item.timestamp + if (!acc[timestamp]) { + acc[timestamp] = { timestamp, count: 0 } + } + acc[timestamp].count += item.count + return acc + }, {}) + + return Object.values(aggregatedData) +} + +export const edgeFunctionReports = ({ + projectRef, + functions, + startDate, + endDate, + interval, + filters, +}: { + projectRef: string + functions: { id: string; name: string }[] + startDate: string + endDate: string + interval: AnalyticsInterval + filters: { + functionIds?: string[] + } +}): ReportConfig[] => [ + { + id: 'total-invocations', + label: 'Total Edge Function Invocations', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of edge function invocations over time.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const sql = METRIC_SQL.TotalInvocations(interval, filters.functionIds) + const response = await runQuery(projectRef, sql, startDate, endDate) + + if (!response?.result) return { data: [] } + + // Transform and aggregate the data using extracted functions + const transformedData = transformInvocationData(response.result, functions) + const data = aggregateInvocationsByTimestamp(transformedData) + + const attributes = [ + { + attribute: 'count', + label: 'Count', + }, + ] + + return { data, attributes, query: sql } + }, + }, + { + id: 'execution-status-codes', + label: 'Edge Function Status Codes', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of edge function executions by status code.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async ( + projectRef: string, + startDate: string, + endDate: string, + interval: AnalyticsInterval, + functionIds?: string[] + ) => { + const sql = METRIC_SQL.ExecutionStatusCodes(interval, functionIds) + const rawData = await runQuery(projectRef, sql, startDate, endDate) + + if (!rawData?.result) return { data: [] } + + /** + * The query returns { timestamp, status_code: 500, count: 10 } + * and we have to transform it to { timestamp, 500: 10 } + * to be able to render the chart. + */ + + const statusCodes = extractStatusCodesFromData(rawData.result) + const attributes = generateStatusCodeAttributes(statusCodes) + + const data = transformStatusCodeData(rawData.result, statusCodes) + + return { data, attributes, query: sql } + }, + }, + { + id: 'execution-time', + label: 'Edge Function Execution Time', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Average execution time for edge functions.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + YAxisProps: { + width: 50, + tickFormatter: (value: number) => `${value}ms`, + }, + format: (value: unknown) => `${Number(value).toFixed(0)}ms`, + dataProvider: async () => { + const sql = METRIC_SQL.ExecutionTime(interval, filters.functionIds) + const rawData = await runQuery(projectRef, sql, startDate, endDate) + + if (!rawData?.result) return { data: [] } + + // Transform the raw data to ensure one data point per timestamp + const transformedData = rawData.result?.map((point: any) => ({ + ...point, + timestamp: isUnixMicro(point.timestamp) + ? unixMicroToIsoTimestamp(point.timestamp) + : dayjs.utc(point.timestamp).toISOString(), + function_name: functions.find((f) => f.id === point.function_id)?.name ?? point.function_id, + })) + + // If we have multiple function IDs, we need to aggregate the execution times per timestamp + const aggregatedData = transformedData.reduce((acc: Record, item: any) => { + const timestamp = item.timestamp + if (!acc[timestamp]) { + acc[timestamp] = { + timestamp, + avg_execution_time: item.avg_execution_time, + count: 1, + } + } else { + // Calculate weighted average for multiple functions at the same timestamp + const totalTime = + acc[timestamp].avg_execution_time * acc[timestamp].count + item.avg_execution_time + acc[timestamp].count += 1 + acc[timestamp].avg_execution_time = totalTime / acc[timestamp].count + } + return acc + }, {}) + + const data = Object.values(aggregatedData).map(({ count, ...item }) => item) + + const attributes = [ + { + attribute: 'avg_execution_time', + label: 'Avg. execution time (ms)', + }, + ] + return { data, attributes, query: sql } + }, + }, + { + id: 'invocations-by-region', + label: 'Edge Function Invocations by Region', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of edge function invocations by region.', + availableIn: ['pro', 'team', 'enterprise'], + dataProvider: async () => { + const sql = METRIC_SQL.InvocationsByRegion(interval, filters.functionIds) + const rawData = await runQuery(projectRef, sql, startDate, endDate) + const data = rawData.result?.map((point: any) => ({ + ...point, + timestamp: isUnixMicro(point.timestamp) + ? unixMicroToIsoTimestamp(point.timestamp) + : dayjs.utc(point.timestamp).toISOString(), + })) + + const attributes = [ + { + attribute: 'region', + label: 'Region', + provider: 'logs', + enabled: true, + }, + { + attribute: 'count', + label: 'Count', + provider: 'logs', + enabled: true, + }, + ] + + return { data, attributes, query: sql } + }, + }, +] diff --git a/apps/studio/data/reports/v2/edge-functions.test.tsx b/apps/studio/data/reports/v2/edge-functions.test.tsx new file mode 100644 index 00000000000..e1217d7b7a0 --- /dev/null +++ b/apps/studio/data/reports/v2/edge-functions.test.tsx @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest' +import { + extractStatusCodesFromData, + generateStatusCodeAttributes, + transformStatusCodeData, + transformInvocationData, + aggregateInvocationsByTimestamp, +} from './edge-functions.config' + +describe('extractStatusCodesFromData', () => { + it('should extract and sort unique status codes from the data', () => { + const data = [ + { status_code: 200 }, + { status_code: 500 }, + { status_code: 200 }, + { status_code: 404 }, + ] + const result = extractStatusCodesFromData(data) + expect(result).toEqual(['200', '404', '500']) + }) + + it('should handle an empty array', () => { + const data: any[] = [] + const result = extractStatusCodesFromData(data) + expect(result).toEqual([]) + }) + + it('should handle data with missing status_code properties', () => { + const data = [{ status_code: 200 }, {}, { status_code: 500 }] + const result = extractStatusCodesFromData(data) + expect(result).toEqual(['200', '500']) + }) + + it('should handle various data types for status_code', () => { + const data = [{ status_code: 200 }, { status_code: '500' }, { status_code: 404 }] + const result = extractStatusCodesFromData(data) + expect(result).toEqual(['200', '404', '500']) + }) +}) + +describe('generateStatusCodeAttributes', () => { + it('should generate the correct attributes for a list of status codes', () => { + const statusCodes = ['200', '404', '500'] + const result = generateStatusCodeAttributes(statusCodes) + expect(result.map(({ color, ...rest }) => rest)).toEqual([ + { + attribute: '200', + label: '200 OK', + }, + { + attribute: '404', + label: '404 Not Found', + }, + { + attribute: '500', + label: '500 Internal Server Error', + }, + ]) + }) + + it('should handle an empty array', () => { + const statusCodes: string[] = [] + const result = generateStatusCodeAttributes(statusCodes) + expect(result).toEqual([]) + }) +}) + +describe('transformStatusCodeData', () => { + it('should pivot the data correctly', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00Z', status_code: 200, count: 10 }, + { timestamp: '2023-01-01T00:00:00Z', status_code: 500, count: 5 }, + { timestamp: '2023-01-02T00:00:00Z', status_code: 200, count: 20 }, + ] + const result = transformStatusCodeData(data, ['200', '500']) + expect(result).toEqual([ + { timestamp: '2023-01-01T00:00:00.000Z', '200': 10, '500': 5 }, + { timestamp: '2023-01-02T00:00:00.000Z', '200': 20, '500': 0 }, + ]) + }) + + it('should handle an empty array', () => { + const data: any[] = [] + const result = transformStatusCodeData(data, []) + expect(result).toEqual([]) + }) + + it('should handle a single entry', () => { + const data = [{ timestamp: '2023-01-01T00:00:00Z', status_code: 200, count: 10 }] + const result = transformStatusCodeData(data, ['200', '404']) + expect(result).toEqual([{ timestamp: '2023-01-01T00:00:00.000Z', '200': 10, '404': 0 }]) + }) +}) + +describe('transformInvocationData', () => { + const mockFunctions = [ + { id: 'func1', name: 'Function One' }, + { id: 'func2', name: 'Function Two' }, + ] + + it('should transform raw invocation data with function names', () => { + const rawData = [ + { + timestamp: '2023-01-01T00:00:00Z', + function_id: 'func1', + count: 10, + }, + { + timestamp: '2023-01-01T01:00:00Z', + function_id: 'func2', + count: 5, + }, + ] + + const result = transformInvocationData(rawData, mockFunctions) + expect(result).toEqual([ + { + timestamp: '2023-01-01T00:00:00.000Z', + function_id: 'func1', + count: 10, + function_name: 'Function One', + }, + { + timestamp: '2023-01-01T01:00:00.000Z', + function_id: 'func2', + count: 5, + function_name: 'Function Two', + }, + ]) + }) + + it('should use function_id as fallback when function name not found', () => { + const rawData = [ + { + timestamp: '2023-01-01T00:00:00Z', + function_id: 'unknown_func', + count: 10, + }, + ] + + const result = transformInvocationData(rawData, mockFunctions) + expect(result).toEqual([ + { + timestamp: '2023-01-01T00:00:00.000Z', + function_id: 'unknown_func', + count: 10, + function_name: 'unknown_func', + }, + ]) + }) + + it('should handle unix micro timestamps', () => { + const rawData = [ + { + timestamp: 1672531200000000, // Unix micro timestamp for 2023-01-01T00:00:00Z + function_id: 'func1', + count: 10, + }, + ] + + const result = transformInvocationData(rawData, mockFunctions) + expect(result[0].timestamp).toBe('2023-01-01T00:00:00.000Z') + expect(result[0].function_name).toBe('Function One') + }) + + it('should handle empty data array', () => { + const result = transformInvocationData([], mockFunctions) + expect(result).toEqual([]) + }) + + it('should handle empty functions array', () => { + const rawData = [ + { + timestamp: '2023-01-01T00:00:00Z', + function_id: 'func1', + count: 10, + }, + ] + + const result = transformInvocationData(rawData, []) + expect(result).toEqual([ + { + timestamp: '2023-01-01T00:00:00.000Z', + function_id: 'func1', + count: 10, + function_name: 'func1', + }, + ]) + }) +}) + +describe('aggregateInvocationsByTimestamp', () => { + it('should aggregate counts by timestamp', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func1', count: 10 }, + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func2', count: 5 }, + { timestamp: '2023-01-01T01:00:00.000Z', function_id: 'func1', count: 20 }, + ] + + const result = aggregateInvocationsByTimestamp(data) + expect(result).toEqual([ + { timestamp: '2023-01-01T00:00:00.000Z', count: 15 }, + { timestamp: '2023-01-01T01:00:00.000Z', count: 20 }, + ]) + }) + + it('should handle single entry per timestamp', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func1', count: 10 }, + { timestamp: '2023-01-01T01:00:00.000Z', function_id: 'func2', count: 5 }, + ] + + const result = aggregateInvocationsByTimestamp(data) + expect(result).toEqual([ + { timestamp: '2023-01-01T00:00:00.000Z', count: 10 }, + { timestamp: '2023-01-01T01:00:00.000Z', count: 5 }, + ]) + }) + + it('should handle empty data array', () => { + const result = aggregateInvocationsByTimestamp([]) + expect(result).toEqual([]) + }) + + it('should handle multiple entries with same timestamp and different counts', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func1', count: 1 }, + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func2', count: 2 }, + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func3', count: 3 }, + ] + + const result = aggregateInvocationsByTimestamp(data) + expect(result).toEqual([{ timestamp: '2023-01-01T00:00:00.000Z', count: 6 }]) + }) + + it('should preserve timestamp order from reduce operation', () => { + const data = [ + { timestamp: '2023-01-01T02:00:00.000Z', function_id: 'func1', count: 30 }, + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func1', count: 10 }, + { timestamp: '2023-01-01T01:00:00.000Z', function_id: 'func1', count: 20 }, + ] + + const result = aggregateInvocationsByTimestamp(data) + expect(result).toHaveLength(3) + expect(result.map((item) => item.timestamp)).toEqual([ + '2023-01-01T02:00:00.000Z', + '2023-01-01T00:00:00.000Z', + '2023-01-01T01:00:00.000Z', + ]) + }) +}) diff --git a/apps/studio/data/reports/v2/reports.types.ts b/apps/studio/data/reports/v2/reports.types.ts new file mode 100644 index 00000000000..5edd0b23791 --- /dev/null +++ b/apps/studio/data/reports/v2/reports.types.ts @@ -0,0 +1,45 @@ +import { AnalyticsInterval } from 'data/analytics/constants' +import { YAxisProps } from 'recharts' + +type ReportDataProviderFilter = { + functionIds?: string[] +} + +export interface ReportDataProvider { + ( + projectRef: string, + startDate: string, + endDate: string, + interval: AnalyticsInterval, + functionIds?: string[], + edgeFnIdToName?: (id: string) => string | undefined, + filters?: ReportDataProviderFilter[] + ): Promise<{ + data: any + attributes?: { + attribute: string + label: string + color?: { light: string; dark: string } + }[] + query?: string // The SQL used to fetch the data if any + }> // [jordi] would be cool to have a type that forces data keys to match the attributes +} + +export interface ReportConfig { + id: string + label: string + dataProvider: ReportDataProvider + valuePrecision: number + hide: boolean + showTooltip: boolean + showLegend: boolean + showMaxValue: boolean + hideChartType: boolean + defaultChartStyle: string + titleTooltip: string + availableIn: string[] + format?: (value: unknown) => string + YAxisProps?: YAxisProps + xAxisKey?: string + yAxisKey?: string +} diff --git a/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts index 4319a90f7cb..7f8629b974d 100644 --- a/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts +++ b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts @@ -201,13 +201,15 @@ export const useEdgeFunctionsDiff = ({ const mainBody = mainBodiesMap[slug] if (!currentBody || !mainBody) return - const allFileKeys = new Set([...currentBody, ...mainBody].map((f) => fileKey(f.name))) + const allFileKeys = new Set( + [...currentBody.files, ...mainBody.files].map((f) => fileKey(f.name)) + ) const fileInfos: FileInfo[] = [] let hasModifications = false for (const key of allFileKeys) { - const currentFile = currentBody.find((f) => fileKey(f.name) === key) - const mainFile = mainBody.find((f) => fileKey(f.name) === key) + const currentFile = currentBody.files.find((f) => fileKey(f.name) === key) + const mainFile = mainBody.files.find((f) => fileKey(f.name) === key) let status: FileStatus = 'unchanged' @@ -236,7 +238,7 @@ export const useEdgeFunctionsDiff = ({ addedSlugs.forEach((slug) => { const body = addedBodiesMap[slug] if (body) { - functionFileInfo[slug] = body.map((file) => ({ + functionFileInfo[slug] = body.files.map((file) => ({ key: fileKey(file.name), status: 'added' as FileStatus, })) @@ -247,7 +249,7 @@ export const useEdgeFunctionsDiff = ({ removedSlugs.forEach((slug) => { const body = removedBodiesMap[slug] if (body) { - functionFileInfo[slug] = body.map((file) => ({ + functionFileInfo[slug] = body.files.map((file) => ({ key: fileKey(file.name), status: 'removed' as FileStatus, })) diff --git a/apps/studio/hooks/custom-content/CustomContent.types.ts b/apps/studio/hooks/custom-content/CustomContent.types.ts index 66816dffa96..28568d054b3 100644 --- a/apps/studio/hooks/custom-content/CustomContent.types.ts +++ b/apps/studio/hooks/custom-content/CustomContent.types.ts @@ -1,4 +1,5 @@ -import { ConnectionType } from 'components/interfaces/Connect/Connect.constants' +import { CONNECTION_TYPES } from 'components/interfaces/Connect/Connect.constants' +import type { CloudProvider } from 'shared-data' export type CustomContentTypes = { organizationLegalDocuments: { @@ -25,9 +26,7 @@ export type CustomContentTypes = { * * These could be helpful in rendering, for e.g an environment file like `.env` */ - connectFrameworks: { - key: string - label: string - obj: ConnectionType[] - } + connectFrameworks: (typeof CONNECTION_TYPES)[number] + + infraCloudProviders: CloudProvider[] } diff --git a/apps/studio/hooks/custom-content/custom-content.json b/apps/studio/hooks/custom-content/custom-content.json index 1378a1822a9..57bd6db86b7 100644 --- a/apps/studio/hooks/custom-content/custom-content.json +++ b/apps/studio/hooks/custom-content/custom-content.json @@ -7,5 +7,7 @@ "logs:default_query": null, - "connect:frameworks": null + "connect:frameworks": null, + + "infra:cloud_providers": ["AWS", "AWS_K8S", "FLY"] } diff --git a/apps/studio/hooks/custom-content/custom-content.sample.json b/apps/studio/hooks/custom-content/custom-content.sample.json index 7992201514d..da5a880f342 100644 --- a/apps/studio/hooks/custom-content/custom-content.sample.json +++ b/apps/studio/hooks/custom-content/custom-content.sample.json @@ -72,5 +72,7 @@ ] } ] - } + }, + + "infra:cloud_providers": ["AWS_NIMBUS"] } diff --git a/apps/studio/hooks/custom-content/custom-content.schema.json b/apps/studio/hooks/custom-content/custom-content.schema.json index 6d7020a3447..adea1a5f9fe 100644 --- a/apps/studio/hooks/custom-content/custom-content.schema.json +++ b/apps/studio/hooks/custom-content/custom-content.schema.json @@ -72,13 +72,23 @@ } } } + }, + + "infra:cloud_providers": { + "type": ["array"], + "description": "Filters the valid cloud providers", + "items": { + "type": "string", + "enum": ["AWS", "AWS_K8S", "AWS_NIMBUS", "FLY"] + } } }, "required": [ "organization:legal_documents", "project_homepage:example_projects", "logs:default_query", - "connect:frameworks" + "connect:frameworks", + "infra:cloud_providers" ], "additionalProperties": false } diff --git a/apps/studio/hooks/custom-content/useCustomContent.ts b/apps/studio/hooks/custom-content/useCustomContent.ts index 0d1b2342642..8f26fe1f397 100644 --- a/apps/studio/hooks/custom-content/useCustomContent.ts +++ b/apps/studio/hooks/custom-content/useCustomContent.ts @@ -28,16 +28,16 @@ const useCustomContent = ( contents: T ): { [key in CustomContentToCamelCase]: - | (typeof customContentStaticObj)[CustomContent] | CustomContentTypes[CustomContentToCamelCase] + | null } => { // [Joshen] Running into some TS errors without the `as` here - must be overlooking something super simple return Object.fromEntries( contents.map((content) => [contentToCamelCase(content), customContentStaticObj[content]]) ) as { [key in CustomContentToCamelCase]: - | (typeof customContentStaticObj)[CustomContent] | CustomContentTypes[CustomContentToCamelCase] + | null } } diff --git a/apps/studio/hooks/misc/useReportDateRange.ts b/apps/studio/hooks/misc/useReportDateRange.ts index af9502de124..bd1be26821c 100644 --- a/apps/studio/hooks/misc/useReportDateRange.ts +++ b/apps/studio/hooks/misc/useReportDateRange.ts @@ -9,6 +9,7 @@ import { } from 'components/interfaces/Reports/Reports.constants' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { maybeShowUpgradePrompt } from 'components/interfaces/Settings/Logs/Logs.utils' +import { AnalyticsInterval } from 'data/analytics/constants' export const DATERANGE_LIMITS: { [key: string]: number } = { free: 1, @@ -20,7 +21,7 @@ export const DATERANGE_LIMITS: { [key: string]: number } = { export interface ReportDateRange { period_start: { date: string; time_period: string } period_end: { date: string; time_period: string } - interval: string + interval: AnalyticsInterval } // Create parsers for individual URL parameters diff --git a/apps/studio/hooks/useChartData.ts b/apps/studio/hooks/useChartData.ts index 63998abc939..29f129b464a 100644 --- a/apps/studio/hooks/useChartData.ts +++ b/apps/studio/hooks/useChartData.ts @@ -15,7 +15,6 @@ import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants' import { useAuthLogsReport } from 'data/reports/auth-report-query' import type { ChartData } from 'components/ui/Charts/Charts.types' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import { useEdgeFunctionReport } from 'data/reports/edgefn-query' export const useChartData = ({ attributes, @@ -56,25 +55,9 @@ export const useChartData = ({ enabled: enabled && logsAttributes.length > 0 && !isEdgeFunctionRoute, }) - const { - data: edgeFunctionData, - attributes: edgeFunctionChartAttributes, - isLoading: isEdgeFunctionLoading, - } = useEdgeFunctionReport({ - projectRef: ref as string, - attributes: logsAttributes, - startDate, - endDate, - interval: interval as AnalyticsInterval, - enabled: enabled && logsAttributes.length > 0 && isEdgeFunctionRoute, - functionIds, - }) - - const logsData = isEdgeFunctionRoute ? edgeFunctionData : authData - const logsChartAttributes = isEdgeFunctionRoute - ? edgeFunctionChartAttributes - : authChartAttributes - const isLogsLoading = isEdgeFunctionRoute ? isEdgeFunctionLoading : isAuthLoading + const logsData = authData + const logsChartAttributes = authChartAttributes + const isLogsLoading = isAuthLoading const combinedData = useMemo(() => { if (data) return data diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index e1543a0e150..bafe2feee59 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -64,7 +64,7 @@ Sentry.init({ `Failed to construct 'URL': Invalid URL` ) // [Joshen] Similar behaviour for this error from SessionTimeoutModal to control the quota usage - const isSessionTimeoutEvent = (hint.originalException as any)?.message.includes( + const isSessionTimeoutEvent = (hint.originalException as any)?.message?.includes( 'Session error detected' ) diff --git a/apps/studio/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index 4d2d4ffbf66..d9343193b46 100644 --- a/apps/studio/lib/constants/infrastructure.ts +++ b/apps/studio/lib/constants/infrastructure.ts @@ -2,6 +2,7 @@ import type { CloudProvider } from 'shared-data' import { AWS_REGIONS, FLY_REGIONS } from 'shared-data' import type { components } from 'data/api' +import { useCustomContent } from 'hooks/custom-content/useCustomContent' export const AWS_REGIONS_DEFAULT = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' @@ -33,11 +34,21 @@ export const PRICING_TIER_PRODUCT_IDS = { ENTERPRISE: 'tier_enterprise', } -export const DEFAULT_PROVIDER: CloudProvider = - process.env.NEXT_PUBLIC_ENVIRONMENT && - ['staging', 'preview'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT) - ? 'AWS_K8S' - : 'AWS' +export function useDefaultProvider() { + const defaultProvider: CloudProvider = + process.env.NEXT_PUBLIC_ENVIRONMENT && + ['staging', 'preview'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT) + ? 'AWS_K8S' + : 'AWS' + + const { infraCloudProviders: validCloudProviders } = useCustomContent(['infra:cloud_providers']) + + if (validCloudProviders?.includes(defaultProvider)) { + return defaultProvider + } + + return (validCloudProviders?.[0] ?? 'AWS') as CloudProvider +} export const PROVIDERS = { FLY: { @@ -60,6 +71,12 @@ export const PROVIDERS = { default_region: AWS_REGIONS_DEFAULT, regions: { ...AWS_REGIONS }, }, + AWS_NIMBUS: { + id: 'AWS_NIMBUS', + name: 'AWS (Nimbus)', + default_region: AWS_REGIONS_DEFAULT, + regions: { ...AWS_REGIONS }, + }, } as const export const PROJECT_STATUS: { diff --git a/apps/studio/lib/eszip-parser.test.ts b/apps/studio/lib/eszip-parser.test.ts index 4577549e85c..ec38531a19d 100644 --- a/apps/studio/lib/eszip-parser.test.ts +++ b/apps/studio/lib/eszip-parser.test.ts @@ -62,6 +62,7 @@ describe('eszip-parser', () => { mockParser.parseBytes.mockResolvedValue(mockSpecifiers) mockParser.load.mockResolvedValue(undefined) mockParser.getModuleSource + .mockResolvedValueOnce(null) .mockResolvedValueOnce(mockModuleSource1) .mockResolvedValueOnce(mockModuleSource2) @@ -70,12 +71,13 @@ describe('eszip-parser', () => { expect(Parser.createInstance).toHaveBeenCalledTimes(1) expect(mockParser.parseBytes).toHaveBeenCalledWith(mockBytes) expect(mockParser.load).toHaveBeenCalled() - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ + expect(result.version).toEqual(0) + expect(result.files).toHaveLength(2) + expect(result.files[0]).toEqual({ name: 'file1.ts', content: mockModuleSource1, }) - expect(result[1]).toEqual({ + expect(result.files[1]).toEqual({ name: 'file2.ts', content: mockModuleSource2, }) @@ -111,9 +113,10 @@ describe('eszip-parser', () => { const result = await parseEszip(mockBytes) // Only file1.ts and file2.ts should be included - expect(result).toHaveLength(2) - expect(result[0].name).toBe('file1.ts') - expect(result[1].name).toBe('file2.ts') + expect(result.version).toEqual(0) + expect(result.files).toHaveLength(2) + expect(result.files[0].name).toBe('file1.ts') + expect(result.files[1].name).toBe('file2.ts') }) }) }) diff --git a/apps/studio/lib/eszip-parser.ts b/apps/studio/lib/eszip-parser.ts index bf84bc38c6a..7aa5428e747 100644 --- a/apps/studio/lib/eszip-parser.ts +++ b/apps/studio/lib/eszip-parser.ts @@ -63,8 +63,14 @@ export async function parseEszip(bytes: Uint8Array) { throw loadError } + // Extract version + let version = parseInt(await parser.getModuleSource('---SUPABASE-ESZIP-VERSION-ESZIP---')) + if (isNaN(version)) { + version = 0 + } + // Extract files from the eszip - const files = await extractEszip(parser, specifiers) + const files = await extractEszip(parser, specifiers, version >= 2) // Convert files to the expected format const responseFiles = await Promise.all( @@ -77,14 +83,17 @@ export async function parseEszip(bytes: Uint8Array) { }) ) - return responseFiles + return { + version, + files: responseFiles, + } } catch (error) { console.error('Error in parseEszip:', error) throw error } } -async function extractEszip(parser: any, specifiers: string[]) { +async function extractEszip(parser: any, specifiers: string[], isDeno2: boolean) { const files = [] // First, filter out the specifiers we want to keep @@ -114,9 +123,13 @@ async function extractEszip(parser: any, specifiers: string[]) { try { // Try to get the module source const moduleSource = await parser.getModuleSource(specifier) + let qualifiedSpecifier = specifier // Get the file path - const filePath = url2path(specifier) + if (isDeno2 && !specifier.startsWith('file://')) { + qualifiedSpecifier = `file://${specifier}` + } + const filePath = url2path(qualifiedSpecifier) // Create a file object const file = new File([moduleSource], filePath) diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 19e0af38490..34b2be5f2d1 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -30,12 +30,12 @@ import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import Head from 'next/head' import { NuqsAdapter } from 'nuqs/adapters/next/pages' -import { ErrorInfo } from 'react' +import { ErrorInfo, useCallback } from 'react' import { ErrorBoundary } from 'react-error-boundary' import { FeatureFlagProvider, - getFlags as getConfigCatFlags, + getFlags, TelemetryTagManager, ThemeProvider, useThemeSandbox, @@ -51,7 +51,7 @@ import { GlobalErrorBoundaryState } from 'components/ui/GlobalErrorBoundaryState import { useRootQueryClient } from 'data/query-client' import { customFont, sourceCodePro } from 'fonts' import { AuthProvider } from 'lib/auth' -import { API_URL, BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from 'lib/constants' import { ProfileProvider } from 'lib/profile' import { Telemetry } from 'lib/telemetry' import { AppPropsWithLayout } from 'types' @@ -100,6 +100,16 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { const isTestEnv = process.env.NEXT_PUBLIC_NODE_ENV === 'test' + const cloudProvider = useDefaultProvider() + const getConfigCatFlags = useCallback( + (userEmail?: string) => { + const customAttributes = cloudProvider ? { cloud_provider: cloudProvider } : undefined + + return getFlags(userEmail, customAttributes) + }, + [cloudProvider] + ) + return ( diff --git a/apps/studio/pages/api/edge-functions/body.ts b/apps/studio/pages/api/edge-functions/body.ts index 317bddc189f..ed220a07144 100644 --- a/apps/studio/pages/api/edge-functions/body.ts +++ b/apps/studio/pages/api/edge-functions/body.ts @@ -81,11 +81,9 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { const uint8Array = new Uint8Array(arrayBuffer) // Parse the eszip file using our utility - const files = await parseEszip(uint8Array) + const parsed = await parseEszip(uint8Array) - return res.status(200).json({ - files, - }) + return res.status(200).json(parsed) } catch (error) { console.error('Error processing edge function body:', error) return res.status(500).json({ error: 'Internal server error' }) diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 51a50a4b11d..52e49b7aa04 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -224,7 +224,7 @@ const CreateProject = () => {
{ const showAdvancedConfig = useIsFeatureEnabled('project_creation:show_advanced_config') + const { infraCloudProviders: validCloudProviders } = useCustomContent(['infra:cloud_providers']) + // This is to make the database.new redirect work correctly. The database.new redirect should be set to supabase.com/dashboard/new/last-visited-org if (slug === 'last-visited-org') { if (lastVisitedOrganization) { @@ -221,9 +224,11 @@ const Wizard: NextPageWithLayout = () => { project.organization_id === currentOrg?.id && project.status !== PROJECT_STATUS.INACTIVE ) ?? [] + const defaultProvider = useDefaultProvider() + const { data: _defaultRegion, error: defaultRegionError } = useDefaultRegionQuery( { - cloudProvider: PROVIDERS[DEFAULT_PROVIDER].id, + cloudProvider: PROVIDERS[defaultProvider].id, }, { enabled: !smartRegionEnabled, @@ -239,7 +244,7 @@ const Wizard: NextPageWithLayout = () => { useOrganizationAvailableRegionsQuery( { slug: slug, - cloudProvider: PROVIDERS[DEFAULT_PROVIDER].id, + cloudProvider: PROVIDERS[defaultProvider].id, }, { enabled: smartRegionEnabled, @@ -304,7 +309,7 @@ const Wizard: NextPageWithLayout = () => { organization: slug, projectName: projectName || '', postgresVersion: '', - cloudProvider: PROVIDERS[DEFAULT_PROVIDER].id, + cloudProvider: PROVIDERS[defaultProvider].id, dbPass: '', dbPassStrength: 0, dbRegion: defaultRegion || undefined, @@ -455,7 +460,7 @@ const Wizard: NextPageWithLayout = () => { useEffect(() => { if (regionError) { - form.setValue('dbRegion', PROVIDERS[DEFAULT_PROVIDER].default_region.displayName) + form.setValue('dbRegion', PROVIDERS[defaultProvider].default_region.displayName) } }, [regionError]) @@ -489,7 +494,7 @@ const Wizard: NextPageWithLayout = () => { canCreateProject && additionalMonthlySpend > 0 && (
- Additional Costs + Additional costs
${additionalMonthlySpend}/m @@ -707,15 +712,20 @@ const Wizard: NextPageWithLayout = () => { - {Object.values(PROVIDERS).map((providerObj) => { - const label = providerObj['name'] - const value = providerObj['id'] - return ( - - {label} - + {Object.values(PROVIDERS) + .filter( + (provider) => + validCloudProviders?.includes(provider.id) ?? true ) - })} + .map((providerObj) => { + const label = providerObj['name'] + const value = providerObj['id'] + return ( + + {label} + + ) + })} @@ -735,7 +745,7 @@ const Wizard: NextPageWithLayout = () => { layout="horizontal" label={
- Compute Size + Compute size
{ href="https://supabase.com/docs/guides/platform/compute-add-ons" >
-

Compute Add-Ons

+

Compute add-ons

@@ -754,7 +764,7 @@ const Wizard: NextPageWithLayout = () => { href="https://supabase.com/docs/guides/platform/manage-your-usage/compute" >
-

Compute Billing

+

Compute billing

@@ -845,7 +855,7 @@ const Wizard: NextPageWithLayout = () => { return ( @@ -926,7 +936,7 @@ const Wizard: NextPageWithLayout = () => { { const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug }) const { - data: functionFiles, + data: functionBody, isLoading: isLoadingFiles, isError: isErrorLoadingFiles, isSuccess: isSuccessLoadingFiles, @@ -123,15 +123,29 @@ const CodePage = () => { } } - function getBasePath(entrypoint: string | undefined): string { + function getBasePath( + entrypoint: string | undefined, + fileNames: string[], + version: number + ): string { if (!entrypoint) { return '/' } + let qualifiedEntrypoint = entrypoint + + if (version >= 2) { + const candidate = fileNames.find((name) => entrypoint.endsWith(name)) + if (candidate) { + qualifiedEntrypoint = `file://${candidate}` + } else { + qualifiedEntrypoint = entrypoint + } + } try { - return dirname(new URL(entrypoint).pathname) + return dirname(new URL(qualifiedEntrypoint).pathname) } catch (e) { - console.error('Failed to parse entrypoint', entrypoint) + console.error('Failed to parse entrypoint', qualifiedEntrypoint) return '/' } } @@ -155,9 +169,13 @@ const CodePage = () => { useEffect(() => { // Set files from API response when available - if (selectedFunction?.entrypoint_path && functionFiles) { - const base_path = getBasePath(selectedFunction?.entrypoint_path) - const filesWithRelPath = functionFiles + if (selectedFunction?.entrypoint_path && functionBody) { + const base_path = getBasePath( + selectedFunction?.entrypoint_path, + functionBody.files.map((file) => file.name), + functionBody.version + ) + const filesWithRelPath = functionBody.files // ignore empty files .filter((file: { name: string; content: string }) => !!file.content.length) // set file paths relative to entrypoint @@ -192,7 +210,7 @@ const CodePage = () => { }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [functionFiles]) + }, [functionBody]) return (
diff --git a/apps/studio/pages/project/[ref]/reports/auth.tsx b/apps/studio/pages/project/[ref]/reports/auth.tsx index 3c65f9ca8f2..34838356c35 100644 --- a/apps/studio/pages/project/[ref]/reports/auth.tsx +++ b/apps/studio/pages/project/[ref]/reports/auth.tsx @@ -4,7 +4,7 @@ import dayjs from 'dayjs' import { ArrowRight, RefreshCw } from 'lucide-react' import { useState } from 'react' -import ReportChart from 'components/interfaces/Reports/ReportChart' +import { ReportChart } from 'components/interfaces/Reports/ReportChart' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav' diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 48ab74b916f..959967b54aa 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' import { useFlag, useParams } from 'common' -import ReportChart from 'components/interfaces/Reports/ReportChart' +import { ReportChart } from 'components/interfaces/Reports/ReportChart' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants' @@ -22,7 +22,7 @@ import Table from 'components/to-be-cleaned/Table' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import ChartHandler from 'components/ui/Charts/ChartHandler' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' +import { LazyComposedChartHandler } from 'components/ui/Charts/ComposedChartHandler' import { ReportSettings } from 'components/ui/Charts/ReportSettings' import GrafanaPromoBanner from 'components/ui/GrafanaPromoBanner' import Panel from 'components/ui/Panel' @@ -269,7 +269,7 @@ const DatabaseUsage = () => { orgPlan?.id && (showChartsV2 ? REPORT_ATTRIBUTES_V2.filter((chart) => !chart.hide).map((chart) => ( - { )) : REPORT_ATTRIBUTES.filter((chart) => !chart.hide).map((chart, i) => chart.availableIn?.includes(orgPlan?.id) ? ( - { { +const EdgeFunctionsReportV2: NextPageWithLayout = () => { return ( @@ -32,20 +34,23 @@ const EdgeFunctionsReport: NextPageWithLayout = () => { ) } -EdgeFunctionsReport.getLayout = (page) => ( +EdgeFunctionsReportV2.getLayout = (page) => ( {page} ) -export type UpdateDateRange = (from: string, to: string) => void -export default EdgeFunctionsReport +export default EdgeFunctionsReportV2 const EdgeFunctionsUsage = () => { const { ref } = useParams() const { data: functions, isLoading: isLoadingFunctions } = useEdgeFunctionsQuery({ projectRef: ref, }) + + const chartSyncId = `edge-functions-${ref}` + useChartHoverState(chartSyncId) + const [isOpen, setIsOpen] = useState(false) const [functionIds, setFunctionIds] = useState([]) const [tempFunctionIds, setTempFunctionIds] = useState(functionIds) @@ -61,8 +66,6 @@ const EdgeFunctionsUsage = () => { updateDateRange, datePickerValue, datePickerHelpers, - isOrgPlanLoading, - orgPlan, showUpgradePrompt, setShowUpgradePrompt, handleDatePickerChange, @@ -71,21 +74,27 @@ const EdgeFunctionsUsage = () => { const queryClient = useQueryClient() const [isRefreshing, setIsRefreshing] = useState(false) - const EDGEFN_CHARTS = getEdgeFunctionReportAttributes() + const reportConfig = useMemo(() => { + return edgeFunctionReports({ + projectRef: ref!, + functions: functions ?? [], + startDate: selectedDateRange?.period_start?.date ?? '', + endDate: selectedDateRange?.period_end?.date ?? '', + interval: selectedDateRange?.interval ?? 'minute', + filters: { + functionIds, + }, + }) + }, [ref, functions, selectedDateRange, functionIds]) const onRefreshReport = async () => { if (!selectedDateRange) return setIsRefreshing(true) - queryClient.invalidateQueries(['edge-function-report', ref]) + queryClient.invalidateQueries(['report-v2']) setTimeout(() => setIsRefreshing(false), 1000) } - if (!ref) { - // Prevent rendering charts until the ref is available - return <> - } - return ( <> @@ -101,6 +110,8 @@ const EdgeFunctionsUsage = () => { tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} onClick={onRefreshReport} /> + + { >
{selectedDateRange && - EDGEFN_CHARTS.filter((attr) => !attr.hide).map((attr, i) => ( - - ))} + reportConfig + .filter((report) => !report.hide) + .map((report) => ( + + ))}
diff --git a/apps/studio/pages/project/[ref]/reports/realtime.tsx b/apps/studio/pages/project/[ref]/reports/realtime.tsx index 27b2c81abce..14ef2d580bd 100644 --- a/apps/studio/pages/project/[ref]/reports/realtime.tsx +++ b/apps/studio/pages/project/[ref]/reports/realtime.tsx @@ -19,7 +19,7 @@ import { TopApiRoutesRenderer, TotalRequestsChartRenderer, } from 'components/interfaces/Reports/renderers/ApiRenderers' -import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' +import { LazyComposedChartHandler } from 'components/ui/Charts/ComposedChartHandler' import ReportWidget from 'components/interfaces/Reports/ReportWidget' import ReportFilterBar from 'components/interfaces/Reports/ReportFilterBar' @@ -187,7 +187,7 @@ const RealtimeUsage = () => { > {selectedDateRange && REALTIME_REPORT_ATTRIBUTES.filter((chart) => !chart.hide).map((chart) => ( - item.status !== STORAGE_ROW_STATUS.EDITING ) + // [Joshen] JFYI storage does support folders of the same name with different casing + // but its an issue with the List V1 endpoint that's causing an issue with fetching contents + // for folders of the same name with different casing + // We should remove this check once all projects are on the List V2 endpoint const hasSameNameInColumn = currentColumnItems.filter((item) => item.name.toLowerCase() === name.toLowerCase()).length > 0 diff --git a/apps/studio/tests/features/logs/Logs.utils.test.ts b/apps/studio/tests/features/logs/Logs.utils.test.ts index b074f865b8a..ace442b6142 100644 --- a/apps/studio/tests/features/logs/Logs.utils.test.ts +++ b/apps/studio/tests/features/logs/Logs.utils.test.ts @@ -2,8 +2,235 @@ import { checkForILIKEClause, checkForWildcard, checkForWithClause, + fillTimeseries, + unixMicroToIsoTimestamp, } from 'components/interfaces/Settings/Logs/Logs.utils' import { describe, test, expect } from 'vitest' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' + +dayjs.extend(utc) + +describe('fillTimeseries', () => { + test('should return empty array for empty data without min/max', () => { + const result = fillTimeseries([], 'timestamp', 'value', 0) + expect(result).toEqual([]) + }) + + test('should return empty array for empty data with min/max', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T01:00:00.000Z' + const result = fillTimeseries([], 'timestamp', 'value', 0, min, max) + + // When min/max are provided, the function fills the time range with default values + // This creates 61 data points (one for each minute from 00:00 to 01:00) + expect(result).toHaveLength(61) + expect(result[0]).toEqual({ timestamp: '2023-01-01T00:00:00.000Z', value: 0 }) + expect(result[60]).toEqual({ timestamp: '2023-01-01T01:00:00.000Z', value: 0 }) + }) + + test('should normalize timestamps when data exceeds minPointsToFill', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', value: 1 }, + { timestamp: '2023-01-01T00:01:00.000Z', value: 2 }, + { timestamp: '2023-01-01T00:02:00.000Z', value: 3 }, + { timestamp: '2023-01-01T00:03:00.000Z', value: 4 }, + { timestamp: '2023-01-01T00:04:00.000Z', value: 5 }, + { timestamp: '2023-01-01T00:05:00.000Z', value: 6 }, + { timestamp: '2023-01-01T00:06:00.000Z', value: 7 }, + { timestamp: '2023-01-01T00:07:00.000Z', value: 8 }, + { timestamp: '2023-01-01T00:08:00.000Z', value: 9 }, + { timestamp: '2023-01-01T00:09:00.000Z', value: 10 }, + { timestamp: '2023-01-01T00:10:00.000Z', value: 11 }, + { timestamp: '2023-01-01T00:11:00.000Z', value: 12 }, + { timestamp: '2023-01-01T00:12:00.000Z', value: 13 }, + { timestamp: '2023-01-01T00:13:00.000Z', value: 14 }, + { timestamp: '2023-01-01T00:14:00.000Z', value: 15 }, + { timestamp: '2023-01-01T00:15:00.000Z', value: 16 }, + { timestamp: '2023-01-01T00:16:00.000Z', value: 17 }, + { timestamp: '2023-01-01T00:17:00.000Z', value: 18 }, + { timestamp: '2023-01-01T00:18:00.000Z', value: 19 }, + { timestamp: '2023-01-01T00:19:00.000Z', value: 20 }, + { timestamp: '2023-01-01T00:20:00.000Z', value: 21 }, + ] + const result = fillTimeseries(data, 'timestamp', 'value', 0, undefined, undefined, 20) + + // Should return normalized data without filling gaps + expect(result).toHaveLength(21) + result.forEach((item) => { + expect(item.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + }) + }) + + test('should fill gaps in sparse data with 1-minute intervals', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:04:00.000Z' + const data = [ + { timestamp: '2023-01-01T00:01:00.000Z', value: 10 }, + { timestamp: '2023-01-01T00:03:00.000Z', value: 30 }, + ] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, '2m') + + expect(result).toHaveLength(5) + const sortedResult = result.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + + expect(sortedResult[0]).toEqual({ timestamp: '2023-01-01T00:00:00.000Z', value: 0 }) + expect(sortedResult[1]).toEqual({ timestamp: '2023-01-01T00:01:00.000Z', value: 10 }) + expect(sortedResult[2]).toEqual({ timestamp: '2023-01-01T00:02:00.000Z', value: 0 }) + expect(sortedResult[3]).toEqual({ timestamp: '2023-01-01T00:03:00.000Z', value: 30 }) + expect(sortedResult[4]).toEqual({ timestamp: '2023-01-01T00:04:00.000Z', value: 0 }) + }) + + test('should handle multiple value keys', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:02:00.000Z' + const data = [{ timestamp: '2023-01-01T00:01:00.000Z', count1: 10, count2: 100 }] + + const result = fillTimeseries(data, 'timestamp', ['count1', 'count2'], 5, min, max, 20, '1m') + + expect(result).toHaveLength(3) + const sortedResult = result.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + + expect(sortedResult[0]).toEqual({ timestamp: '2023-01-01T00:00:00.000Z', count1: 5, count2: 5 }) + expect(sortedResult[1]).toEqual({ + timestamp: '2023-01-01T00:01:00.000Z', + count1: 10, + count2: 100, + }) + expect(sortedResult[2]).toEqual({ timestamp: '2023-01-01T00:02:00.000Z', count1: 5, count2: 5 }) + }) + + test('should handle different interval formats', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:10:00.000Z' + const data = [{ timestamp: '2023-01-01T00:05:00.000Z', value: 50 }] + + // Test 5-minute intervals: 00:00, 00:05, 00:10 = 3 points + const result5m = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, '5m') + expect(result5m).toHaveLength(3) + + // Test 2-minute intervals: 00:00, 00:02, 00:04, 00:05, 00:06, 00:08, 00:10 = 7 points + const result2m = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, '2m') + console.log( + '2m intervals:', + result2m.map((r) => r.timestamp) + ) + expect(result2m).toHaveLength(7) + + // Test 1-hour intervals: 00:00, 01:00, 02:00 with existing data at 00:05 = 4 points + const maxHour = '2023-01-01T02:00:00.000Z' + const result1h = fillTimeseries(data, 'timestamp', 'value', 0, min, maxHour, 20, '1h') + expect(result1h).toHaveLength(4) + }) + + test('should handle microsecond timestamps correctly', () => { + const now = dayjs.utc('2023-01-01T00:00:00.000Z') + const data = [ + { timestamp: now.valueOf() * 1000, value: 1 }, + { timestamp: now.add(1, 'minute').valueOf() * 1000, value: 2 }, + ] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, undefined, undefined, 1) + + expect(result).toHaveLength(2) + result.forEach((item) => { + expect(item.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + }) + }) + + test('should handle mixed timestamp formats', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', value: 1 }, + { timestamp: dayjs.utc('2023-01-01T00:01:00.000Z').valueOf() * 1000, value: 2 }, + { timestamp: '2023-01-01T00:02:00.000Z', value: 3 }, + ] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, undefined, undefined, 1) + + expect(result).toHaveLength(3) + result.forEach((item) => { + expect(item.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + }) + }) + + test('should not fill gaps when data is dense enough', () => { + const data = Array.from({ length: 25 }, (_, i) => ({ + timestamp: dayjs.utc('2023-01-01T00:00:00.000Z').add(i, 'minute').toISOString(), + value: i, + })) + + const result = fillTimeseries(data, 'timestamp', 'value', 0, undefined, undefined, 20) + + expect(result).toHaveLength(25) + expect(result).toEqual(data) + }) + + test('should handle edge case with single data point', () => { + const data = [{ timestamp: '2023-01-01T00:00:00.000Z', value: 1 }] + + const result = fillTimeseries(data, 'timestamp', 'value', 0) + + expect(result).toEqual(data) + }) + + test('should handle edge case with single data point and min/max', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:02:00.000Z' + const data = [{ timestamp: '2023-01-01T00:01:00.000Z', value: 1 }] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, '1m') + + expect(result).toHaveLength(3) + const sortedResult = result.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + + expect(sortedResult[0]).toEqual({ timestamp: '2023-01-01T00:00:00.000Z', value: 0 }) + expect(sortedResult[1]).toEqual({ timestamp: '2023-01-01T00:01:00.000Z', value: 1 }) + expect(sortedResult[2]).toEqual({ timestamp: '2023-01-01T00:02:00.000Z', value: 0 }) + }) + + test('should handle invalid interval format gracefully', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:02:00.000Z' + const data = [{ timestamp: '2023-01-01T00:01:00.000Z', value: 1 }] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, 'invalid') + + // Should fall back to default behavior + expect(result.length).toBeGreaterThan(1) + }) + + test('should preserve all properties of original data', () => { + const data = [ + { + timestamp: '2023-01-01T00:00:00.000Z', + value: 1, + extra: 'data', + nested: { prop: 'value' }, + }, + ] + + const result = fillTimeseries(data, 'timestamp', 'value', 0) + + expect(result[0]).toEqual(data[0]) + expect(result[0].extra).toBe('data') + expect(result[0].nested).toEqual({ prop: 'value' }) + }) + + test('should handle empty value keys array', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:01:00.000Z' + const data = [{ timestamp: '2023-01-01T00:00:30.000Z', value: 1 }] + + const result = fillTimeseries(data, 'timestamp', [], 0, min, max, 20, '30s') + + expect(result).toHaveLength(3) + result.forEach((item) => { + expect(item).toHaveProperty('timestamp') + expect(item).not.toHaveProperty('value') + }) + }) +}) describe('checkForWithClause', () => { test('basic queries', () => { diff --git a/apps/studio/tests/vitestSetup.ts b/apps/studio/tests/vitestSetup.ts index f309589b528..df9816a4bba 100644 --- a/apps/studio/tests/vitestSetup.ts +++ b/apps/studio/tests/vitestSetup.ts @@ -4,6 +4,14 @@ import { createDynamicRouteParser } from 'next-router-mock/dist/dynamic-routes' import { afterAll, afterEach, beforeAll, vi } from 'vitest' import { routerMock } from './lib/route-mock' import { mswServer } from './lib/msw' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import timezone from 'dayjs/plugin/timezone' +import relativeTime from 'dayjs/plugin/relativeTime' + +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(relativeTime) // Uncomment this if HTML in errors are being annoying. // @@ -38,7 +46,7 @@ beforeAll(() => { vi.mock('next/compat/router', () => require('next-router-mock')) // Mock the useParams hook from common module globally - vi.mock('common', async (importOriginal) => { + vi.mock('common', async (importOriginal: any) => { const actual = await importOriginal() return { ...(typeof actual === 'object' ? actual : {}), diff --git a/apps/ui-library/__registry__/index.tsx b/apps/ui-library/__registry__/index.tsx index fff2697c3a9..f20ed0206cd 100644 --- a/apps/ui-library/__registry__/index.tsx +++ b/apps/ui-library/__registry__/index.tsx @@ -1,527 +1,43 @@ // @ts-nocheck -// This file is autogenerated by scripts/build-registry.ts +// This file is autogenerated by scripts/build-registry.mts // Do not edit this file directly. import * as React from "react" -export const Index: Record = { +export const Index = { "default": { - "password-based-auth-nextjs": { - name: "password-based-auth-nextjs", - type: "registry:block", - registryDependencies: ["button","card","input","label"], - component: React.lazy(() => import("@/registry/default/blocks/password-based-auth-nextjs/app/auth/login/page.tsx")), - source: "", - files: ["registry/default/blocks/password-based-auth-nextjs/app/auth/login/page.tsx","registry/default/blocks/password-based-auth-nextjs/app/auth/error/page.tsx","registry/default/blocks/password-based-auth-nextjs/app/protected/page.tsx","registry/default/blocks/password-based-auth-nextjs/app/auth/confirm/route.ts","registry/default/blocks/password-based-auth-nextjs/components/login-form.tsx","registry/default/blocks/password-based-auth-nextjs/middleware.ts","registry/default/blocks/password-based-auth-nextjs/app/auth/sign-up/page.tsx","registry/default/blocks/password-based-auth-nextjs/app/auth/sign-up-success/page.tsx","registry/default/blocks/password-based-auth-nextjs/components/sign-up-form.tsx","registry/default/blocks/password-based-auth-nextjs/app/auth/forgot-password/page.tsx","registry/default/blocks/password-based-auth-nextjs/app/auth/update-password/page.tsx","registry/default/blocks/password-based-auth-nextjs/components/forgot-password-form.tsx","registry/default/blocks/password-based-auth-nextjs/components/update-password-form.tsx","registry/default/blocks/password-based-auth-nextjs/components/logout-button.tsx","registry/default/clients/nextjs/lib/supabase/client.ts","registry/default/clients/nextjs/lib/supabase/middleware.ts","registry/default/clients/nextjs/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "password-based-auth-react": { - name: "password-based-auth-react", - type: "registry:block", - registryDependencies: ["button","card","input","label"], - component: React.lazy(() => import("@/registry/default/blocks/password-based-auth-react/components/login-form.tsx")), - source: "", - files: ["registry/default/blocks/password-based-auth-react/components/login-form.tsx","registry/default/blocks/password-based-auth-react/components/sign-up-form.tsx","registry/default/blocks/password-based-auth-react/components/forgot-password-form.tsx","registry/default/blocks/password-based-auth-react/components/update-password-form.tsx","registry/default/clients/react/lib/supabase/client.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "password-based-auth-react-router": { - name: "password-based-auth-react-router", - type: "registry:block", - registryDependencies: ["button","card","input","label"], - component: React.lazy(() => import("@/registry/default/blocks/password-based-auth-react-router/app/routes/auth.confirm.tsx")), - source: "", - files: ["registry/default/blocks/password-based-auth-react-router/app/routes/auth.confirm.tsx","registry/default/blocks/password-based-auth-react-router/app/routes/auth.error.tsx","registry/default/blocks/password-based-auth-react-router/app/routes/forgot-password.tsx","registry/default/blocks/password-based-auth-react-router/app/routes/login.tsx","registry/default/blocks/password-based-auth-react-router/app/routes/logout.tsx","registry/default/blocks/password-based-auth-react-router/app/routes/protected.tsx","registry/default/blocks/password-based-auth-react-router/app/routes/sign-up.tsx","registry/default/blocks/password-based-auth-react-router/app/routes/update-password.tsx","registry/default/blocks/password-based-auth-react-router/app/routes.ts","registry/default/clients/react-router/lib/supabase/client.ts","registry/default/clients/react-router/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "password-based-auth-tanstack": { - name: "password-based-auth-tanstack", - type: "registry:block", - registryDependencies: ["button","card","input","label"], - component: React.lazy(() => import("@/registry/default/blocks/password-based-auth-tanstack/routes/login.tsx")), - source: "", - files: ["registry/default/blocks/password-based-auth-tanstack/routes/login.tsx","registry/default/blocks/password-based-auth-tanstack/routes/auth/error.tsx","registry/default/blocks/password-based-auth-tanstack/routes/_protected.tsx","registry/default/blocks/password-based-auth-tanstack/routes/_protected/protected.tsx","registry/default/blocks/password-based-auth-tanstack/routes/auth/confirm.ts","registry/default/blocks/password-based-auth-tanstack/components/login-form.tsx","registry/default/blocks/password-based-auth-tanstack/routes/sign-up.tsx","registry/default/blocks/password-based-auth-tanstack/routes/sign-up-success.tsx","registry/default/blocks/password-based-auth-tanstack/components/sign-up-form.tsx","registry/default/blocks/password-based-auth-tanstack/routes/forgot-password.tsx","registry/default/blocks/password-based-auth-tanstack/routes/update-password.tsx","registry/default/blocks/password-based-auth-tanstack/components/forgot-password-form.tsx","registry/default/blocks/password-based-auth-tanstack/components/update-password-form.tsx","registry/default/blocks/password-based-auth-tanstack/lib/supabase/fetch-user-server-fn.ts","registry/default/clients/tanstack/lib/supabase/client.ts","registry/default/clients/tanstack/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "social-auth-nextjs": { - name: "social-auth-nextjs", - type: "registry:block", - registryDependencies: ["button","card"], - component: React.lazy(() => import("@/registry/default/blocks/social-auth-nextjs/app/auth/login/page.tsx")), - source: "", - files: ["registry/default/blocks/social-auth-nextjs/app/auth/login/page.tsx","registry/default/blocks/social-auth-nextjs/app/auth/error/page.tsx","registry/default/blocks/social-auth-nextjs/app/protected/page.tsx","registry/default/blocks/social-auth-nextjs/app/auth/oauth/route.ts","registry/default/blocks/social-auth-nextjs/components/login-form.tsx","registry/default/blocks/social-auth-nextjs/middleware.ts","registry/default/blocks/social-auth-nextjs/components/logout-button.tsx","registry/default/clients/nextjs/lib/supabase/client.ts","registry/default/clients/nextjs/lib/supabase/middleware.ts","registry/default/clients/nextjs/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "social-auth-react": { - name: "social-auth-react", - type: "registry:block", - registryDependencies: ["button","card"], - component: React.lazy(() => import("@/registry/default/blocks/social-auth-react/components/login-form.tsx")), - source: "", - files: ["registry/default/blocks/social-auth-react/components/login-form.tsx","registry/default/clients/react/lib/supabase/client.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "social-auth-react-router": { - name: "social-auth-react-router", - type: "registry:block", - registryDependencies: ["button","card"], - component: React.lazy(() => import("@/registry/default/blocks/social-auth-react-router/app/routes/auth.error.tsx")), - source: "", - files: ["registry/default/blocks/social-auth-react-router/app/routes/auth.error.tsx","registry/default/blocks/social-auth-react-router/app/routes/auth.oauth.tsx","registry/default/blocks/social-auth-react-router/app/routes/login.tsx","registry/default/blocks/social-auth-react-router/app/routes/logout.tsx","registry/default/blocks/social-auth-react-router/app/routes/protected.tsx","registry/default/blocks/social-auth-react-router/app/routes.ts","registry/default/clients/react-router/lib/supabase/client.ts","registry/default/clients/react-router/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "social-auth-tanstack": { - name: "social-auth-tanstack", - type: "registry:block", - registryDependencies: ["button","card"], - component: React.lazy(() => import("@/registry/default/blocks/social-auth-tanstack/components/login-form.tsx")), - source: "", - files: ["registry/default/blocks/social-auth-tanstack/components/login-form.tsx","registry/default/blocks/social-auth-tanstack/lib/supabase/fetch-user-server-fn.ts","registry/default/blocks/social-auth-tanstack/routes/_protected.tsx","registry/default/blocks/social-auth-tanstack/routes/_protected/protected.tsx","registry/default/blocks/social-auth-tanstack/routes/auth/error.tsx","registry/default/blocks/social-auth-tanstack/routes/auth/oauth.ts","registry/default/blocks/social-auth-tanstack/routes/login.tsx","registry/default/clients/tanstack/lib/supabase/client.ts","registry/default/clients/tanstack/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "dropzone-nextjs": { - name: "dropzone-nextjs", - type: "registry:component", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/blocks/dropzone/components/dropzone.tsx")), - source: "", - files: ["registry/default/blocks/dropzone/components/dropzone.tsx","registry/default/blocks/dropzone/hooks/use-supabase-upload.ts","registry/default/clients/nextjs/lib/supabase/client.ts","registry/default/clients/nextjs/lib/supabase/middleware.ts","registry/default/clients/nextjs/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "dropzone-react": { - name: "dropzone-react", - type: "registry:component", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/blocks/dropzone/components/dropzone.tsx")), - source: "", - files: ["registry/default/blocks/dropzone/components/dropzone.tsx","registry/default/blocks/dropzone/hooks/use-supabase-upload.ts","registry/default/clients/react/lib/supabase/client.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "dropzone-react-router": { - name: "dropzone-react-router", - type: "registry:component", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/blocks/dropzone/components/dropzone.tsx")), - source: "", - files: ["registry/default/blocks/dropzone/components/dropzone.tsx","registry/default/blocks/dropzone/hooks/use-supabase-upload.ts","registry/default/clients/react-router/lib/supabase/client.ts","registry/default/clients/react-router/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "dropzone-tanstack": { - name: "dropzone-tanstack", - type: "registry:component", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/blocks/dropzone/components/dropzone.tsx")), - source: "", - files: ["registry/default/blocks/dropzone/components/dropzone.tsx","registry/default/blocks/dropzone/hooks/use-supabase-upload.ts","registry/default/clients/tanstack/lib/supabase/client.ts","registry/default/clients/tanstack/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-cursor-nextjs": { - name: "realtime-cursor-nextjs", - type: "registry:component", - registryDependencies: [], - component: React.lazy(() => import("@/registry/default/blocks/realtime-cursor/components/cursor.tsx")), - source: "", - files: ["registry/default/blocks/realtime-cursor/components/cursor.tsx","registry/default/blocks/realtime-cursor/components/realtime-cursors.tsx","registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts","registry/default/clients/nextjs/lib/supabase/client.ts","registry/default/clients/nextjs/lib/supabase/middleware.ts","registry/default/clients/nextjs/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-cursor-react": { - name: "realtime-cursor-react", - type: "registry:component", - registryDependencies: [], - component: React.lazy(() => import("@/registry/default/blocks/realtime-cursor/components/cursor.tsx")), - source: "", - files: ["registry/default/blocks/realtime-cursor/components/cursor.tsx","registry/default/blocks/realtime-cursor/components/realtime-cursors.tsx","registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts","registry/default/clients/react/lib/supabase/client.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-cursor-react-router": { - name: "realtime-cursor-react-router", - type: "registry:component", - registryDependencies: [], - component: React.lazy(() => import("@/registry/default/blocks/realtime-cursor/components/cursor.tsx")), - source: "", - files: ["registry/default/blocks/realtime-cursor/components/cursor.tsx","registry/default/blocks/realtime-cursor/components/realtime-cursors.tsx","registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts","registry/default/clients/react-router/lib/supabase/client.ts","registry/default/clients/react-router/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-cursor-tanstack": { - name: "realtime-cursor-tanstack", - type: "registry:component", - registryDependencies: [], - component: React.lazy(() => import("@/registry/default/blocks/realtime-cursor/components/cursor.tsx")), - source: "", - files: ["registry/default/blocks/realtime-cursor/components/cursor.tsx","registry/default/blocks/realtime-cursor/components/realtime-cursors.tsx","registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts","registry/default/clients/tanstack/lib/supabase/client.ts","registry/default/clients/tanstack/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "current-user-avatar-nextjs": { - name: "current-user-avatar-nextjs", - type: "registry:component", - registryDependencies: ["avatar"], - component: React.lazy(() => import("@/registry/default/blocks/current-user-avatar/components/current-user-avatar.tsx")), - source: "", - files: ["registry/default/blocks/current-user-avatar/components/current-user-avatar.tsx","registry/default/blocks/current-user-avatar/hooks/use-current-user-name.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-image.ts","registry/default/clients/nextjs/lib/supabase/client.ts","registry/default/clients/nextjs/lib/supabase/middleware.ts","registry/default/clients/nextjs/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "current-user-avatar-react": { - name: "current-user-avatar-react", - type: "registry:component", - registryDependencies: ["avatar"], - component: React.lazy(() => import("@/registry/default/blocks/current-user-avatar/components/current-user-avatar.tsx")), - source: "", - files: ["registry/default/blocks/current-user-avatar/components/current-user-avatar.tsx","registry/default/blocks/current-user-avatar/hooks/use-current-user-name.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-image.ts","registry/default/clients/react/lib/supabase/client.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "current-user-avatar-react-router": { - name: "current-user-avatar-react-router", - type: "registry:component", - registryDependencies: ["avatar"], - component: React.lazy(() => import("@/registry/default/blocks/current-user-avatar/components/current-user-avatar.tsx")), - source: "", - files: ["registry/default/blocks/current-user-avatar/components/current-user-avatar.tsx","registry/default/blocks/current-user-avatar/hooks/use-current-user-name.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-image.ts","registry/default/clients/react-router/lib/supabase/client.ts","registry/default/clients/react-router/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "current-user-avatar-tanstack": { - name: "current-user-avatar-tanstack", - type: "registry:component", - registryDependencies: ["avatar"], - component: React.lazy(() => import("@/registry/default/blocks/current-user-avatar/components/current-user-avatar.tsx")), - source: "", - files: ["registry/default/blocks/current-user-avatar/components/current-user-avatar.tsx","registry/default/blocks/current-user-avatar/hooks/use-current-user-name.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-image.ts","registry/default/clients/tanstack/lib/supabase/client.ts","registry/default/clients/tanstack/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-avatar-stack-nextjs": { - name: "realtime-avatar-stack-nextjs", - type: "registry:component", - registryDependencies: ["avatar","tooltip"], - component: React.lazy(() => import("@/registry/default/blocks/realtime-avatar-stack/components/avatar-stack.tsx")), - source: "", - files: ["registry/default/blocks/realtime-avatar-stack/components/avatar-stack.tsx","registry/default/blocks/realtime-avatar-stack/components/realtime-avatar-stack.tsx","registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-name.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-image.ts","registry/default/clients/nextjs/lib/supabase/client.ts","registry/default/clients/nextjs/lib/supabase/middleware.ts","registry/default/clients/nextjs/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-avatar-stack-react": { - name: "realtime-avatar-stack-react", - type: "registry:component", - registryDependencies: ["avatar","tooltip"], - component: React.lazy(() => import("@/registry/default/blocks/realtime-avatar-stack/components/avatar-stack.tsx")), - source: "", - files: ["registry/default/blocks/realtime-avatar-stack/components/avatar-stack.tsx","registry/default/blocks/realtime-avatar-stack/components/realtime-avatar-stack.tsx","registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-name.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-image.ts","registry/default/clients/react/lib/supabase/client.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-avatar-stack-react-router": { - name: "realtime-avatar-stack-react-router", - type: "registry:component", - registryDependencies: ["avatar","tooltip"], - component: React.lazy(() => import("@/registry/default/blocks/realtime-avatar-stack/components/avatar-stack.tsx")), - source: "", - files: ["registry/default/blocks/realtime-avatar-stack/components/avatar-stack.tsx","registry/default/blocks/realtime-avatar-stack/components/realtime-avatar-stack.tsx","registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-name.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-image.ts","registry/default/clients/react-router/lib/supabase/client.ts","registry/default/clients/react-router/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-avatar-stack-tanstack": { - name: "realtime-avatar-stack-tanstack", - type: "registry:component", - registryDependencies: ["avatar","tooltip"], - component: React.lazy(() => import("@/registry/default/blocks/realtime-avatar-stack/components/avatar-stack.tsx")), - source: "", - files: ["registry/default/blocks/realtime-avatar-stack/components/avatar-stack.tsx","registry/default/blocks/realtime-avatar-stack/components/realtime-avatar-stack.tsx","registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-name.ts","registry/default/blocks/current-user-avatar/hooks/use-current-user-image.ts","registry/default/clients/tanstack/lib/supabase/client.ts","registry/default/clients/tanstack/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-chat-nextjs": { - name: "realtime-chat-nextjs", - type: "registry:component", - registryDependencies: ["input","button"], - component: React.lazy(() => import("@/registry/default/blocks/realtime-chat/components/chat-message.tsx")), - source: "", - files: ["registry/default/blocks/realtime-chat/components/chat-message.tsx","registry/default/blocks/realtime-chat/components/realtime-chat.tsx","registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx","registry/default/blocks/realtime-chat/hooks/use-chat-scroll.tsx","registry/default/clients/nextjs/lib/supabase/client.ts","registry/default/clients/nextjs/lib/supabase/middleware.ts","registry/default/clients/nextjs/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-chat-react": { - name: "realtime-chat-react", - type: "registry:component", - registryDependencies: ["input","button"], - component: React.lazy(() => import("@/registry/default/blocks/realtime-chat/components/chat-message.tsx")), - source: "", - files: ["registry/default/blocks/realtime-chat/components/chat-message.tsx","registry/default/blocks/realtime-chat/components/realtime-chat.tsx","registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx","registry/default/blocks/realtime-chat/hooks/use-chat-scroll.tsx","registry/default/clients/react/lib/supabase/client.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-chat-react-router": { - name: "realtime-chat-react-router", - type: "registry:component", - registryDependencies: ["input","button"], - component: React.lazy(() => import("@/registry/default/blocks/realtime-chat/components/chat-message.tsx")), - source: "", - files: ["registry/default/blocks/realtime-chat/components/chat-message.tsx","registry/default/blocks/realtime-chat/components/realtime-chat.tsx","registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx","registry/default/blocks/realtime-chat/hooks/use-chat-scroll.tsx","registry/default/clients/react-router/lib/supabase/client.ts","registry/default/clients/react-router/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "realtime-chat-tanstack": { - name: "realtime-chat-tanstack", - type: "registry:component", - registryDependencies: ["input","button"], - component: React.lazy(() => import("@/registry/default/blocks/realtime-chat/components/chat-message.tsx")), - source: "", - files: ["registry/default/blocks/realtime-chat/components/chat-message.tsx","registry/default/blocks/realtime-chat/components/realtime-chat.tsx","registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx","registry/default/blocks/realtime-chat/hooks/use-chat-scroll.tsx","registry/default/clients/tanstack/lib/supabase/client.ts","registry/default/clients/tanstack/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "infinite-query-hook": { - name: "infinite-query-hook", - type: "registry:block", - registryDependencies: [], - - source: "", - files: ["registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "supabase-client-nextjs": { - name: "supabase-client-nextjs", - type: "registry:lib", - registryDependencies: [], - - source: "", - files: ["registry/default/clients/nextjs/lib/supabase/client.ts","registry/default/clients/nextjs/lib/supabase/middleware.ts","registry/default/clients/nextjs/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "supabase-client-react": { - name: "supabase-client-react", - type: "registry:lib", - registryDependencies: [], - - source: "", - files: ["registry/default/clients/react/lib/supabase/client.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "supabase-client-react-router": { - name: "supabase-client-react-router", - type: "registry:lib", - registryDependencies: [], - - source: "", - files: ["registry/default/clients/react-router/lib/supabase/client.ts","registry/default/clients/react-router/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "supabase-client-tanstack": { - name: "supabase-client-tanstack", - type: "registry:lib", - registryDependencies: [], - - source: "", - files: ["registry/default/clients/tanstack/lib/supabase/client.ts","registry/default/clients/tanstack/lib/supabase/server.ts"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "platform-kit-nextjs": { - name: "platform-kit-nextjs", - type: "registry:block", - registryDependencies: ["alert","badge","button","card","chart","command","dialog","drawer","form","hover-card","input","label","popover","select","skeleton","switch","table","toggle","toggle-group","tooltip"], - component: React.lazy(() => import("@/registry/default/platform/platform-kit-nextjs/components/dynamic-form.tsx")), - source: "", - files: ["registry/default/platform/platform-kit-nextjs/app/api/ai/sql/route.ts","registry/default/platform/platform-kit-nextjs/app/api/supabase-proxy/[...path]/route.ts","registry/default/platform/platform-kit-nextjs/components/dynamic-form.tsx","registry/default/platform/platform-kit-nextjs/components/logo-supabase.tsx","registry/default/platform/platform-kit-nextjs/components/results-table.tsx","registry/default/platform/platform-kit-nextjs/components/sql-editor.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/auth.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/database.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/index.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/logs.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/secrets.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/storage.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/suggestions.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/users.tsx","registry/default/platform/platform-kit-nextjs/components/users-growth-chart.tsx","registry/default/platform/platform-kit-nextjs/hooks/use-auth.ts","registry/default/platform/platform-kit-nextjs/hooks/use-logs.ts","registry/default/platform/platform-kit-nextjs/hooks/use-run-query.ts","registry/default/platform/platform-kit-nextjs/hooks/use-secrets.ts","registry/default/platform/platform-kit-nextjs/hooks/use-storage.ts","registry/default/platform/platform-kit-nextjs/hooks/use-suggestions.ts","registry/default/platform/platform-kit-nextjs/hooks/use-tables.ts","registry/default/platform/platform-kit-nextjs/hooks/use-user-counts.ts","registry/default/platform/platform-kit-nextjs/lib/logs.ts","registry/default/platform/platform-kit-nextjs/lib/management-api-schema.d.ts","registry/default/platform/platform-kit-nextjs/lib/management-api.ts","registry/default/platform/platform-kit-nextjs/lib/pg-meta/sql.ts","registry/default/platform/platform-kit-nextjs/lib/pg-meta/index.ts","registry/default/platform/platform-kit-nextjs/lib/pg-meta/types.ts","registry/default/platform/platform-kit-nextjs/lib/schemas/auth.ts","registry/default/platform/platform-kit-nextjs/lib/schemas/secrets.ts","registry/default/platform/platform-kit-nextjs/contexts/SheetNavigationContext.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , - "ai-editor-rules": { - name: "ai-editor-rules", - type: "registry:file", - registryDependencies: [], - - source: "", - files: ["registry/default/ai-editor-rules/create-db-functions.mdc","registry/default/ai-editor-rules/create-migration.mdc","registry/default/ai-editor-rules/create-rls-policies.mdc","registry/default/ai-editor-rules/postgres-sql-style-guide.mdc","registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc","registry/default/ai-editor-rules/use-realtime.mdc"], - category: "undefined", - subcategory: "undefined", - chunks: [] - } - , "dropzone-demo": { - name: "dropzone-demo", - type: "registry:example", - registryDependencies: [], component: React.lazy(() => import("@/registry/default/examples/dropzone-demo.tsx")), - source: "", - files: ["registry/default/examples/dropzone-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] } , "realtime-cursor-demo": { - name: "realtime-cursor-demo", - type: "registry:example", - registryDependencies: [], component: React.lazy(() => import("@/registry/default/examples/realtime-cursor-demo.tsx")), - source: "", - files: ["registry/default/examples/realtime-cursor-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] } , "password-based-auth-demo": { - name: "password-based-auth-demo", - type: "registry:example", - registryDependencies: [], component: React.lazy(() => import("@/registry/default/examples/password-based-auth.tsx")), - source: "", - files: ["registry/default/examples/password-based-auth.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] } , "current-user-avatar-demo": { - name: "current-user-avatar-demo", - type: "registry:example", - registryDependencies: [], component: React.lazy(() => import("@/registry/default/examples/current-user-avatar-demo.tsx")), - source: "", - files: ["registry/default/examples/current-user-avatar-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] } , "current-user-avatar-preview": { - name: "current-user-avatar-preview", - type: "registry:example", - registryDependencies: [], component: React.lazy(() => import("@/registry/default/examples/current-user-avatar-preview.tsx")), - source: "", - files: ["registry/default/examples/current-user-avatar-preview.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] } , "realtime-avatar-stack-demo": { - name: "realtime-avatar-stack-demo", - type: "registry:example", - registryDependencies: [], component: React.lazy(() => import("@/registry/default/examples/realtime-avatar-stack-demo.tsx")), - source: "", - files: ["registry/default/examples/realtime-avatar-stack-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] } , "realtime-avatar-stack-preview": { - name: "realtime-avatar-stack-preview", - type: "registry:example", - registryDependencies: [], component: React.lazy(() => import("@/registry/default/examples/realtime-avatar-stack-preview.tsx")), - source: "", - files: ["registry/default/examples/realtime-avatar-stack-preview.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] } , "infinite-query-hook-demo": { - name: "infinite-query-hook-demo", - type: "registry:example", - registryDependencies: [], component: React.lazy(() => import("@/registry/default/examples/infinite-query-hook-demo.tsx")), - source: "", - files: ["registry/default/examples/infinite-query-hook-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] } }, -} +} as const diff --git a/apps/ui-library/components/block-item.tsx b/apps/ui-library/components/block-item.tsx index beb5e2dbcbc..e4618d1c926 100644 --- a/apps/ui-library/components/block-item.tsx +++ b/apps/ui-library/components/block-item.tsx @@ -19,11 +19,3 @@ export const BlockItem = ({ name }: BlockItemProps) => {
) } - -export const BlockItemPreview = ({ title, src }: { title: string; src: string }) => { - return ( -
-