mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
Merge branch 'master' into feature/model-providers
This commit is contained in:
commit
bfe3433e67
3223 files changed
+6263
-71431
No files matched your search
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -17,7 +17,7 @@ export default function Component() {
|
||||
fill="none"
|
||||
className="absolute left-[5px] top-[50px] z-10"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<g clipPath="url(#a)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M173.928 21.13C115.811 44.938 58.751 45.773 0 26.141c4.227-4.386 7.82-2.715 10.567-1.88 21.133 5.64 42.9 6.266 64.457 7.101 31.066 1.253 60.441-5.848 89.183-17.335 1.268-.418 2.325-1.253 4.861-2.924-14.582-2.924-29.165 2.089-41.845-3.76.212-.835.212-1.879.423-2.714 9.51-.627 19.231-1.253 28.742-2.089 9.51-.835 18.808-1.88 28.318-2.506 6.974-.418 9.933 2.924 7.397 9.19-3.17 8.145-7.608 15.664-11.623 23.391-.423.836-1.057 1.88-1.902 2.298-2.325.835-4.65 1.044-7.186 1.67-.422-2.088-1.479-4.386-1.268-6.265.423-2.506 1.902-4.595 3.804-9.19Z"
|
||||
@@ -48,7 +48,7 @@ export default function Component() {
|
||||
viewBox="0 0 122 148"
|
||||
className="absolute left-[85px] top-[10px] z-10 -scale-x-100"
|
||||
>
|
||||
<g clip-path="url(#ab)">
|
||||
<g clipPath="url(#ab)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M0 2.65c6.15-4.024 12.299-2.753 17.812-.847a115.56 115.56 0 0 1 21.84 10.59C70.4 32.727 88.849 61.744 96.483 97.54c1.908 9.108 2.544 18.639 3.817 29.017 8.481-4.871 12.934-14.402 21.416-19.909 1.061 4.236-1.06 6.989-2.756 9.319-6.998 9.531-14.207 19.062-21.63 28.382-3.604 4.448-6.36 4.871-10.177 1.059-8.058-7.837-12.935-17.368-14.42-28.382 0-.424.636-1.059 1.485-2.118 9.118 2.33 6.997 13.979 14.843 18.215 3.393-14.614.848-28.593-2.969-42.149-4.029-14.19-9.33-27.746-17.812-39.82-8.27-11.86-18.66-21.392-30.11-30.287C26.93 11.758 14.207 6.039 0 2.65Z"
|
||||
@@ -96,7 +96,7 @@ export default function Component() {
|
||||
viewBox="0 0 75 175"
|
||||
className="absolute left-[30px] top-[38px] z-10 rotate-[-40deg]"
|
||||
>
|
||||
<g clip-path="url(#abc)">
|
||||
<g clipPath="url(#abc)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20.187 175c-4.439-2.109-7.186-2.531-8.032-4.008-3.17-5.484-6.763-10.968-8.454-17.084-5.073-16.242-4.439-32.694-1.057-49.146 5.707-28.053 18.388-52.942 34.24-76.565 1.692-2.531 3.171-5.063 4.862-7.805 0-.21-.211-.632-.634-1.265-4.65 1.265-9.511 2.53-14.161 3.585-2.537.422-5.496.422-8.032-.421-1.48-.422-3.593-2.742-3.593-4.219 0-1.898 1.48-4.218 2.747-5.906 1.057-1.054 2.96-1.265 4.65-1.687C35.406 7.315 48.088 3.729 60.98.776c10.99-2.53 14.584 1.055 13.95 11.812-.634 11.18-.846 22.358-1.268 33.326-.212 3.375-.846 6.96-1.268 10.757-8.878-4.007-8.878-4.007-12.048-38.177C47.03 33.259 38.153 49.289 29.91 65.741 21.667 82.193 16.17 99.49 13.212 117.84c-2.959 18.984.634 36.912 6.975 57.161Z"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Minus, Plus } from 'lucide-react'
|
||||
import { Bar, BarChart, ResponsiveContainer } from 'recharts'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -104,11 +104,12 @@ export default function NavigationMenuDemo() {
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/docs" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={buttonVariants({ type: 'text', size: 'small' })}>
|
||||
Documentation
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
<NavigationMenuLink
|
||||
href="/docs"
|
||||
className={buttonVariants({ type: 'text', size: 'small' })}
|
||||
>
|
||||
Documentation
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
|
||||
@@ -144,11 +144,12 @@ export default function NavigationMenuDemo() {
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/docs" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={buttonVariants({ type: 'text', size: 'small' })}>
|
||||
Documentation
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
<NavigationMenuLink
|
||||
href="/docs"
|
||||
className={buttonVariants({ type: 'text', size: 'small' })}
|
||||
>
|
||||
Documentation
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
|
||||
@@ -8,15 +8,12 @@ const tags = Array.from({ length: 50 }).map((_, i, a) => `v1.2.0-beta.${a.length
|
||||
export default function ScrollAreaDemo() {
|
||||
return (
|
||||
<ScrollArea className="h-72 w-48 rounded-md border">
|
||||
<div className="p-4">
|
||||
<div className="p-4 divide-y divide-border">
|
||||
<h4 className="mb-4 text-sm font-medium leading-none">Tags</h4>
|
||||
{tags.map((tag) => (
|
||||
<>
|
||||
<div key={tag} className="text-sm">
|
||||
{tag}
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
<div key={tag} className="text-sm py-2">
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -28,13 +28,13 @@ export default function SheetDemo() {
|
||||
<Label_Shadcn_ htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label_Shadcn_>
|
||||
<Input_Shadcn_ id="name" value="Pedro Duarte" className="col-span-3" />
|
||||
<Input_Shadcn_ id="name" value="Pedro Duarte" className="col-span-3" readOnly />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label_Shadcn_ htmlFor="username" className="text-right">
|
||||
Username
|
||||
</Label_Shadcn_>
|
||||
<Input_Shadcn_ id="username" value="@peduarte" className="col-span-3" />
|
||||
<Input_Shadcn_ id="username" value="@peduarte" className="col-span-3" readOnly />
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
|
||||
@@ -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')
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -107,7 +107,7 @@ const BreadcrumbsInternal = ({
|
||||
<DrawerTrigger aria-label="Toggle Menu">
|
||||
<BreadcrumbEllipsis className="h-4 w-4" />
|
||||
</DrawerTrigger>
|
||||
<DrawerContent showHandle={false}>
|
||||
<DrawerContent>
|
||||
<div className="grid gap-1 px-4">
|
||||
{breadcrumbs.slice(1, -2).map((crumb, index) =>
|
||||
crumb.url ? (
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -121,6 +121,55 @@ Future<void> signInWithFacebook() async {
|
||||
}
|
||||
```
|
||||
|
||||
### Alternative: Using Facebook SDK with signInWithIdToken
|
||||
|
||||
For more control over the Facebook authentication flow, you can use the Facebook SDK directly and then authenticate with Supabase using [`signInWithIdToken()`](/docs/reference/dart/auth-signinwithidtoken):
|
||||
|
||||
First, add the Facebook SDK dependency to your `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter_facebook_auth: ^7.0.1
|
||||
```
|
||||
|
||||
Then implement the Facebook authentication:
|
||||
|
||||
```dart
|
||||
import 'package:flutter_facebook_auth/flutter_facebook_auth.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
Future<void> signInWithFacebook() async {
|
||||
try {
|
||||
final LoginResult result = await FacebookAuth.instance.login(
|
||||
permissions: ['public_profile', 'email'],
|
||||
);
|
||||
|
||||
if (result.status == LoginStatus.success) {
|
||||
final accessToken = result.accessToken!.tokenString;
|
||||
|
||||
await Supabase.instance.client.auth.signInWithIdToken(
|
||||
provider: OAuthProvider.facebook,
|
||||
idToken: accessToken,
|
||||
);
|
||||
|
||||
// Authentication successful
|
||||
} else {
|
||||
// Handle login cancellation or failure
|
||||
throw Exception('Facebook login failed: ${result.status}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle errors
|
||||
throw Exception('Facebook authentication error: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Make sure to configure your Facebook app properly and add the required permissions in the Facebook Developer Console. The `signInWithIdToken` method requires the Facebook access token to be valid and properly scoped.
|
||||
|
||||
</Admonition>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Admonition type="note" label="Read Replicas and Custom Config">
|
||||
|
||||
|
||||
@@ -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.
|
||||
<Admonition type="note" label="Beta Version">
|
||||
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -558,12 +558,12 @@ functions:
|
||||
- id: sign-in-with-id-token
|
||||
title: 'signInWithIdToken()'
|
||||
description: |
|
||||
Allows you to perform native Google and Apple sign in by combining it with [google_sign_in](https://pub.dev/packages/google_sign_in) or [sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) packages.
|
||||
Allows you to perform native Google, Apple, and Facebook sign in by combining it with [google_sign_in](https://pub.dev/packages/google_sign_in), [sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple), or [flutter_facebook_auth](https://pub.dev/packages/flutter_facebook_auth) packages.
|
||||
params:
|
||||
- name: provider
|
||||
isOptional: false
|
||||
type: OAuthProvider
|
||||
description: The provider to perform the sign in with. Currently, `OAuthProvider.google` and `OAuthProvider.apple` are supported.
|
||||
description: The provider to perform the sign in with.
|
||||
- name: idToken
|
||||
isOptional: false
|
||||
type: String
|
||||
@@ -719,6 +719,36 @@ functions:
|
||||
nonce: rawNonce,
|
||||
);
|
||||
```
|
||||
- id: sign-in-with-facebook
|
||||
name: Native Facebook Sign in
|
||||
description: |
|
||||
You can perform native Facebook sign in using [flutter_facebook_auth](https://pub.dev/packages/flutter_facebook_auth).
|
||||
|
||||
First, set up your Facebook app in the [Facebook Developer Console](https://developers.facebook.com) and configure it in your Supabase dashboard under `Authentication -> Providers -> Facebook`.
|
||||
code: |
|
||||
```dart
|
||||
import 'package:flutter_facebook_auth/flutter_facebook_auth.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
Future<void> signInWithFacebook() async {
|
||||
final LoginResult result = await FacebookAuth.instance.login(
|
||||
permissions: ['public_profile', 'email'],
|
||||
);
|
||||
|
||||
if (result.status == LoginStatus.success) {
|
||||
final accessToken = result.accessToken!.tokenString;
|
||||
|
||||
final response = await supabase.auth.signInWithIdToken(
|
||||
provider: OAuthProvider.facebook,
|
||||
idToken: accessToken,
|
||||
);
|
||||
} else {
|
||||
throw const AuthException(
|
||||
'Facebook login failed: ${result.status}',
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
- id: sign-in-with-oauth
|
||||
title: 'signInWithOAuth()'
|
||||
description: |
|
||||
|
||||
@@ -214,10 +214,14 @@ export function useLoadTableEditorStateFromLocalStorageIntoUrl({
|
||||
}
|
||||
|
||||
export const handleCopyCell = (
|
||||
{ column, row }: { column: CalculatedColumn<any, unknown>; row: any },
|
||||
{
|
||||
mode,
|
||||
column,
|
||||
row,
|
||||
}: { mode: 'SELECT' | 'EDIT'; column: CalculatedColumn<any, unknown>; 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)
|
||||
|
||||
@@ -128,9 +128,7 @@ export const Grid = memo(
|
||||
}
|
||||
}
|
||||
|
||||
const removeAllFilters = () => {
|
||||
onApplyFilters([])
|
||||
}
|
||||
const removeAllFilters = () => onApplyFilters([])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -110,7 +110,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
return (
|
||||
<TableContainer>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="p-0">
|
||||
<TableCell colSpan={5} className="p-0">
|
||||
<AlertError
|
||||
error={error}
|
||||
subject="Failed to retrieve access tokens"
|
||||
@@ -135,7 +135,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
|
||||
return (
|
||||
<TableContainer>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-12">
|
||||
<TableCell colSpan={5} className="py-12">
|
||||
<p className="text-sm text-center text-foreground">No access tokens found</p>
|
||||
<p className="text-sm text-center text-foreground-light">
|
||||
You do not have any tokens created yet
|
||||
|
||||
@@ -143,7 +143,7 @@ export const PolicyTableRow = ({
|
||||
<TableHead className="w-[40%]">Name</TableHead>
|
||||
<TableHead className="w-[20%]">Command</TableHead>
|
||||
<TableHead className="w-[30%]">Applied to</TableHead>
|
||||
<TableHead className="w-0 text-right">
|
||||
<TableHead className="text-right">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
|
||||
@@ -13,6 +13,11 @@ import { EMPTY_ARR } from 'lib/void'
|
||||
import { basename } from 'path'
|
||||
import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui'
|
||||
|
||||
const EMPTY_FUNCTION_BODY: EdgeFunctionBodyData = {
|
||||
version: 0,
|
||||
files: EMPTY_ARR,
|
||||
}
|
||||
|
||||
interface EdgeFunctionsDiffPanelProps {
|
||||
diffResults: EdgeFunctionsDiffResult
|
||||
currentBranchRef?: string
|
||||
@@ -81,11 +86,11 @@ const FunctionDiff = ({
|
||||
}
|
||||
}, [allFileKeys, activeFileKey])
|
||||
|
||||
const currentFile = currentBody.find(
|
||||
(f: EdgeFunctionBodyData[number]) => 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}
|
||||
/>
|
||||
|
||||
@@ -199,7 +199,7 @@ export const ConnectionPanel = ({
|
||||
<div className="flex flex-col pl-[52px]">
|
||||
<span className="text-xs text-foreground">
|
||||
{type === 'transaction'
|
||||
? 'Pre-warmed connection pool to Postgres'
|
||||
? 'Clients share a connection pool'
|
||||
: 'Each client has a dedicated connection to Postgres'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ export const CreateNewProjectDialog = ({
|
||||
<FormControl_Shadcn_>
|
||||
<Input
|
||||
id="db-password"
|
||||
label="Database Password"
|
||||
label="Database password"
|
||||
type="password"
|
||||
placeholder="Type in a strong password"
|
||||
value={field.value}
|
||||
|
||||
@@ -401,7 +401,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => {
|
||||
disabled={isCreating || isUpdating}
|
||||
loading={isCreating || isUpdating}
|
||||
>
|
||||
Confirm
|
||||
{isEditing ? 'Save' : 'Create'} function
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
|
||||
@@ -2,40 +2,32 @@ import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
|
||||
|
||||
interface DeleteDestinationProps {
|
||||
visible: boolean
|
||||
setVisible: (value: boolean) => void
|
||||
onDelete: () => void
|
||||
isLoading: boolean
|
||||
name: string
|
||||
setVisible: (value: boolean) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const DeleteDestination = ({
|
||||
export const DeleteDestination = ({
|
||||
visible,
|
||||
setVisible,
|
||||
onDelete,
|
||||
isLoading,
|
||||
name,
|
||||
setVisible,
|
||||
onDelete,
|
||||
}: DeleteDestinationProps) => {
|
||||
return (
|
||||
<>
|
||||
<TextConfirmModal
|
||||
variant={'warning'}
|
||||
visible={visible}
|
||||
onCancel={() => setVisible(!visible)}
|
||||
onConfirm={onDelete}
|
||||
title="Delete this destination"
|
||||
loading={isLoading}
|
||||
confirmLabel={`Delete destination`}
|
||||
confirmPlaceholder="Type in name of destination"
|
||||
confirmString={name ?? 'Unknown'}
|
||||
text={
|
||||
<>
|
||||
<span>This will delete the destination</span>{' '}
|
||||
</>
|
||||
}
|
||||
alert={{ title: 'You cannot recover this destination once deleted.' }}
|
||||
/>
|
||||
</>
|
||||
<TextConfirmModal
|
||||
variant="destructive"
|
||||
visible={visible}
|
||||
loading={isLoading}
|
||||
title="Delete this destination"
|
||||
confirmLabel={isLoading ? 'Deleting…' : `Delete destination`}
|
||||
confirmPlaceholder="Type in name of destination"
|
||||
confirmString={name ?? 'Unknown'}
|
||||
text={`This will delete the destination "${name}"`}
|
||||
alert={{ title: 'You cannot recover this destination once deleted.' }}
|
||||
onCancel={() => setVisible(!visible)}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteDestination
|
||||
@@ -1,10 +1,21 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useParams } from 'common'
|
||||
import { useCreateTenantSourceMutation } from 'data/replication/create-tenant-source-mutation'
|
||||
import { useReplicationPublicationsQuery } from 'data/replication/publications-query'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useCreateDestinationPipelineMutation } from 'data/replication/create-destination-pipeline-mutation'
|
||||
import { useCreateTenantSourceMutation } from 'data/replication/create-tenant-source-mutation'
|
||||
import { useReplicationDestinationByIdQuery } from 'data/replication/destination-by-id-query'
|
||||
import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query'
|
||||
import { useReplicationPublicationsQuery } from 'data/replication/publications-query'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useUpdateDestinationPipelineMutation } from 'data/replication/update-destination-pipeline-mutation'
|
||||
import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from 'state/replication-pipeline-request-status'
|
||||
import {
|
||||
Accordion_Shadcn_,
|
||||
AccordionContent_Shadcn_,
|
||||
@@ -23,6 +34,7 @@ import {
|
||||
SelectGroup_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
Separator,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
@@ -30,21 +42,28 @@ import {
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
Switch,
|
||||
TextArea_Shadcn_,
|
||||
WarningIcon,
|
||||
Label_Shadcn_ as Label,
|
||||
} from 'ui'
|
||||
import * as z from 'zod'
|
||||
import PublicationsComboBox from './PublicationsComboBox'
|
||||
import NewPublicationPanel from './NewPublicationPanel'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useReplicationDestinationByIdQuery } from 'data/replication/destination-by-id-query'
|
||||
import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useCreateDestinationPipelineMutation } from 'data/replication/create-destination-pipeline-mutation'
|
||||
import { useUpdateDestinationPipelineMutation } from 'data/replication/update-destination-pipeline-mutation'
|
||||
import NewPublicationPanel from './NewPublicationPanel'
|
||||
import PublicationsComboBox from './PublicationsComboBox'
|
||||
|
||||
const formId = 'destination-editor'
|
||||
const types = ['BigQuery'] as const
|
||||
const TypeEnum = z.enum(types)
|
||||
|
||||
const FormSchema = z.object({
|
||||
type: TypeEnum,
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
projectId: z.string().min(1, 'Project id is required'),
|
||||
datasetId: z.string().min(1, 'Dataset id is required'),
|
||||
serviceAccountKey: z.string().min(1, 'Service account key is required'),
|
||||
publicationName: z.string().min(1, 'Publication is required'),
|
||||
maxSize: z.number().min(1, 'Max Size must be greater than 0').int().optional(),
|
||||
maxFillMs: z.number().min(1, 'Max Fill milliseconds should be greater than 0').int().optional(),
|
||||
maxStalenessMins: z.number().nonnegative().optional(),
|
||||
})
|
||||
|
||||
interface DestinationPanelProps {
|
||||
visible: boolean
|
||||
@@ -58,22 +77,33 @@ interface DestinationPanelProps {
|
||||
}
|
||||
}
|
||||
|
||||
const DestinationPanel = ({
|
||||
export const DestinationPanel = ({
|
||||
visible,
|
||||
sourceId,
|
||||
onClose,
|
||||
existingDestination,
|
||||
}: DestinationPanelProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { setRequestStatus } = usePipelineRequestStatus()
|
||||
|
||||
const editMode = !!existingDestination
|
||||
const [publicationPanelVisible, setPublicationPanelVisible] = useState(false)
|
||||
|
||||
const { mutateAsync: createTenantSource, isLoading: creatingTenantSource } =
|
||||
useCreateTenantSourceMutation()
|
||||
|
||||
const { mutateAsync: createDestinationPipeline, isLoading: creatingDestinationPipeline } =
|
||||
useCreateDestinationPipelineMutation()
|
||||
const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation()
|
||||
const { mutateAsync: stopPipeline, isLoading: stoppingPipeline } = useStopPipelineMutation()
|
||||
useCreateDestinationPipelineMutation({
|
||||
onSuccess: () => form.reset(defaultValues),
|
||||
})
|
||||
|
||||
const { mutateAsync: updateDestinationPipeline, isLoading: updatingDestinationPipeline } =
|
||||
useUpdateDestinationPipelineMutation()
|
||||
useUpdateDestinationPipelineMutation({
|
||||
onSuccess: () => form.reset(defaultValues),
|
||||
})
|
||||
|
||||
const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation()
|
||||
|
||||
const { data: publications, isLoading: loadingPublications } = useReplicationPublicationsQuery({
|
||||
projectRef,
|
||||
sourceId,
|
||||
@@ -89,26 +119,6 @@ const DestinationPanel = ({
|
||||
pipelineId: existingDestination?.pipelineId,
|
||||
})
|
||||
|
||||
const isCreating = creatingTenantSource || creatingDestinationPipeline || startingPipeline
|
||||
const isUpdating = updatingDestinationPipeline || stoppingPipeline || startingPipeline
|
||||
const isSubmitting = isCreating || isUpdating
|
||||
const editMode = !!existingDestination
|
||||
|
||||
const formId = 'destination-editor'
|
||||
const types = ['BigQuery'] as const
|
||||
const TypeEnum = z.enum(types)
|
||||
const FormSchema = z.object({
|
||||
type: TypeEnum,
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
projectId: z.string().min(1, 'Project id is required'),
|
||||
datasetId: z.string().min(1, 'Dataset id is required'),
|
||||
serviceAccountKey: z.string().min(1, 'Service account key is required'),
|
||||
publicationName: z.string().min(1, 'Publication is required'),
|
||||
maxSize: z.number().min(1, 'Max Size must be greater than 0').int(),
|
||||
maxFillMs: z.number().min(1, 'Max Fill milliseconds should be greater than 0').int(),
|
||||
maxStalenessMins: z.number().nonnegative(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
type: TypeEnum.enum.BigQuery,
|
||||
@@ -118,403 +128,406 @@ const DestinationPanel = ({
|
||||
// For now, the password will always be set as empty for security reasons.
|
||||
serviceAccountKey: destinationData?.config?.big_query?.service_account_key ?? '',
|
||||
publicationName: pipelineData?.config.publication_name ?? '',
|
||||
maxSize: pipelineData?.config?.batch?.max_size ?? 1000,
|
||||
maxFillMs: pipelineData?.config?.batch?.max_fill_ms ?? 10,
|
||||
maxStalenessMins: destinationData?.config?.big_query?.max_staleness_mins ?? 5,
|
||||
enabled: existingDestination?.enabled ?? true,
|
||||
maxSize: pipelineData?.config?.batch?.max_size,
|
||||
maxFillMs: pipelineData?.config?.batch?.max_fill_ms,
|
||||
maxStalenessMins: destinationData?.config?.big_query?.max_staleness_mins,
|
||||
}),
|
||||
[destinationData, pipelineData, existingDestination]
|
||||
[destinationData, pipelineData]
|
||||
)
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
mode: 'onBlur',
|
||||
reValidateMode: 'onBlur',
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues,
|
||||
})
|
||||
const isSaving = creatingDestinationPipeline || updatingDestinationPipeline || startingPipeline
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!sourceId) return console.error('Source id is required')
|
||||
|
||||
try {
|
||||
if (editMode && existingDestination) {
|
||||
if (!sourceId) {
|
||||
console.error('Source id is required')
|
||||
return
|
||||
if (!existingDestination.pipelineId) return console.error('Pipeline id is required')
|
||||
|
||||
const bigQueryConfig: any = {
|
||||
projectId: data.projectId,
|
||||
datasetId: data.datasetId,
|
||||
serviceAccountKey: data.serviceAccountKey,
|
||||
}
|
||||
if (!existingDestination.pipelineId) {
|
||||
console.error('Pipeline id is required')
|
||||
return
|
||||
if (!!data.maxStalenessMins) {
|
||||
bigQueryConfig.maxStalenessMins = data.maxStalenessMins
|
||||
}
|
||||
// Update existing destination
|
||||
|
||||
const batchConfig: any = {}
|
||||
if (!!data.maxSize) batchConfig.maxSize = data.maxSize
|
||||
if (!!data.maxFillMs) batchConfig.maxFillMs = data.maxFillMs
|
||||
const hasBothBatchFields = Object.keys(batchConfig).length === 2
|
||||
|
||||
await updateDestinationPipeline({
|
||||
destinationId: existingDestination.destinationId,
|
||||
pipelineId: existingDestination.pipelineId,
|
||||
projectRef,
|
||||
destinationName: data.name,
|
||||
destinationConfig: {
|
||||
bigQuery: {
|
||||
projectId: data.projectId,
|
||||
datasetId: data.datasetId,
|
||||
serviceAccountKey: data.serviceAccountKey,
|
||||
maxStalenessMins: data.maxStalenessMins,
|
||||
},
|
||||
},
|
||||
destinationConfig: { bigQuery: bigQueryConfig },
|
||||
pipelineConfig: {
|
||||
publicationName: data.publicationName,
|
||||
batch: { maxSize: data.maxSize, maxFillMs: data.maxFillMs },
|
||||
...(hasBothBatchFields ? { batch: batchConfig } : {}),
|
||||
},
|
||||
sourceId,
|
||||
})
|
||||
if (data.enabled) {
|
||||
await startPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
|
||||
// Set request status only right before starting, then fire and close
|
||||
const snapshot = existingDestination.enabled ? 'started' : 'stopped'
|
||||
if (existingDestination.enabled) {
|
||||
setRequestStatus(
|
||||
existingDestination.pipelineId,
|
||||
PipelineStatusRequestStatus.RestartRequested,
|
||||
snapshot
|
||||
)
|
||||
toast.success('Settings applied. Restarting the pipeline...')
|
||||
} else {
|
||||
await stopPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
|
||||
setRequestStatus(
|
||||
existingDestination.pipelineId,
|
||||
PipelineStatusRequestStatus.StartRequested,
|
||||
snapshot
|
||||
)
|
||||
toast.success('Settings applied. Starting the pipeline...')
|
||||
}
|
||||
startPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
|
||||
onClose()
|
||||
} else {
|
||||
const bigQueryConfig: any = {
|
||||
projectId: data.projectId,
|
||||
datasetId: data.datasetId,
|
||||
serviceAccountKey: data.serviceAccountKey,
|
||||
}
|
||||
if (!!data.maxStalenessMins) {
|
||||
bigQueryConfig.maxStalenessMins = data.maxStalenessMins
|
||||
}
|
||||
|
||||
toast.success('Successfully updated destination')
|
||||
} else {
|
||||
// Create new destination
|
||||
if (!sourceId) {
|
||||
console.error('Source id is required')
|
||||
return
|
||||
}
|
||||
const batchConfig: any = {}
|
||||
if (!!data.maxSize) batchConfig.maxSize = data.maxSize
|
||||
if (!!data.maxFillMs) batchConfig.maxFillMs = data.maxFillMs
|
||||
const hasBothBatchFields = Object.keys(batchConfig).length === 2
|
||||
|
||||
const { pipeline_id: pipelineId } = await createDestinationPipeline({
|
||||
projectRef,
|
||||
destinationName: data.name,
|
||||
destinationConfig: {
|
||||
bigQuery: {
|
||||
projectId: data.projectId,
|
||||
datasetId: data.datasetId,
|
||||
serviceAccountKey: data.serviceAccountKey,
|
||||
maxStalenessMins: data.maxStalenessMins,
|
||||
},
|
||||
},
|
||||
destinationConfig: { bigQuery: bigQueryConfig },
|
||||
sourceId,
|
||||
pipelineConfig: {
|
||||
publicationName: data.publicationName,
|
||||
batch: { maxSize: data.maxSize, maxFillMs: data.maxFillMs },
|
||||
...(hasBothBatchFields ? { batch: batchConfig } : {}),
|
||||
},
|
||||
})
|
||||
if (data.enabled) {
|
||||
await startPipeline({ projectRef, pipelineId })
|
||||
}
|
||||
toast.success('Successfully created destination')
|
||||
// Set request status only right before starting, then fire and close
|
||||
setRequestStatus(pipelineId, PipelineStatusRequestStatus.StartRequested, undefined)
|
||||
toast.success('Destination created. Starting the pipeline...')
|
||||
startPipeline({ projectRef, pipelineId })
|
||||
onClose()
|
||||
}
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${editMode ? 'update' : 'create'} destination`)
|
||||
const action = editMode ? 'apply and run' : 'create and start'
|
||||
toast.error(`Failed to ${action} destination`)
|
||||
}
|
||||
}
|
||||
|
||||
const onEnableReplication = async () => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
await createTenantSource({ projectRef })
|
||||
}
|
||||
|
||||
const { enabled } = form.watch()
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode && destinationData && pipelineData) {
|
||||
form.reset(defaultValues)
|
||||
}
|
||||
}, [destinationData, pipelineData, editMode, defaultValues, form])
|
||||
|
||||
return (
|
||||
return sourceId ? (
|
||||
<>
|
||||
{sourceId ? (
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={onClose}>
|
||||
<SheetContent showClose={false} size="default">
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader className="flex justify-between items-center">
|
||||
<div>
|
||||
<SheetTitle>{editMode ? 'Edit Destination' : 'New Destination'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{editMode ? null : 'Send data to a new destination'}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue('enabled', checked)
|
||||
}}
|
||||
<Sheet open={visible} onOpenChange={onClose}>
|
||||
<SheetContent showClose={false} size="default">
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{editMode ? 'Edit destination' : 'Create a new destination'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{editMode ? null : 'Send data to a new destination'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-grow overflow-auto px-0 pb-0">
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pb-4">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-8"
|
||||
label="Name"
|
||||
layout="vertical"
|
||||
description="A name you will use to identify this destination"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Name" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<h3 className="mb-4">What data to send</h3>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="publicationName"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Publication"
|
||||
layout="vertical"
|
||||
description="A publication is a collection of tables that you want to replicate "
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<PublicationsComboBox
|
||||
publications={publications?.map((pub) => pub.name) || []}
|
||||
loading={loadingPublications}
|
||||
field={field}
|
||||
onNewPublicationClick={() => setPublicationPanelVisible(true)}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<h3 className="mb-4 mt-8">Where to send that data</h3>
|
||||
|
||||
<FormField_Shadcn_
|
||||
name="type"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Type"
|
||||
layout="vertical"
|
||||
description="The type of destination to send the data to"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_ value={field.value}>
|
||||
<SelectTrigger_Shadcn_>{field.value}</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="BigQuery">BigQuery</SelectItem_Shadcn_>
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Project ID"
|
||||
layout="vertical"
|
||||
description="Which BigQuery project to send data to"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Project ID" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="datasetId"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Project's Dataset ID"
|
||||
layout="vertical"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Dataset ID" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="serviceAccountKey"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Service Account Key"
|
||||
layout="vertical"
|
||||
description="The service account key for BigQuery"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<TextArea_Shadcn_
|
||||
{...field}
|
||||
rows={4}
|
||||
maxLength={5000}
|
||||
placeholder="Service account key"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<Label className="text-sm mx-2">Enable</Label>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-grow overflow-auto">
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-8"
|
||||
label="Name"
|
||||
layout="vertical"
|
||||
description="A name you will use to identify this destination"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Name" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<h3 className="mb-4">What data to send</h3>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="publicationName"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Publication"
|
||||
layout="vertical"
|
||||
description="A publication is a collection of tables that you want to replicate "
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<PublicationsComboBox
|
||||
publications={publications?.map((pub) => pub.name) || []}
|
||||
loading={loadingPublications}
|
||||
field={field}
|
||||
onNewPublicationClick={() => setPublicationPanelVisible(true)}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<h3 className="mb-4 mt-8">Where to send that data</h3>
|
||||
<Separator />
|
||||
|
||||
<FormField_Shadcn_
|
||||
name="type"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Type"
|
||||
layout="vertical"
|
||||
description="The type of destination to send the data to"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_ value={field.value}>
|
||||
<SelectTrigger_Shadcn_>{field.value}</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="BigQuery">
|
||||
BigQuery
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
></FormField_Shadcn_>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Project Id"
|
||||
layout="vertical"
|
||||
description="Which BigQuery project to send data to"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Project id" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="datasetId"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Project's Dataset Id"
|
||||
layout="vertical"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Dataset id" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="serviceAccountKey"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Service Account Key"
|
||||
layout="vertical"
|
||||
description="The service account key for BigQuery"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<TextArea_Shadcn_
|
||||
{...field}
|
||||
rows={4}
|
||||
maxLength={5000}
|
||||
placeholder="Service account key"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Accordion_Shadcn_ type="single" collapsible>
|
||||
<AccordionItem_Shadcn_ value="item-1" className="border-none">
|
||||
<AccordionTrigger_Shadcn_ className="font-normal gap-2 justify-start mb-0 mt-8">
|
||||
Advanced Settings
|
||||
</AccordionTrigger_Shadcn_>
|
||||
<AccordionContent_Shadcn_ asChild className="!pb-0">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="maxSize"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Max Size"
|
||||
layout="vertical"
|
||||
description="The maximum size of the data to send"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type="number"
|
||||
{...form.register('maxSize', {
|
||||
valueAsNumber: true, // Ensure the value is handled as a number
|
||||
})}
|
||||
placeholder="Max size"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="maxFillMs"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Max Fill Seconds"
|
||||
layout="vertical"
|
||||
description="The maximum amount of time to fill the data"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type="number"
|
||||
{...form.register('maxFillMs', {
|
||||
valueAsNumber: true, // Ensure the value is handled as a number
|
||||
})}
|
||||
placeholder="Max fill seconds"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="maxStalenessMins"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Max Staleness"
|
||||
layout="vertical"
|
||||
description="Maximum staleness time allowed"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type="number"
|
||||
{...form.register('maxStalenessMins', {
|
||||
valueAsNumber: true, // Ensure the value is handled as a number
|
||||
})}
|
||||
placeholder="Max staleness in minutes"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent_Shadcn_>
|
||||
</AccordionItem_Shadcn_>
|
||||
</Accordion_Shadcn_>
|
||||
<div className="hidden">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout className="mb-4" layout="vertical" label="Enabled">
|
||||
<FormControl_Shadcn_>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</SheetSection>
|
||||
<SheetFooter>
|
||||
<Button disabled={isSubmitting} type="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
form={formId}
|
||||
htmlType="submit"
|
||||
>
|
||||
{editMode ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<NewPublicationPanel
|
||||
visible={publicationPanelVisible}
|
||||
sourceId={sourceId}
|
||||
onClose={() => setPublicationPanelVisible(false)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={onClose}>
|
||||
<SheetContent showClose={false} size="default">
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>New Destination</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-grow overflow-auto">
|
||||
<Alert_Shadcn_>
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
{/* Pricing to be decided yet */}
|
||||
Enabling replication will cost additional $xx.xx
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
<span></span>
|
||||
<div className="flex items-center gap-x-2 mt-3">
|
||||
<Button type="default" onClick={onEnableReplication}>
|
||||
Enable replication
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</SheetSection>
|
||||
<SheetFooter>
|
||||
<Button disabled={isSubmitting} type="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
<div className="px-5">
|
||||
<Accordion_Shadcn_ type="single" collapsible>
|
||||
<AccordionItem_Shadcn_ value="item-1" className="border-none">
|
||||
<AccordionTrigger_Shadcn_ className="font-normal gap-2 justify-between text-sm">
|
||||
Advanced Settings
|
||||
</AccordionTrigger_Shadcn_>
|
||||
<AccordionContent_Shadcn_ asChild className="!pb-0">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="maxSize"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Max size"
|
||||
layout="vertical"
|
||||
description="The maximum size of the data to send. Leave empty to use default value."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
field.onChange(val === '' ? undefined : Number(val))
|
||||
}}
|
||||
placeholder="Leave empty for default"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="maxFillMs"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Max fill milliseconds"
|
||||
layout="vertical"
|
||||
description="The maximum amount of time to fill the data in milliseconds. Leave empty to use default value."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
field.onChange(val === '' ? undefined : Number(val))
|
||||
}}
|
||||
placeholder="Leave empty for default"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="maxStalenessMins"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Max staleness minutes"
|
||||
layout="vertical"
|
||||
description="Maximum staleness time allowed in minutes. Leave empty to use default value."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
field.onChange(val === '' ? undefined : Number(val))
|
||||
}}
|
||||
placeholder="Leave empty for default"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent_Shadcn_>
|
||||
</AccordionItem_Shadcn_>
|
||||
</Accordion_Shadcn_>
|
||||
</div>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</SheetSection>
|
||||
<SheetFooter>
|
||||
<Button disabled={isSaving} type="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={isSaving} form={formId} htmlType="submit">
|
||||
{editMode
|
||||
? existingDestination?.enabled
|
||||
? 'Apply and restart'
|
||||
: 'Apply and start'
|
||||
: 'Create and start'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<NewPublicationPanel
|
||||
visible={publicationPanelVisible}
|
||||
sourceId={sourceId}
|
||||
onClose={() => setPublicationPanelVisible(false)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Sheet open={visible} onOpenChange={onClose}>
|
||||
<SheetContent showClose={false} size="default">
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create a new destination</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-grow overflow-auto">
|
||||
<Alert_Shadcn_>
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
{/* Pricing to be decided yet */}
|
||||
Enabling replication will cost additional $xx.xx
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
<span></span>
|
||||
<div className="flex items-center gap-x-2 mt-3">
|
||||
<Button
|
||||
type="default"
|
||||
loading={creatingTenantSource}
|
||||
onClick={onEnableReplication}
|
||||
>
|
||||
Enable replication
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</SheetSection>
|
||||
<SheetFooter>
|
||||
<Button disabled={creatingTenantSource} type="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export default DestinationPanel
|
||||
@@ -6,18 +6,20 @@ import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { useDeleteDestinationPipelineMutation } from 'data/replication/delete-destination-pipeline-mutation'
|
||||
import { useReplicationPipelineReplicationStatusQuery } from 'data/replication/pipeline-replication-status-query'
|
||||
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
|
||||
import { Pipeline } from 'data/replication/pipelines-query'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from 'state/replication-pipeline-request-status'
|
||||
import { ResponseError } from 'types'
|
||||
import { Button } from 'ui'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import DeleteDestination from './DeleteDestination'
|
||||
import DestinationPanel from './DestinationPanel'
|
||||
import { DeleteDestination } from './DeleteDestination'
|
||||
import { DestinationPanel } from './DestinationPanel'
|
||||
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { PipelineStatus, PipelineStatusName } from './PipelineStatus'
|
||||
import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
@@ -48,6 +50,7 @@ export const DestinationRow = ({
|
||||
}: DestinationRowProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showEditDestinationPanel, setShowEditDestinationPanel] = useState(false)
|
||||
|
||||
const {
|
||||
@@ -74,6 +77,15 @@ export const DestinationRow = ({
|
||||
const pipelineStatus = pipelineStatusData?.status
|
||||
const statusName = getStatusName(pipelineStatus)
|
||||
|
||||
// Fetch table-level replication status to surface errors in list view
|
||||
const { data: replicationStatusData } = useReplicationPipelineReplicationStatusQuery(
|
||||
{ projectRef, pipelineId: pipeline?.id },
|
||||
{ refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
|
||||
)
|
||||
const tableStatuses = replicationStatusData?.table_statuses ?? []
|
||||
const errorCount = tableStatuses.filter((t) => t.state?.name === 'error').length
|
||||
const hasTableErrors = errorCount > 0
|
||||
|
||||
const onDeleteClick = async () => {
|
||||
if (!projectRef) {
|
||||
return console.error('Project ref is required')
|
||||
@@ -83,14 +95,20 @@ export const DestinationRow = ({
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
await deleteDestinationPipeline({
|
||||
projectRef,
|
||||
destinationId: destinationId,
|
||||
pipelineId: pipeline.id,
|
||||
})
|
||||
// Close dialog after successful deletion
|
||||
setShowDeleteDestinationForm(false)
|
||||
toast.success(`Deleted destination "${destinationName}"`)
|
||||
} catch (error) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +150,21 @@ export const DestinationRow = ({
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Button asChild type="default">
|
||||
<Button asChild type="default" className="relative">
|
||||
<Link href={`/project/${projectRef}/database/replication/${pipeline?.id}`}>
|
||||
View status
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>View status</span>
|
||||
{hasTableErrors && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertCircle size={14} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{errorCount} table{errorCount === 1 ? '' : 's'} have replication errors
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<RowMenu
|
||||
@@ -154,7 +184,7 @@ export const DestinationRow = ({
|
||||
visible={showDeleteDestinationForm}
|
||||
setVisible={setShowDeleteDestinationForm}
|
||||
onDelete={onDeleteClick}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isLoading={isDeleting}
|
||||
name={destinationName}
|
||||
/>
|
||||
<DestinationPanel
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query'
|
||||
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
|
||||
import { Button, cn, Input_Shadcn_ } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import NewDestinationPanel from './DestinationPanel'
|
||||
import { DestinationPanel } from './DestinationPanel'
|
||||
import { DestinationRow } from './DestinationRow'
|
||||
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
|
||||
@@ -159,7 +159,7 @@ export const Destinations = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewDestinationPanel
|
||||
<DestinationPanel
|
||||
visible={showNewDestinationPanel}
|
||||
sourceId={sourceId}
|
||||
onClose={() => setShowNewDestinationPanel(false)}
|
||||
|
||||
@@ -22,16 +22,25 @@ export const getStatusName = (
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const PIPELINE_ENABLE_ALLOWED_FROM = ['stopped'] as const
|
||||
export const PIPELINE_DISABLE_ALLOWED_FROM = ['started', 'failed'] as const
|
||||
export const PIPELINE_ACTIONABLE_STATES = ['failed', 'started', 'stopped'] as const
|
||||
|
||||
const PIPELINE_STATE_MESSAGES = {
|
||||
enabling: {
|
||||
title: 'Pipeline enabling',
|
||||
message: 'Starting the pipeline. Table replication will resume once enabled.',
|
||||
badge: 'Enabling',
|
||||
title: 'Starting pipeline',
|
||||
message: 'Starting the pipeline. Table replication will resume once running.',
|
||||
badge: 'Starting',
|
||||
},
|
||||
disabling: {
|
||||
title: 'Pipeline disabling',
|
||||
message: 'Stopping the pipeline. Table replication will be paused once disabled.',
|
||||
badge: 'Disabling',
|
||||
title: 'Stopping pipeline',
|
||||
message: 'Stopping the pipeline. Table replication will be paused once stopped.',
|
||||
badge: 'Stopping',
|
||||
},
|
||||
restarting: {
|
||||
title: 'Restarting pipeline',
|
||||
message: 'Applying settings and restarting the pipeline.',
|
||||
badge: 'Restarting',
|
||||
},
|
||||
failed: {
|
||||
title: 'Pipeline failed',
|
||||
@@ -40,7 +49,7 @@ const PIPELINE_STATE_MESSAGES = {
|
||||
},
|
||||
stopped: {
|
||||
title: 'Pipeline stopped',
|
||||
message: 'Replication is paused. Enable the pipeline to resume data synchronization.',
|
||||
message: 'Replication is paused. Start the pipeline to resume data synchronization.',
|
||||
badge: 'Stopped',
|
||||
},
|
||||
starting: {
|
||||
@@ -60,8 +69,8 @@ const PIPELINE_STATE_MESSAGES = {
|
||||
},
|
||||
notRunning: {
|
||||
title: 'Pipeline not running',
|
||||
message: 'Replication is not active. Enable the pipeline to start data synchronization.',
|
||||
badge: 'Disabled',
|
||||
message: 'Replication is not active. Start the pipeline to begin data synchronization.',
|
||||
badge: 'Stopped',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -69,23 +78,25 @@ export const getPipelineStateMessages = (
|
||||
requestStatus: PipelineStatusRequestStatus | undefined,
|
||||
statusName: string | undefined
|
||||
) => {
|
||||
// Always prioritize request status (enabling/disabling) over pipeline status
|
||||
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
|
||||
// Reflect optimistic request intent immediately after click
|
||||
if (requestStatus === PipelineStatusRequestStatus.RestartRequested) {
|
||||
return PIPELINE_STATE_MESSAGES.restarting
|
||||
}
|
||||
if (requestStatus === PipelineStatusRequestStatus.StartRequested) {
|
||||
return PIPELINE_STATE_MESSAGES.enabling
|
||||
}
|
||||
|
||||
if (requestStatus === PipelineStatusRequestStatus.DisableRequested) {
|
||||
if (requestStatus === PipelineStatusRequestStatus.StopRequested) {
|
||||
return PIPELINE_STATE_MESSAGES.disabling
|
||||
}
|
||||
|
||||
// Only check pipeline status if no request is in progress
|
||||
// Fall back to steady states
|
||||
switch (statusName) {
|
||||
case 'starting':
|
||||
return PIPELINE_STATE_MESSAGES.starting
|
||||
case 'failed':
|
||||
return PIPELINE_STATE_MESSAGES.failed
|
||||
case 'stopped':
|
||||
return PIPELINE_STATE_MESSAGES.stopped
|
||||
case 'starting':
|
||||
return PIPELINE_STATE_MESSAGES.starting
|
||||
case 'started':
|
||||
return PIPELINE_STATE_MESSAGES.running
|
||||
case 'unknown':
|
||||
|
||||
@@ -45,18 +45,26 @@ export const PipelineStatus = ({
|
||||
// Get consistent tooltip message using the same logic as other components
|
||||
const stateMessages = getPipelineStateMessages(requestStatus, statusName)
|
||||
|
||||
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
|
||||
// Show optimistic request state while backend still reports steady states
|
||||
if (requestStatus === PipelineStatusRequestStatus.RestartRequested) {
|
||||
return {
|
||||
label: 'Enabling...',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-brand-600" />,
|
||||
color: 'text-brand-600',
|
||||
label: 'Restarting',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
|
||||
color: 'text-warning-600',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
}
|
||||
|
||||
if (requestStatus === PipelineStatusRequestStatus.DisableRequested) {
|
||||
if (requestStatus === PipelineStatusRequestStatus.StartRequested) {
|
||||
return {
|
||||
label: 'Disabling...',
|
||||
label: 'Starting',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
|
||||
color: 'text-warning-600',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
}
|
||||
if (requestStatus === PipelineStatusRequestStatus.StopRequested) {
|
||||
return {
|
||||
label: 'Stopping',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
|
||||
color: 'text-warning-600',
|
||||
tooltip: stateMessages.message,
|
||||
|
||||
+17
-19
@@ -1,7 +1,3 @@
|
||||
// @ts-nocheck [Joshen] Temporarily silencing the TS checks here but please eventually remove
|
||||
// it's cause the API types are conflicting a bit - API types have probably been updated for the UI here
|
||||
// but the UI hasn't been updated yet to fit the new API types
|
||||
|
||||
import { Activity, ChevronLeft, ExternalLink, Search, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -24,7 +20,13 @@ import { Badge, Button, cn } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { ErroredTableDetails } from './ErroredTableDetails'
|
||||
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import {
|
||||
PIPELINE_ACTIONABLE_STATES,
|
||||
PIPELINE_DISABLE_ALLOWED_FROM,
|
||||
PIPELINE_ENABLE_ALLOWED_FROM,
|
||||
PIPELINE_ERROR_MESSAGES,
|
||||
getStatusName,
|
||||
} from './Pipeline.utils'
|
||||
import { PipelineStatus } from './PipelineStatus'
|
||||
import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
import { TableState } from './ReplicationPipelineStatus.types'
|
||||
@@ -97,8 +99,9 @@ export const ReplicationPipelineStatus = () => {
|
||||
const isPipelineRunning = statusName === 'started'
|
||||
const hasTableData = tableStatuses.length > 0
|
||||
const isEnablingDisabling =
|
||||
requestStatus === PipelineStatusRequestStatus.EnableRequested ||
|
||||
requestStatus === PipelineStatusRequestStatus.DisableRequested
|
||||
requestStatus === PipelineStatusRequestStatus.StartRequested ||
|
||||
requestStatus === PipelineStatusRequestStatus.StopRequested ||
|
||||
requestStatus === PipelineStatusRequestStatus.RestartRequested
|
||||
const showDisabledState = !isPipelineRunning || isEnablingDisabling
|
||||
|
||||
const onTogglePipeline = async () => {
|
||||
@@ -110,12 +113,12 @@ export const ReplicationPipelineStatus = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (statusName === 'stopped') {
|
||||
if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as any)) {
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
|
||||
} else if (statusName === 'started') {
|
||||
} else if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as any)) {
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName)
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
|
||||
@@ -181,9 +184,9 @@ export const ReplicationPipelineStatus = () => {
|
||||
type={statusName === 'stopped' ? 'primary' : 'default'}
|
||||
onClick={() => onTogglePipeline()}
|
||||
loading={isPipelineError || isStartingPipeline || isStoppingPipeline}
|
||||
disabled={!['failed', 'started', 'stopped', 'stopping'].includes(statusName ?? '')}
|
||||
disabled={!PIPELINE_ACTIONABLE_STATES.includes((statusName ?? '') as any)}
|
||||
>
|
||||
{statusName === 'stopped' ? 'Enable' : 'Disable'} pipeline
|
||||
{statusName === 'stopped' ? 'Start' : 'Stop'} pipeline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,15 +289,10 @@ export const ReplicationPipelineStatus = () => {
|
||||
Status unavailable while pipeline is {config.badge.toLowerCase()}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-foreground">
|
||||
{statusConfig.description}
|
||||
</div>
|
||||
{'lag' in table.state && (
|
||||
<div className="text-xs text-foreground-light">
|
||||
Lag: {table.state.lag}ms
|
||||
</div>
|
||||
)}
|
||||
{table.state.name === 'error' && (
|
||||
<ErroredTableDetails
|
||||
state={table.state}
|
||||
|
||||
+30
-28
@@ -56,9 +56,10 @@ export const getDisabledStateConfig = ({
|
||||
const { title, message, badge } = getPipelineStateMessages(requestStatus, statusName)
|
||||
|
||||
// Get icon and colors based on current state
|
||||
const isEnabling = requestStatus === PipelineStatusRequestStatus.EnableRequested
|
||||
const isDisabling = requestStatus === PipelineStatusRequestStatus.DisableRequested
|
||||
const isTransitioning = isEnabling || isDisabling
|
||||
const isEnabling = requestStatus === PipelineStatusRequestStatus.StartRequested
|
||||
const isDisabling = requestStatus === PipelineStatusRequestStatus.StopRequested
|
||||
const isRestarting = requestStatus === PipelineStatusRequestStatus.RestartRequested
|
||||
const isTransitioning = isEnabling || isDisabling || isRestarting
|
||||
|
||||
const icon = isTransitioning ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
@@ -72,37 +73,38 @@ export const getDisabledStateConfig = ({
|
||||
<Activity className="w-6 h-6" />
|
||||
)
|
||||
|
||||
const colors = isEnabling
|
||||
? {
|
||||
bg: 'bg-brand-50',
|
||||
text: 'text-brand-900',
|
||||
subtext: 'text-brand-700',
|
||||
iconBg: 'bg-brand-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: isDisabling || statusName === 'starting' || statusName === 'unknown'
|
||||
const colors =
|
||||
isEnabling || isRestarting
|
||||
? {
|
||||
bg: 'bg-warning-50',
|
||||
text: 'text-warning-900',
|
||||
subtext: 'text-warning-700',
|
||||
iconBg: 'bg-warning-600',
|
||||
bg: 'bg-brand-50',
|
||||
text: 'text-brand-900',
|
||||
subtext: 'text-brand-700',
|
||||
iconBg: 'bg-brand-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: statusName === 'failed'
|
||||
: isDisabling || statusName === 'starting' || statusName === 'unknown'
|
||||
? {
|
||||
bg: 'bg-destructive-50',
|
||||
text: 'text-destructive-900',
|
||||
subtext: 'text-destructive-700',
|
||||
iconBg: 'bg-destructive-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: {
|
||||
bg: 'bg-surface-100',
|
||||
text: 'text-foreground',
|
||||
subtext: 'text-foreground-light',
|
||||
iconBg: 'bg-foreground-lighter',
|
||||
bg: 'bg-warning-50',
|
||||
text: 'text-warning-900',
|
||||
subtext: 'text-warning-700',
|
||||
iconBg: 'bg-warning-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: statusName === 'failed'
|
||||
? {
|
||||
bg: 'bg-destructive-50',
|
||||
text: 'text-destructive-900',
|
||||
subtext: 'text-destructive-700',
|
||||
iconBg: 'bg-destructive-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: {
|
||||
bg: 'bg-surface-100',
|
||||
text: 'text-foreground',
|
||||
subtext: 'text-foreground-light',
|
||||
iconBg: 'bg-foreground-lighter',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
|
||||
return { title, message, badge, icon, colors }
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const RetryOptionsDropdown = ({ tableId, tableName }: RetryOptionsDropdow
|
||||
const { mutate: rollbackTable, isLoading: isRollingBack } = useRollbackTableMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
const { projectRef, pipelineId } = vars
|
||||
toast.success(`Table "${tableName}" rolled back successfully`)
|
||||
toast.success(`Table "${tableName}" rolled back successfully and pipeline is being restarted`)
|
||||
startPipeline({ projectRef, pipelineId })
|
||||
},
|
||||
onError: (error, vars) => {
|
||||
|
||||
@@ -20,7 +20,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import {
|
||||
PIPELINE_DISABLE_ALLOWED_FROM,
|
||||
PIPELINE_ENABLE_ALLOWED_FROM,
|
||||
PIPELINE_ERROR_MESSAGES,
|
||||
getStatusName,
|
||||
} from './Pipeline.utils'
|
||||
import { PipelineStatusName } from './PipelineStatus'
|
||||
|
||||
interface RowMenuProps {
|
||||
@@ -44,13 +49,6 @@ export const RowMenu = ({
|
||||
}: RowMenuProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const getStatusName = (status: any) => {
|
||||
if (status && typeof status === 'object' && 'name' in status) {
|
||||
return status.name
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
const statusName = getStatusName(pipelineStatus)
|
||||
const pipelineEnabled = statusName !== PipelineStatusName.STOPPED
|
||||
|
||||
@@ -67,10 +65,13 @@ export const RowMenu = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Only show 'enabling' when transitioning from allowed states
|
||||
if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as any)) {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
|
||||
}
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
toast(`Enabling pipeline ${pipeline.destination_name}`)
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
|
||||
} catch (error) {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
|
||||
}
|
||||
}
|
||||
@@ -86,10 +87,13 @@ export const RowMenu = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Only show 'disabling' when transitioning from allowed states
|
||||
if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as any)) {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName)
|
||||
}
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
toast(`Disabling pipeline ${pipeline.destination_name}`)
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
|
||||
} catch (error) {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION)
|
||||
}
|
||||
}
|
||||
@@ -109,12 +113,12 @@ export const RowMenu = ({
|
||||
{pipelineEnabled ? (
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDisablePipeline}>
|
||||
<Pause size={14} />
|
||||
<p>Disable pipeline</p>
|
||||
<p>Stop pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem className="space-x-2" onClick={onEnablePipeline}>
|
||||
<Play size={14} />
|
||||
<p>Enable pipeline</p>
|
||||
<p>Start pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -451,7 +451,7 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
|
||||
Cancel
|
||||
</Button>
|
||||
<Button form={formId} htmlType="submit" loading={isCreating || isUpdating}>
|
||||
Create trigger
|
||||
{isEditing ? 'Save' : 'Create'} trigger
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
@@ -61,7 +61,7 @@ export const PostgresVersionSelector = ({
|
||||
form,
|
||||
type = 'create',
|
||||
layout = 'horizontal',
|
||||
label = 'Postgres Version',
|
||||
label = 'Postgres version',
|
||||
}: PostgresVersionSelectorProps) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,9 +87,14 @@ export const RegionSelector = ({
|
||||
<>
|
||||
<p>Select the region closest to your users for the best performance.</p>
|
||||
{showNonProdFields && (
|
||||
<p className="text-warning">
|
||||
Note: Only US (NV), Frankfurt and SG are supported for local/staging projects
|
||||
</p>
|
||||
<div className="mt-2 text-warning-600">
|
||||
<p>Only these regions are supported for local/staging projects:</p>
|
||||
<ul className="list-disc list-inside mt-1">
|
||||
<li>East US (North Virginia)</li>
|
||||
<li>Central EU (Frankfurt)</li>
|
||||
<li>Southeast Asia (Singapore)</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export const SecurityOptions = ({
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormItemLayout className="mt-6" layout={layout} label="Data API Configuration">
|
||||
<FormItemLayout className="mt-6" layout={layout} label="Data API configuration">
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupStacked
|
||||
defaultValue={field.value.toString()}
|
||||
|
||||
@@ -1,36 +1,13 @@
|
||||
/**
|
||||
* ReportChart
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
import LogChartHandler from 'components/ui/Charts/LogChartHandler'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useChartData } from 'hooks/useChartData'
|
||||
import type { UpdateDateRange } from 'pages/project/[ref]/reports/database'
|
||||
import { Button, cn } from 'ui'
|
||||
import { ReportChartUpsell } from './v2/ReportChartUpsell'
|
||||
|
||||
const ReportChart = ({
|
||||
chart,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
updateDateRange,
|
||||
functionIds,
|
||||
isLoading,
|
||||
className,
|
||||
}: {
|
||||
interface ReportChartProps {
|
||||
chart: any
|
||||
startDate: string
|
||||
endDate: string
|
||||
@@ -38,13 +15,28 @@ const ReportChart = ({
|
||||
updateDateRange: UpdateDateRange
|
||||
functionIds?: string[]
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
}) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Panel
|
||||
title={<p className="text-sm">{chart.label}</p>}
|
||||
className={cn('h-[260px] relative', className)}
|
||||
>
|
||||
<div className="z-10 flex flex-col items-center justify-center space-y-2 h-full absolute top-0 left-0 w-full bg-surface-100/70 backdrop-blur-md">
|
||||
<h2>{chart.label}</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
This chart is available from{' '}
|
||||
<span className="capitalize">
|
||||
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
|
||||
</span>{' '}
|
||||
plan and above
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
type="primary"
|
||||
onMouseEnter={() => setIsHoveringUpgrade(true)}
|
||||
onMouseLeave={() => setIsHoveringUpgrade(false)}
|
||||
>
|
||||
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=reports`}>
|
||||
Upgrade to{' '}
|
||||
<span className="capitalize">
|
||||
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 w-full h-full z-0">
|
||||
<LogChartHandler
|
||||
attributes={[
|
||||
{
|
||||
attribute: 'demo',
|
||||
enabled: true,
|
||||
label: 'Demo',
|
||||
provider: 'logs',
|
||||
},
|
||||
]}
|
||||
label={chart.label}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={interval}
|
||||
data={chartData as any}
|
||||
isLoading={false}
|
||||
highlightedValue={0}
|
||||
updateDateRange={updateDateRange}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
return <ReportChartUpsell report={chart} orgSlug={org?.slug ?? ''} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -171,4 +94,3 @@ const ReportChart = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default ReportChart
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<keyof PresetConfig['queries'], () => 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 = <T extends string>(
|
||||
queries: BaseQueries<T>,
|
||||
projectRef: string
|
||||
@@ -35,3 +40,36 @@ export const queriesFactory = <T extends string>(
|
||||
)
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className={cn('h-[260px] relative')}>
|
||||
<div className="z-10 flex flex-col items-center justify-center space-y-2 h-full absolute top-0 left-0 w-full bg-surface-100/70 backdrop-blur-md">
|
||||
<h2 className="text-sm">{report.label}</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
This chart is available from{' '}
|
||||
<span className="capitalize">
|
||||
{!!report.availableIn?.length ? report.availableIn[0] : 'Pro'}
|
||||
</span>{' '}
|
||||
plan and above
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
type="primary"
|
||||
onMouseEnter={() => setIsHoveringUpgrade(true)}
|
||||
onMouseLeave={() => setIsHoveringUpgrade(false)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Link href={`/org/${orgSlug}/billing?panel=subscriptionPlan&source=reports`}>
|
||||
Upgrade to{' '}
|
||||
<span className="capitalize">
|
||||
{!!report.availableIn?.length ? report.availableIn[0] : 'Pro'}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 w-full h-full z-0">
|
||||
<LogChartHandler
|
||||
attributes={[
|
||||
{
|
||||
attribute: 'demo',
|
||||
enabled: true,
|
||||
label: 'Demo',
|
||||
provider: 'logs',
|
||||
},
|
||||
]}
|
||||
label={''}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={'1d'}
|
||||
data={demoData as any}
|
||||
isLoading={false}
|
||||
highlightedValue={0}
|
||||
updateDateRange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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<string>(report.defaultChartStyle)
|
||||
|
||||
if (!isAvailable && !isLoadingChart) {
|
||||
return <ReportChartUpsell report={report} orgSlug={org?.slug ?? ''} />
|
||||
}
|
||||
|
||||
const isErrorState = error && !isLoadingChart
|
||||
const showEmptyState = (!finalChartData || finalChartData.length === 0) && !isLoadingChart
|
||||
|
||||
return (
|
||||
<Card id={report.id} className={cn('relative w-full overflow-hidden scroll-mt-16', className)}>
|
||||
<CardContent className="flex flex-col gap-4 min-h-[280px] items-center justify-center">
|
||||
{isLoadingChart ? (
|
||||
<Loader2 className="size-5 animate-spin text-foreground-light" />
|
||||
) : showEmptyState ? (
|
||||
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
|
||||
No data available for the selected time range
|
||||
</p>
|
||||
) : isErrorState ? (
|
||||
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
|
||||
Error loading chart data
|
||||
</p>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<ComposedChart
|
||||
attributes={dynamicAttributes}
|
||||
data={finalChartData}
|
||||
format={report.format ?? undefined}
|
||||
xAxisKey={report.xAxisKey ?? 'timestamp'}
|
||||
yAxisKey={report.yAxisKey ?? dynamicAttributes[0]?.attribute}
|
||||
highlightedValue={0}
|
||||
title={report.label}
|
||||
customDateFormat={undefined}
|
||||
chartHighlight={undefined}
|
||||
chartStyle={chartStyle}
|
||||
showTooltip={report.showTooltip}
|
||||
showLegend={report.showLegend}
|
||||
showTotal={false}
|
||||
showMaxValue={report.showMaxValue}
|
||||
onChartStyleChange={setChartStyle}
|
||||
updateDateRange={updateDateRange}
|
||||
valuePrecision={report.valuePrecision}
|
||||
hideChartType={report.hideChartType}
|
||||
titleTooltip={report.titleTooltip}
|
||||
syncId={syncId}
|
||||
sql={queryResult?.query}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -676,7 +676,7 @@ export const SQLEditor = () => {
|
||||
direction="vertical"
|
||||
autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE}
|
||||
>
|
||||
<ResizablePanel maxSize={70}>
|
||||
<ResizablePanel defaultSize={50} maxSize={70}>
|
||||
<div className="flex-grow overflow-y-auto border-b h-full">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
@@ -793,7 +793,7 @@ export const SQLEditor = () => {
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel maxSize={70}>
|
||||
<ResizablePanel defaultSize={50} maxSize={70}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="animate-spin text-brand" />
|
||||
@@ -811,7 +811,7 @@ export const SQLEditor = () => {
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
<div className="max-h-9">
|
||||
<div className="h-9">
|
||||
{results?.rows !== undefined && !isExecuting && (
|
||||
<GridFooter className="flex items-center justify-between gap-2">
|
||||
<Tooltip>
|
||||
|
||||
@@ -78,7 +78,7 @@ const UtilityActions = ({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testId="sql-editor-utility-actions"
|
||||
data-testid="sql-editor-utility-actions"
|
||||
type="default"
|
||||
className={cn('px-1', isAiOpen ? 'block 2xl:hidden' : 'hidden')}
|
||||
icon={<MoreVertical className="text-foreground-light" />}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { Filters, LogData, LogsEndpointParams } from './Logs.types'
|
||||
* Convert a micro timestamp from number/string to iso timestamp
|
||||
*/
|
||||
export const unixMicroToIsoTimestamp = (unix: string | number): string => {
|
||||
return dayjs.unix(Number(unix) / 1000 / 1000).toISOString()
|
||||
return dayjs.utc(Number(unix) / 1000).toISOString()
|
||||
}
|
||||
|
||||
export const isUnixMicro = (unix: string | number): boolean => {
|
||||
@@ -483,19 +483,22 @@ export const fillTimeseries = (
|
||||
// If we have more points than minPointsToFill, just normalize timestamps and return
|
||||
if (timeseriesData.length > minPointsToFill) {
|
||||
return timeseriesData.map((datum) => {
|
||||
const iso = dayjs.utc(datum[timestampKey]).toISOString()
|
||||
const timestamp = datum[timestampKey]
|
||||
const iso = isUnixMicro(timestamp)
|
||||
? unixMicroToIsoTimestamp(timestamp)
|
||||
: dayjs.utc(timestamp).toISOString()
|
||||
datum[timestampKey] = iso
|
||||
return datum
|
||||
})
|
||||
}
|
||||
|
||||
if (timeseriesData.length <= 1 && !(min || max)) return timeseriesData
|
||||
if (timeseriesData.length <= 1 && !(min && max)) return timeseriesData
|
||||
const dates: unknown[] = timeseriesData.map((datum) => dayjs.utc(datum[timestampKey]))
|
||||
|
||||
const maxDate = max ? dayjs.utc(max) : dayjs.utc(Math.max.apply(null, dates as number[]))
|
||||
const minDate = min ? dayjs.utc(min) : dayjs.utc(Math.min.apply(null, dates as number[]))
|
||||
|
||||
// const truncationSample = timeseriesData.length > 0 ? timeseriesData[0][timestampKey] : min || max
|
||||
// When no data exists but min/max are provided, we need to determine truncation from the time range
|
||||
const truncationSamples = timeseriesData.length > 0 ? dates : [minDate, maxDate]
|
||||
let truncation: 'second' | 'minute' | 'hour' | 'day'
|
||||
let step = 1
|
||||
@@ -515,11 +518,21 @@ export const fillTimeseries = (
|
||||
truncation = getTimestampTruncation(truncationSamples as Dayjs[])
|
||||
}
|
||||
|
||||
// If no data exists and no interval specified, default to minute precision
|
||||
if (timeseriesData.length === 0 && !interval) {
|
||||
truncation = 'minute'
|
||||
}
|
||||
|
||||
const newData = timeseriesData.map((datum) => {
|
||||
const timestamp = datum[timestampKey]
|
||||
const iso = isUnixMicro(timestamp)
|
||||
? unixMicroToIsoTimestamp(timestamp)
|
||||
: dayjs.utc(timestamp).toISOString()
|
||||
|
||||
if (Array.isArray(valueKey) && valueKey.length === 0) {
|
||||
return { [timestampKey]: iso }
|
||||
}
|
||||
|
||||
datum[timestampKey] = iso
|
||||
return datum
|
||||
})
|
||||
@@ -533,7 +546,8 @@ export const fillTimeseries = (
|
||||
d_date.month() === currentDate.month() &&
|
||||
d_date.date() === currentDate.date() &&
|
||||
d_date.hour() === currentDate.hour() &&
|
||||
d_date.minute() === currentDate.minute()
|
||||
d_date.minute() === currentDate.minute() &&
|
||||
d_date.second() === currentDate.second()
|
||||
)
|
||||
})
|
||||
if (!found) {
|
||||
|
||||
@@ -56,22 +56,23 @@ const EdgeFunctionDetailsLayout = ({
|
||||
isError,
|
||||
} = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
|
||||
|
||||
const { data: functionFiles = [], error: filesError } = useEdgeFunctionBodyQuery(
|
||||
{
|
||||
projectRef: ref,
|
||||
slug: functionSlug,
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
retryOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
refetchIntervalInBackground: false,
|
||||
}
|
||||
)
|
||||
const { data: functionBody = { version: 0, files: [] }, error: filesError } =
|
||||
useEdgeFunctionBodyQuery(
|
||||
{
|
||||
projectRef: ref,
|
||||
slug: functionSlug,
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
retryOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
refetchIntervalInBackground: false,
|
||||
}
|
||||
)
|
||||
|
||||
const name = selectedFunction?.name || ''
|
||||
|
||||
@@ -112,7 +113,7 @@ const EdgeFunctionDetailsLayout = ({
|
||||
|
||||
const zipFileWriter = new BlobWriter('application/zip')
|
||||
const zipWriter = new ZipWriter(zipFileWriter, { bufferedWrite: true })
|
||||
functionFiles.forEach((file) => {
|
||||
functionBody.files.forEach((file) => {
|
||||
const nameSections = file.name.split('/')
|
||||
const slugIndex = nameSections.indexOf(functionSlug ?? '')
|
||||
const fileName = nameSections.slice(slugIndex + 1).join('/')
|
||||
|
||||
@@ -105,6 +105,9 @@ export function LogsSidebarMenuV2() {
|
||||
},
|
||||
{
|
||||
enabled: enablePgReplicate,
|
||||
retry: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ export const SQLEditorMenu = () => {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testId="sql-editor-new-query-button"
|
||||
data-testid="sql-editor-new-query-button"
|
||||
type="default"
|
||||
icon={<Plus className="text-foreground" />}
|
||||
className="w-[26px]"
|
||||
|
||||
@@ -53,7 +53,11 @@ export const SearchList = ({ search }: SearchListProps) => {
|
||||
)
|
||||
const totalNumber = (count as unknown as { count: number })?.count ?? 0
|
||||
|
||||
const snippets = useMemo(() => data?.pages.flatMap((page) => page.content), [data?.pages])
|
||||
const snippets = useMemo(
|
||||
// [Joshen] Set folder_id to null to ensure flat list
|
||||
() => data?.pages.flatMap((page) => page.content.map((x) => ({ ...x, folder_id: null }))),
|
||||
[data?.pages]
|
||||
)
|
||||
const treeState = formatFolderResponseForTreeView({ folders: [], contents: snippets as any })
|
||||
|
||||
const snippetsLastItemIds = useMemo(() => getLastItemIds(treeState), [treeState])
|
||||
@@ -117,6 +121,7 @@ export const SearchList = ({ search }: SearchListProps) => {
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
isBranch={false}
|
||||
isOpened={isOpened && !isPreview}
|
||||
isSelected={isActive}
|
||||
isPreview={isPreview}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function useTableEditorTabsCleanUp() {
|
||||
// e.g Using the SQL editor to rename the entity
|
||||
const openTabs = openTabsRef.current
|
||||
.map((id) => tabMapRef.current[id])
|
||||
.filter((tab) => editorEntityTypes['table']?.includes(tab.type))
|
||||
.filter((tab) => !!tab && editorEntityTypes['table']?.includes(tab.type))
|
||||
|
||||
openTabs.forEach((tab) => {
|
||||
const entity = entities?.find((x) => tab.metadata?.tableId === x.id)
|
||||
@@ -97,7 +97,7 @@ export function useSqlEditorTabsCleanup() {
|
||||
// e.g for a shared snippet, the owner could've updated the name of the snippet
|
||||
const openSqlTabs = openTabsRef.current
|
||||
.map((id) => tabMapRef.current[id])
|
||||
.filter((tab) => editorEntityTypes['sql']?.includes(tab.type))
|
||||
.filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type))
|
||||
|
||||
openSqlTabs.forEach((tab) => {
|
||||
const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { UIMessage as MessageType } from '@ai-sdk/react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowDown, Eraser, Info, Pencil, Settings, X } from 'lucide-react'
|
||||
import { Eraser, Info, Pencil, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
|
||||
import { useParams, useSearchParamsShallow } from 'common/hooks'
|
||||
@@ -23,65 +23,21 @@ import uuidv4 from 'lib/uuid'
|
||||
import type { AssistantMessageType } from 'state/ai-assistant-state'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import { AiIconAnimation, Button, cn, KeyboardShortcut } from 'ui'
|
||||
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
|
||||
import { Button, cn, KeyboardShortcut } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { ButtonTooltip } from '../ButtonTooltip'
|
||||
import { ErrorBoundary } from '../ErrorBoundary'
|
||||
import { type SqlSnippet } from './AIAssistant.types'
|
||||
import { onErrorChat } from './AIAssistant.utils'
|
||||
import { AIAssistantChatSelector } from './AIAssistantChatSelector'
|
||||
import { AIAssistantHeader } from './AIAssistantHeader'
|
||||
import { AIOnboarding } from './AIOnboarding'
|
||||
import { AIOptInModal } from './AIOptInModal'
|
||||
import { AssistantChatForm } from './AssistantChatForm'
|
||||
import { Message } from './Message'
|
||||
import { useAutoScroll } from './hooks'
|
||||
|
||||
const MemoizedMessage = memo(
|
||||
({
|
||||
message,
|
||||
isLoading,
|
||||
onResults,
|
||||
onDelete,
|
||||
onEdit,
|
||||
isAfterEditedMessage,
|
||||
isBeingEdited,
|
||||
onCancelEdit,
|
||||
}: {
|
||||
message: MessageType
|
||||
isLoading: boolean
|
||||
onResults: ({
|
||||
messageId,
|
||||
resultId,
|
||||
results,
|
||||
}: {
|
||||
messageId: string
|
||||
resultId?: string
|
||||
results: any[]
|
||||
}) => void
|
||||
onDelete: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
isAfterEditedMessage: boolean
|
||||
isBeingEdited: boolean
|
||||
onCancelEdit: () => void
|
||||
}) => {
|
||||
return (
|
||||
<Message
|
||||
id={message.id}
|
||||
message={message}
|
||||
readOnly={message.role === 'user'}
|
||||
isLoading={isLoading}
|
||||
onResults={onResults}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
isAfterEditedMessage={isAfterEditedMessage}
|
||||
isBeingEdited={isBeingEdited}
|
||||
onCancelEdit={onCancelEdit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MemoizedMessage.displayName = 'MemoizedMessage'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from './elements/Conversation'
|
||||
import { MemoizedMessage } from './Message'
|
||||
|
||||
interface AIAssistantProps {
|
||||
initialMessages?: MessageType[] | undefined
|
||||
@@ -107,7 +63,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
)
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { ref: scrollContainerRef, isSticky, scrollToEnd, setIsSticky } = useAutoScroll()
|
||||
|
||||
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
|
||||
const showMetadataWarning =
|
||||
@@ -119,7 +74,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
const lastUserMessageRef = useRef<MessageType | null>(null)
|
||||
|
||||
const [value, setValue] = useState<string>(snap.initialInput || '')
|
||||
const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [isResubmitting, setIsResubmitting] = useState(false)
|
||||
|
||||
@@ -130,7 +84,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
const snippet = snippets[entityId ?? '']
|
||||
const snippetContent = snippet?.snippet?.content?.sql
|
||||
|
||||
const { data: tables, isLoading: isLoadingTables } = useTablesQuery(
|
||||
const { data: tables } = useTablesQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
@@ -235,7 +189,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
onFinish: handleChatFinish,
|
||||
})
|
||||
|
||||
const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming' || isResubmitting
|
||||
const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming'
|
||||
|
||||
const updateMessage = useCallback(
|
||||
({
|
||||
@@ -316,7 +270,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
<MemoizedMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLoading={isChatLoading && message.id === chatMessages[chatMessages.length - 1].id}
|
||||
status={chatStatus}
|
||||
onResults={updateMessage}
|
||||
onDelete={deleteMessageFromHere}
|
||||
onEdit={editMessage}
|
||||
@@ -328,12 +282,12 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
}),
|
||||
[
|
||||
chatMessages,
|
||||
isChatLoading,
|
||||
updateMessage,
|
||||
deleteMessageFromHere,
|
||||
editMessage,
|
||||
cancelEdit,
|
||||
editingMessageId,
|
||||
chatStatus,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -395,17 +349,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
}
|
||||
}, [isResubmitting, chatStatus, error])
|
||||
|
||||
// Update scroll behavior for new messages
|
||||
useEffect(() => {
|
||||
if (!isChatLoading) {
|
||||
if (inputRef.current) inputRef.current.focus()
|
||||
}
|
||||
|
||||
if (isSticky) {
|
||||
setTimeout(scrollToEnd, 0)
|
||||
}
|
||||
}, [isChatLoading, isSticky, scrollToEnd])
|
||||
|
||||
useEffect(() => {
|
||||
setValue(snap.initialInput || '')
|
||||
if (inputRef.current && snap.initialInput) {
|
||||
@@ -421,10 +364,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [snap.open, isInSQLEditor, snippetContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatMessages.length === 0 && !isSticky) setIsSticky(true)
|
||||
}, [chatMessages, isSticky, setIsSticky])
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
message="Something went wrong with the AI Assistant"
|
||||
@@ -445,102 +384,19 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
]}
|
||||
>
|
||||
<div className={cn('flex flex-col h-full', className)}>
|
||||
<div ref={scrollContainerRef} className={cn('flex-grow overflow-auto flex flex-col')}>
|
||||
<div className="z-30 sticky top-0">
|
||||
<div className="border-b border-b-muted flex items-center bg gap-x-4 px-3 h-[46px]">
|
||||
<div className="text-sm flex-1 flex items-center">
|
||||
<AiIconAnimation size={20} allowHoverEffect={false} />
|
||||
<span className="text-border-stronger dark:text-border-strong ml-3">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16 3.549L7.12 20.600"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<AIAssistantChatSelector disabled={isChatLoading} />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex items-center">
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<Settings strokeWidth={1.5} />}
|
||||
onClick={() => setIsConfirmOptInModalOpen(true)}
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isChatLoading}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: 'Permission settings',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<Eraser strokeWidth={1.5} />}
|
||||
onClick={handleClearMessages}
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isChatLoading}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Clear messages' } }}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="w-7 h-7"
|
||||
onClick={snap.closeAssistant}
|
||||
icon={<X strokeWidth={1.5} />}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Close assistant' } }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showMetadataWarning && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title={
|
||||
!updatedOptInSinceMCP
|
||||
? 'The Assistant has just been updated to help you better!'
|
||||
: isHipaaProjectDisallowed
|
||||
? 'Project metadata is not shared due to HIPAA'
|
||||
: aiOptInLevel === 'disabled'
|
||||
? 'Project metadata is currently not shared'
|
||||
: 'Limited metadata is shared to the Assistant'
|
||||
}
|
||||
description={
|
||||
!updatedOptInSinceMCP
|
||||
? 'You may now opt-in to share schema metadata and even logs for better results'
|
||||
: isHipaaProjectDisallowed
|
||||
? 'Your organization has the HIPAA addon and will not send project metadata with your prompts for projects marked as HIPAA.'
|
||||
: aiOptInLevel === 'disabled'
|
||||
? 'The Assistant can provide better answers if you opt-in to share schema metadata.'
|
||||
: aiOptInLevel === 'schema'
|
||||
? 'Sharing query data in addition to schema can further improve responses. Update AI settings to enable this.'
|
||||
: ''
|
||||
}
|
||||
className="border-0 border-b rounded-none bg-background mb-0"
|
||||
>
|
||||
{!isHipaaProjectDisallowed && (
|
||||
<Button
|
||||
type="default"
|
||||
className="w-fit mt-4"
|
||||
onClick={() => setIsConfirmOptInModalOpen(true)}
|
||||
>
|
||||
Permission settings
|
||||
</Button>
|
||||
)}
|
||||
</Admonition>
|
||||
)}
|
||||
</div>
|
||||
{hasMessages ? (
|
||||
<div className="w-full px-7 py-8 mb-10">
|
||||
<Conversation className={cn('flex-1')}>
|
||||
<AIAssistantHeader
|
||||
isChatLoading={isChatLoading}
|
||||
onClearMessages={handleClearMessages}
|
||||
onCloseAssistant={snap.closeAssistant}
|
||||
showMetadataWarning={showMetadataWarning}
|
||||
updatedOptInSinceMCP={updatedOptInSinceMCP}
|
||||
isHipaaProjectDisallowed={isHipaaProjectDisallowed as boolean}
|
||||
aiOptInLevel={aiOptInLevel}
|
||||
/>
|
||||
|
||||
{hasMessages && (
|
||||
<ConversationContent className="w-full px-7 py-8 mb-10">
|
||||
{renderedMessages}
|
||||
{error && (
|
||||
<div className="border rounded-md px-2 py-2 flex items-center justify-between gap-x-4">
|
||||
@@ -575,65 +431,18 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{isChatLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex gap-4 w-auto overflow-hidden"
|
||||
>
|
||||
<div className="text-foreground-lighter text-sm flex gap-1.5 items-center">
|
||||
<span>Thinking</span>
|
||||
<div className="flex gap-1">
|
||||
<motion.span
|
||||
animate={{ opacity: [0, 1, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, delay: 0 }}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
<motion.span
|
||||
animate={{ opacity: [0, 1, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, delay: 0.3 }}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
<motion.span
|
||||
animate={{ opacity: [0, 1, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, delay: 0.6 }}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : isLoadingTables && isApiKeySet ? (
|
||||
<div className="w-full h-full flex-1 flex flex-col justify-center items-center p-5">
|
||||
<GenericSkeletonLoader className="w-4/5 flex flex-col items-center" />
|
||||
</div>
|
||||
) : isShowingOnboarding ? (
|
||||
<AIOnboarding
|
||||
onMessageSend={sendMessageToAssistant}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
|
||||
onRemoveSnippet={(index) => {
|
||||
const newSnippets = [...(snap.sqlSnippets ?? [])]
|
||||
newSnippets.splice(index, 1)
|
||||
snap.setSqlSnippets(newSnippets)
|
||||
}}
|
||||
suggestions={
|
||||
snap.suggestions as
|
||||
| { title?: string; prompts?: { label: string; description: string }[] }
|
||||
| undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{isChatLoading && (
|
||||
<motion.span
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
className="inline-block w-1.5 h-4 bg-foreground-lighter"
|
||||
/>
|
||||
)}
|
||||
</ConversationContent>
|
||||
)}
|
||||
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
<AnimatePresence>
|
||||
{editingMessageId && (
|
||||
@@ -677,111 +486,83 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{!isSticky && !editingMessageId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="pointer-events-none z-10 -mt-24"
|
||||
>
|
||||
<div className="h-24 w-full bg-gradient-to-t from-background to-transparent relative">
|
||||
<motion.div
|
||||
className="absolute z-20 bottom-8 left-1/2 -translate-x-1/2 pointer-events-auto"
|
||||
variants={{
|
||||
hidden: { y: 5, opacity: 0 },
|
||||
show: { y: 0, opacity: 1 },
|
||||
}}
|
||||
transition={{ duration: 0.1 }}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
exit="hidden"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
className="rounded-full w-8 h-8 p-1.5"
|
||||
onClick={() => {
|
||||
scrollToEnd()
|
||||
if (inputRef.current) inputRef.current.focus()
|
||||
}}
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!isShowingOnboarding && (
|
||||
<div className="px-3 pb-3 z-20 relative">
|
||||
{disablePrompts && (
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="default"
|
||||
title="Assistant has been temporarily disabled"
|
||||
description="We're currently looking into getting it back online"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSuccess && !isApiKeySet && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="OpenAI API key not set"
|
||||
description={
|
||||
<Markdown
|
||||
content={
|
||||
'Add your `OPENAI_API_KEY` to your environment variables to use the AI Assistant.'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AssistantChatForm
|
||||
textAreaRef={inputRef}
|
||||
className={cn(
|
||||
'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0'
|
||||
)}
|
||||
loading={isChatLoading}
|
||||
isEditing={!!editingMessageId}
|
||||
disabled={!isApiKeySet || disablePrompts || (isChatLoading && !editingMessageId)}
|
||||
placeholder={
|
||||
hasMessages
|
||||
? 'Ask a follow up question...'
|
||||
: (snap.sqlSnippets ?? [])?.length > 0
|
||||
? 'Ask a question or make a change...'
|
||||
: 'Chat to Postgres...'
|
||||
}
|
||||
value={value}
|
||||
onValueChange={(e) => setValue(e.target.value)}
|
||||
onSubmit={(finalMessage) => {
|
||||
sendMessageToAssistant(finalMessage)
|
||||
scrollToEnd()
|
||||
}}
|
||||
onStop={() => {
|
||||
stop()
|
||||
// to save partial responses from the AI
|
||||
const lastMessage = chatMessages[chatMessages.length - 1]
|
||||
if (lastMessage && lastMessage.role === 'assistant') {
|
||||
handleChatFinish({ message: lastMessage })
|
||||
}
|
||||
}}
|
||||
sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
|
||||
onRemoveSnippet={(index) => {
|
||||
const newSnippets = [...(snap.sqlSnippets ?? [])]
|
||||
newSnippets.splice(index, 1)
|
||||
snap.setSqlSnippets(newSnippets)
|
||||
}}
|
||||
includeSnippetsInMessage={aiOptInLevel !== 'disabled'}
|
||||
<div className="px-3 pb-3 z-20 relative">
|
||||
{disablePrompts && (
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="default"
|
||||
title="Assistant has been temporarily disabled"
|
||||
description="We're currently looking into getting it back online"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AIOptInModal
|
||||
visible={isConfirmOptInModalOpen}
|
||||
onCancel={() => setIsConfirmOptInModalOpen(false)}
|
||||
/>
|
||||
{isSuccess && !isApiKeySet && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="OpenAI API key not set"
|
||||
description={
|
||||
<Markdown
|
||||
content={
|
||||
'Add your `OPENAI_API_KEY` to your environment variables to use the AI Assistant.'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isShowingOnboarding && (
|
||||
<AIOnboarding
|
||||
sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
|
||||
suggestions={
|
||||
snap.suggestions as
|
||||
| { title?: string; prompts?: { label: string; description: string }[] }
|
||||
| undefined
|
||||
}
|
||||
onValueChange={(val) => setValue(val)}
|
||||
onFocusInput={() => inputRef.current?.focus()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AssistantChatForm
|
||||
textAreaRef={inputRef}
|
||||
className={cn(
|
||||
'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0'
|
||||
)}
|
||||
loading={isChatLoading}
|
||||
isEditing={!!editingMessageId}
|
||||
disabled={!isApiKeySet || disablePrompts || (isChatLoading && !editingMessageId)}
|
||||
placeholder={
|
||||
hasMessages
|
||||
? 'Ask a follow up question...'
|
||||
: (snap.sqlSnippets ?? [])?.length > 0
|
||||
? 'Ask a question or make a change...'
|
||||
: 'Chat to Postgres...'
|
||||
}
|
||||
value={value}
|
||||
onValueChange={(e) => setValue(e.target.value)}
|
||||
onSubmit={(finalMessage) => {
|
||||
sendMessageToAssistant(finalMessage)
|
||||
}}
|
||||
onStop={() => {
|
||||
stop()
|
||||
// to save partial responses from the AI
|
||||
const lastMessage = chatMessages[chatMessages.length - 1]
|
||||
if (lastMessage && lastMessage.role === 'assistant') {
|
||||
handleChatFinish({ message: lastMessage })
|
||||
}
|
||||
}}
|
||||
sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
|
||||
onRemoveSnippet={(index) => {
|
||||
const newSnippets = [...(snap.sqlSnippets ?? [])]
|
||||
newSnippets.splice(index, 1)
|
||||
snap.setSqlSnippets(newSnippets)
|
||||
}}
|
||||
includeSnippetsInMessage={aiOptInLevel !== 'disabled'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Eraser, Settings, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AiIconAnimation, Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { ButtonTooltip } from '../ButtonTooltip'
|
||||
import { AIAssistantChatSelector } from './AIAssistantChatSelector'
|
||||
import { AIOptInModal } from './AIOptInModal'
|
||||
|
||||
interface AIAssistantHeaderProps {
|
||||
isChatLoading: boolean
|
||||
onClearMessages: () => void
|
||||
onCloseAssistant: () => void
|
||||
showMetadataWarning: boolean
|
||||
updatedOptInSinceMCP: boolean
|
||||
isHipaaProjectDisallowed: boolean
|
||||
aiOptInLevel: 'disabled' | 'schema' | 'full' | string | undefined
|
||||
}
|
||||
|
||||
export const AIAssistantHeader = ({
|
||||
isChatLoading,
|
||||
onClearMessages,
|
||||
onCloseAssistant,
|
||||
showMetadataWarning,
|
||||
updatedOptInSinceMCP,
|
||||
isHipaaProjectDisallowed,
|
||||
aiOptInLevel,
|
||||
}: AIAssistantHeaderProps) => {
|
||||
const [isOptInModalOpen, setIsOptInModalOpen] = useState(false)
|
||||
return (
|
||||
<div className="z-30 sticky top-0">
|
||||
<div className="border-b border-b-muted flex items-center bg gap-x-4 px-3 h-[46px]">
|
||||
<div className="text-sm flex-1 flex items-center">
|
||||
<AiIconAnimation size={20} allowHoverEffect={false} />
|
||||
<span className="text-border-stronger dark:text-border-strong ml-3">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16 3.549L7.12 20.600" />
|
||||
</svg>
|
||||
</span>
|
||||
<AIAssistantChatSelector disabled={isChatLoading} />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex items-center">
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<Settings strokeWidth={1.5} />}
|
||||
onClick={() => setIsOptInModalOpen(true)}
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isChatLoading}
|
||||
tooltip={{
|
||||
content: { side: 'bottom', text: 'Permission settings' },
|
||||
}}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<Eraser strokeWidth={1.5} />}
|
||||
onClick={onClearMessages}
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isChatLoading}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Clear messages' } }}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="w-7 h-7"
|
||||
onClick={onCloseAssistant}
|
||||
icon={<X strokeWidth={1.5} />}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Close assistant' } }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showMetadataWarning && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title={
|
||||
!updatedOptInSinceMCP
|
||||
? 'The Assistant has just been updated to help you better!'
|
||||
: isHipaaProjectDisallowed
|
||||
? 'Project metadata is not shared due to HIPAA'
|
||||
: aiOptInLevel === 'disabled'
|
||||
? 'Project metadata is currently not shared'
|
||||
: 'Limited metadata is shared to the Assistant'
|
||||
}
|
||||
description={
|
||||
!updatedOptInSinceMCP
|
||||
? 'You may now opt-in to share schema metadata and even logs for better results'
|
||||
: isHipaaProjectDisallowed
|
||||
? 'Your organization has the HIPAA addon and will not send project metadata with your prompts for projects marked as HIPAA.'
|
||||
: aiOptInLevel === 'disabled'
|
||||
? 'The Assistant can provide better answers if you opt-in to share schema metadata.'
|
||||
: aiOptInLevel === 'schema'
|
||||
? 'Sharing query data in addition to schema can further improve responses. Update AI settings to enable this.'
|
||||
: ''
|
||||
}
|
||||
className="border-0 border-b rounded-none bg-background mb-0"
|
||||
>
|
||||
{!isHipaaProjectDisallowed && (
|
||||
<Button type="default" className="w-fit mt-4" onClick={() => setIsOptInModalOpen(true)}>
|
||||
Permission settings
|
||||
</Button>
|
||||
)}
|
||||
</Admonition>
|
||||
)}
|
||||
<AIOptInModal visible={isOptInModalOpen} onCancel={() => setIsOptInModalOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,26 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { Button, cn } from 'ui'
|
||||
import { AssistantChatForm } from './AssistantChatForm'
|
||||
import { Button } from 'ui'
|
||||
import { type SqlSnippet } from './AIAssistant.types'
|
||||
import { codeSnippetPrompts, defaultPrompts } from './AIAssistant.prompts'
|
||||
|
||||
interface AIOnboardingProps {
|
||||
onMessageSend: (message: string) => void
|
||||
sqlSnippets?: SqlSnippet[]
|
||||
onRemoveSnippet?: (index: number) => void
|
||||
suggestions?: {
|
||||
title?: string
|
||||
prompts?: { label: string; description: string }[]
|
||||
}
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
onFocusInput?: () => void
|
||||
}
|
||||
|
||||
export const AIOnboarding = ({
|
||||
onMessageSend,
|
||||
sqlSnippets,
|
||||
onRemoveSnippet,
|
||||
suggestions,
|
||||
value,
|
||||
onValueChange,
|
||||
onFocusInput,
|
||||
}: AIOnboardingProps) => {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const prompts = suggestions?.prompts
|
||||
? suggestions.prompts.map((suggestion) => ({
|
||||
title: suggestion.label,
|
||||
@@ -40,64 +32,36 @@ export const AIOnboarding = ({
|
||||
: defaultPrompts
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
{/* Header Section */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||
<motion.div
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center max-w-xl w-full overflow-hiddenflex flex-col items-center"
|
||||
>
|
||||
<h2 className="text-2xl mb-6">How can I assist you?</h2>
|
||||
|
||||
<div className="w-full mb-6">
|
||||
<AssistantChatForm
|
||||
textAreaRef={inputRef}
|
||||
className={cn(
|
||||
'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0'
|
||||
)}
|
||||
loading={false}
|
||||
disabled={false}
|
||||
placeholder="Ask me anything..."
|
||||
value={value}
|
||||
onValueChange={(e) => onValueChange(e.target.value)}
|
||||
onSubmit={(finalMessage) => {
|
||||
if (finalMessage.trim()) {
|
||||
onMessageSend(finalMessage)
|
||||
onValueChange('')
|
||||
}
|
||||
<div className="w-full mb-6">
|
||||
<div className="px-4 mb-4">
|
||||
<h2 className="heading-section text-foreground mb-1">How can I assist you?</h2>
|
||||
<p className="text-foreground-light text-sm">
|
||||
Generate SQL, RLS policies and edge functions, debug issues or check on your project
|
||||
health.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{prompts.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ y: 5, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="w-full justify-start"
|
||||
icon={<FileText strokeWidth={1.5} size={14} className="text-foreground-light" />}
|
||||
onClick={() => {
|
||||
onValueChange(item.prompt)
|
||||
onFocusInput?.()
|
||||
}}
|
||||
sqlSnippets={sqlSnippets}
|
||||
onRemoveSnippet={onRemoveSnippet}
|
||||
snippetsClassName="text-left"
|
||||
includeSnippetsInMessage={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{prompts.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ y: 5, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="outline"
|
||||
className="text-xs rounded-full !h-auto py-1 px-2 text-foreground-light"
|
||||
onClick={() => {
|
||||
onValueChange(item.prompt)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
>
|
||||
{item.title}
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UIMessage as VercelMessage } from '@ai-sdk/react'
|
||||
import { Loader2, Pencil, Trash2 } from 'lucide-react'
|
||||
import { createContext, PropsWithChildren, ReactNode, useMemo, useState } from 'react'
|
||||
import { CheckIcon, Loader2, Pencil, Trash2 } from 'lucide-react'
|
||||
import { createContext, memo, PropsWithChildren, ReactNode, useMemo, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Components } from 'react-markdown/lib/ast-to-react'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
@@ -11,8 +11,8 @@ import { useProfile } from 'lib/profile'
|
||||
import { cn, markdownComponents, WarningIcon } from 'ui'
|
||||
import { ButtonTooltip } from '../ButtonTooltip'
|
||||
import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock'
|
||||
import { DisplayBlockRenderer } from './DisplayBlockRenderer'
|
||||
import { DeleteMessageConfirmModal } from './DeleteMessageConfirmModal'
|
||||
import { DisplayBlockRenderer } from './DisplayBlockRenderer'
|
||||
import {
|
||||
Heading3,
|
||||
Hyperlink,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
MarkdownPre,
|
||||
OrderedList,
|
||||
} from './MessageMarkdown'
|
||||
import { Reasoning } from './elements/Reasoning'
|
||||
|
||||
interface MessageContextType {
|
||||
isLoading: boolean
|
||||
@@ -42,6 +43,7 @@ interface MessageProps {
|
||||
message: VercelMessage
|
||||
isLoading: boolean
|
||||
readOnly?: boolean
|
||||
status?: string
|
||||
action?: ReactNode
|
||||
variant?: 'default' | 'warning'
|
||||
onResults: ({
|
||||
@@ -60,7 +62,7 @@ interface MessageProps {
|
||||
onCancelEdit: () => void
|
||||
}
|
||||
|
||||
export const Message = function Message({
|
||||
const Message = function Message({
|
||||
id,
|
||||
message,
|
||||
isLoading,
|
||||
@@ -72,6 +74,7 @@ export const Message = function Message({
|
||||
onEdit,
|
||||
isAfterEditedMessage = false,
|
||||
isBeingEdited = false,
|
||||
status,
|
||||
onCancelEdit,
|
||||
}: PropsWithChildren<MessageProps>) {
|
||||
const { profile } = useProfile()
|
||||
@@ -125,23 +128,59 @@ export const Message = function Message({
|
||||
<ProfileImage
|
||||
alt={profile?.username}
|
||||
src={profile?.profileImageUrl}
|
||||
className="w-5 h-5 shrink-0 rounded-full"
|
||||
className="w-5 h-5 shrink-0 rounded-full translate-y-0.5"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 [&>div:first-child]:!mt-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
{shouldUsePartsRendering ? (
|
||||
(() => {
|
||||
const shownLoadingTools = new Set<string>()
|
||||
return parts.map(
|
||||
(part: NonNullable<VercelMessage['parts']>[number], index: number) => {
|
||||
switch (part.type) {
|
||||
case 'dynamic-tool': {
|
||||
return (
|
||||
<div
|
||||
key={`${id}-tool-${part.toolCallId}`}
|
||||
className={cn(
|
||||
'border rounded-md border-muted heading-meta flex items-center gap-2 text-foreground-lighter py-2 px-3 dynamic-tool-item',
|
||||
'[&:not(.dynamic-tool-item+.dynamic-tool-item)]:mt-4 [&.dynamic-tool-item+.dynamic-tool-item]:mt-1 first:!mt-0',
|
||||
'[&:not(:has(+.dynamic-tool-item))]:mb-4'
|
||||
)}
|
||||
>
|
||||
{part.state === 'input-streaming' ? (
|
||||
<Loader2 strokeWidth={1.5} size={12} className="animate-spin" />
|
||||
) : (
|
||||
<CheckIcon
|
||||
strokeWidth={1.5}
|
||||
size={12}
|
||||
className="text-foreground-muted"
|
||||
/>
|
||||
)}
|
||||
{`${part.toolName}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'reasoning':
|
||||
return (
|
||||
<Reasoning
|
||||
key={`${message.id}-${index}}`}
|
||||
className={cn(
|
||||
'w-full dynamic-tool-item',
|
||||
'[&:not(.dynamic-tool-item+.dynamic-tool-item)]:mt-4 [&.dynamic-tool-item+.dynamic-tool-item]:mt-1 first:!mt-0',
|
||||
'[&:not(:has(+.dynamic-tool-item))]:mb-4'
|
||||
)}
|
||||
isStreaming={part.state === 'streaming'}
|
||||
>
|
||||
{part.text}
|
||||
</Reasoning>
|
||||
)
|
||||
case 'text':
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={`${id}-part-${index}`}
|
||||
className={cn(
|
||||
'prose prose-sm [&>div]:my-4 prose-h1:text-xl prose-h1:mt-6 prose-h3:no-underline prose-h3:text-base prose-h3:mb-4 prose-strong:font-medium prose-strong:text-foreground break-words [&>p:not(:last-child)]:!mb-2 [&>*>p:first-child]:!mt-0 [&>*>p:last-child]:!mb-0 [&>*>*>p:first-child]:!mt-0 [&>*>*>p:last-child]:!mb-0 [&>ol>li]:!pl-4',
|
||||
'max-w-none prose prose-sm [&>div]:my-4 prose-h1:text-xl prose-h1:mt-6 prose-h2:text-lg prose-h3:no-underline prose-h3:text-base prose-h3:mb-4 prose-strong:font-medium prose-strong:text-foreground prose-ol:space-y-3 prose-ul:space-y-3 prose-li:my-0 break-words [&>p:not(:last-child)]:!mb-2 [&>*>p:first-child]:!mt-0 [&>*>p:last-child]:!mb-0 [&>*>*>p:first-child]:!mt-0 [&>*>*>p:last-child]:!mb-0 [&>ol>li]:!pl-4',
|
||||
isUser && 'text-foreground [&>p]:font-medium',
|
||||
isBeingEdited && 'animate-pulse'
|
||||
)}
|
||||
@@ -155,10 +194,6 @@ export const Message = function Message({
|
||||
case 'tool-display_query': {
|
||||
const { toolCallId, state, input } = part
|
||||
if (state === 'input-streaming' || state === 'input-available') {
|
||||
if (shownLoadingTools.has('display_query')) {
|
||||
return null
|
||||
}
|
||||
shownLoadingTools.add('display_query')
|
||||
return (
|
||||
<div
|
||||
key={`${id}-tool-loading-display_query`}
|
||||
@@ -188,10 +223,6 @@ export const Message = function Message({
|
||||
case 'tool-display_edge_function': {
|
||||
const { toolCallId, state, input } = part
|
||||
if (state === 'input-streaming' || state === 'input-available') {
|
||||
if (shownLoadingTools.has('display_edge_function')) {
|
||||
return null
|
||||
}
|
||||
shownLoadingTools.add('display_edge_function')
|
||||
return (
|
||||
<div
|
||||
key={`${id}-tool-loading-display_edge_function`}
|
||||
@@ -206,7 +237,7 @@ export const Message = function Message({
|
||||
return (
|
||||
<div
|
||||
key={`${id}-tool-${toolCallId}`}
|
||||
className="w-auto overflow-x-hidden"
|
||||
className="w-auto overflow-x-hidden my-4"
|
||||
>
|
||||
<EdgeFunctionBlock
|
||||
label={(input as any).name || 'Edge Function'}
|
||||
@@ -219,7 +250,6 @@ export const Message = function Message({
|
||||
}
|
||||
return null
|
||||
}
|
||||
case 'reasoning':
|
||||
case 'source-url':
|
||||
case 'source-document':
|
||||
case 'file':
|
||||
@@ -242,8 +272,8 @@ export const Message = function Message({
|
||||
<span className="text-foreground-lighter italic">Assistant is thinking...</span>
|
||||
)}
|
||||
|
||||
{/* Action buttons - only show for user messages on hover */}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||
{/* Action button - only show for user messages on hover */}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity mt-1 mb-2">
|
||||
{message.role === 'user' && (
|
||||
<>
|
||||
<ButtonTooltip
|
||||
@@ -293,3 +323,51 @@ export const Message = function Message({
|
||||
</MessageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const MemoizedMessage = memo(
|
||||
({
|
||||
message,
|
||||
status,
|
||||
onResults,
|
||||
onDelete,
|
||||
onEdit,
|
||||
isAfterEditedMessage,
|
||||
isBeingEdited,
|
||||
onCancelEdit,
|
||||
}: {
|
||||
message: VercelMessage
|
||||
status: string
|
||||
onResults: ({
|
||||
messageId,
|
||||
resultId,
|
||||
results,
|
||||
}: {
|
||||
messageId: string
|
||||
resultId?: string
|
||||
results: any[]
|
||||
}) => void
|
||||
onDelete: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
isAfterEditedMessage: boolean
|
||||
isBeingEdited: boolean
|
||||
onCancelEdit: () => void
|
||||
}) => {
|
||||
return (
|
||||
<Message
|
||||
id={message.id}
|
||||
message={message}
|
||||
readOnly={message.role === 'user'}
|
||||
isLoading={status === 'submitted' || status === 'streaming'}
|
||||
status={status}
|
||||
onResults={onResults}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
isAfterEditedMessage={isAfterEditedMessage}
|
||||
isBeingEdited={isBeingEdited}
|
||||
onCancelEdit={onCancelEdit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MemoizedMessage.displayName = 'MemoizedMessage'
|
||||
@@ -305,7 +305,7 @@ export const MarkdownPre = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-auto overflow-x-hidden not-prose">
|
||||
<div className="w-auto overflow-x-hidden not-prose my-4 ">
|
||||
{language === 'edge' ? (
|
||||
<EdgeFunctionBlock
|
||||
label={title}
|
||||
@@ -346,7 +346,7 @@ export const MarkdownPre = ({
|
||||
value={cleanContent}
|
||||
language={language as CodeBlockLang}
|
||||
className={cn(
|
||||
'max-h-96 max-w-none block border rounded !bg-transparent !py-3 !px-3.5 prose dark:prose-dark text-foreground',
|
||||
'my-4 max-h-96 max-w-none block border rounded !bg-transparent !py-3 !px-3.5 prose dark:prose-dark text-foreground',
|
||||
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block [&>code>span]:text-foreground'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ArrowDownIcon } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
type ConversationProps = ComponentProps<typeof StickToBottom>
|
||||
type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>
|
||||
type ConversationScrollButtonProps = ComponentProps<typeof Button>
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
||||
<StickToBottom.Content className={cn('p-4', className)} {...props} />
|
||||
)
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom()
|
||||
}, [scrollToBottom])
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn('absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full', className)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="tiny"
|
||||
type="default"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BrainIcon, ChevronDownIcon, Loader2 } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { memo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import {
|
||||
cn,
|
||||
Collapsible,
|
||||
CollapsibleContent_Shadcn_ as CollapsibleContent,
|
||||
CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
|
||||
} from 'ui'
|
||||
|
||||
type ReasoningProps = Omit<ComponentProps<typeof Collapsible>, 'children'> & {
|
||||
isStreaming?: boolean
|
||||
children: string
|
||||
}
|
||||
|
||||
export const Reasoning = memo(({ className, isStreaming, children, ...props }: ReasoningProps) => (
|
||||
<Collapsible
|
||||
className={cn('not-prose border rounded-md border-muted', className)}
|
||||
defaultOpen={false}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-foreground-lighter heading-meta px-3 py-2 w-full'
|
||||
)}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
<Loader2 strokeWidth={1.5} size={12} className="animate-spin" />
|
||||
<p>Thinking...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BrainIcon strokeWidth={1.5} size={12} className="text-foreground-muted" />
|
||||
<p>Reasoned</p>
|
||||
</>
|
||||
)}
|
||||
<ChevronDownIcon strokeWidth={1.5} size={12} className="text-foreground-muted" />
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent
|
||||
className={cn('p-5 pt-2 text-xs leading-normal', 'max-h-64 overflow-y-auto')}
|
||||
>
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))
|
||||
|
||||
Reasoning.displayName = 'Reasoning'
|
||||
@@ -4,7 +4,7 @@ import { Area, AreaChart as RechartAreaChart, Tooltip, XAxis } from 'recharts'
|
||||
import { useChartHoverState } from './useChartHoverState'
|
||||
|
||||
import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants'
|
||||
import ChartHeader from './ChartHeader'
|
||||
import { ChartHeader } from './ChartHeader'
|
||||
import type { CommonChartProps, Datum } from './Charts.types'
|
||||
import { numberFormatter, useChartSize } from './Charts.utils'
|
||||
import NoDataPlaceholder from './NoDataPlaceholder'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { ComponentProps, useState, useMemo } from 'react'
|
||||
import { useChartHoverState } from './useChartHoverState'
|
||||
import { ComponentProps, useMemo, useState } from 'react'
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
@@ -14,10 +13,11 @@ import {
|
||||
|
||||
import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants'
|
||||
import type { CategoricalChartState } from 'recharts/types/chart/types'
|
||||
import ChartHeader from './ChartHeader'
|
||||
import { ChartHeader } from './ChartHeader'
|
||||
import type { CommonChartProps, Datum } from './Charts.types'
|
||||
import { numberFormatter, useChartSize } from './Charts.utils'
|
||||
import NoDataPlaceholder from './NoDataPlaceholder'
|
||||
import { useChartHoverState } from './useChartHoverState'
|
||||
|
||||
export interface BarChartProps<D = Datum> extends CommonChartProps<D> {
|
||||
yAxisKey: string
|
||||
@@ -57,10 +57,9 @@ const BarChart = ({
|
||||
showGrid = false,
|
||||
syncId,
|
||||
}: BarChartProps) => {
|
||||
const { hoveredIndex, isHovered, isCurrentChart, setHover, clearHover } =
|
||||
useChartHoverState('default')
|
||||
const { Container } = useChartSize(size)
|
||||
const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState(
|
||||
syncId || 'default'
|
||||
)
|
||||
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
|
||||
|
||||
// Transform data to ensure yAxisKey values are numbers
|
||||
@@ -177,7 +176,7 @@ const BarChart = ({
|
||||
/>
|
||||
<Tooltip
|
||||
content={(props) =>
|
||||
syncId && syncTooltip && hoveredIndex !== null ? (
|
||||
syncId && isHovered && isCurrentChart && hoveredIndex !== null ? (
|
||||
<div className="bg-black/90 text-white p-2 rounded text-xs">
|
||||
<div className="font-medium">
|
||||
{dayjs(data[hoveredIndex]?.[xAxisKey]).format(customDateFormat)}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Activity, BarChartIcon, GitCommitHorizontalIcon, InfoIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Activity,
|
||||
BarChartIcon,
|
||||
GitCommitHorizontalIcon,
|
||||
InfoIcon,
|
||||
SquareTerminal,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { formatBytes } from 'lib/helpers'
|
||||
import { useChartHoverState } from './useChartHoverState'
|
||||
import { numberFormatter } from './Charts.utils'
|
||||
import { useChartHoverState } from './useChartHoverState'
|
||||
|
||||
export interface ChartHeaderProps {
|
||||
title?: string
|
||||
@@ -31,9 +40,10 @@ export interface ChartHeaderProps {
|
||||
shouldFormatBytes?: boolean
|
||||
isNetworkChart?: boolean
|
||||
attributes?: any[]
|
||||
sql?: string
|
||||
}
|
||||
|
||||
const ChartHeader = ({
|
||||
export const ChartHeader = ({
|
||||
format,
|
||||
highlightedValue,
|
||||
highlightedLabel,
|
||||
@@ -57,16 +67,25 @@ const ChartHeader = ({
|
||||
shouldFormatBytes = false,
|
||||
isNetworkChart = false,
|
||||
attributes,
|
||||
sql,
|
||||
}: ChartHeaderProps) => {
|
||||
const { hoveredIndex, syncHover } = useChartHoverState(syncId || 'default')
|
||||
const { hoveredIndex, isHovered, isCurrentChart, setHover, clearHover } = useChartHoverState(
|
||||
syncId || 'default'
|
||||
)
|
||||
const [localHighlightedValue, setLocalHighlightedValue] = useState(highlightedValue)
|
||||
const [localHighlightedLabel, setLocalHighlightedLabel] = useState(highlightedLabel)
|
||||
const { ref } = useParams()
|
||||
const router = useRouter()
|
||||
|
||||
const formatHighlightedValue = (value: any) => {
|
||||
if (typeof value !== 'number') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof format === 'function') {
|
||||
return format(value)
|
||||
}
|
||||
|
||||
if (shouldFormatBytes) {
|
||||
const bytesValue = isNetworkChart ? Math.abs(value) : value
|
||||
return formatBytes(bytesValue, valuePrecision)
|
||||
@@ -76,7 +95,7 @@ const ChartHeader = ({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (syncId && hoveredIndex !== null && syncHover && data && xAxisKey && yAxisKey) {
|
||||
if (syncId && hoveredIndex !== null && isHovered && data && xAxisKey && yAxisKey) {
|
||||
const activeDataPoint = data[hoveredIndex]
|
||||
if (activeDataPoint) {
|
||||
// For stacked charts, we need to calculate the total of all attributes
|
||||
@@ -128,7 +147,7 @@ const ChartHeader = ({
|
||||
}
|
||||
}, [
|
||||
hoveredIndex,
|
||||
syncHover,
|
||||
isHovered,
|
||||
syncId,
|
||||
data,
|
||||
xAxisKey,
|
||||
@@ -147,20 +166,21 @@ const ChartHeader = ({
|
||||
{title}
|
||||
</h3>
|
||||
{docsUrl && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
className="text-foreground-lighter hover:text-foreground-light"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
Read docs
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="px-1"
|
||||
asChild
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'top',
|
||||
text: 'Read docs',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link href={docsUrl} target="_blank">
|
||||
<InfoIcon className="w-4 h-4 text-foreground-lighter" />
|
||||
</Link>
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -170,10 +190,6 @@ const ChartHeader = ({
|
||||
className={`text-foreground text-xl font-normal ${minimalHeader ? 'text-base' : 'text-2xl'}`}
|
||||
>
|
||||
{localHighlightedValue !== undefined && formatHighlightedValue(localHighlightedValue)}
|
||||
{format === 'seconds' ? ' ' : ''}
|
||||
<span className="text-lg">
|
||||
{typeof format === 'function' ? format(localHighlightedValue) : format}
|
||||
</span>
|
||||
</h4>
|
||||
)
|
||||
const label = <h4 className="text-foreground-lighter text-xs">{localHighlightedLabel}</h4>
|
||||
@@ -202,43 +218,57 @@ const ChartHeader = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{sql ? (
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
className="px-1.5"
|
||||
asChild
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'top',
|
||||
text: 'Open in Log Explorer',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link href={`/project/${ref}/logs/explorer?q=${encodeURIComponent(sql)}`}>
|
||||
<SquareTerminal className="w-4 h-4 text-foreground-lighter" />
|
||||
</Link>
|
||||
</ButtonTooltip>
|
||||
) : null}
|
||||
|
||||
{!hideChartType && onChartStyleChange && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1.5"
|
||||
icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
|
||||
onClick={() => onChartStyleChange(chartStyle === 'bar' ? 'line' : 'bar')}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
View as {chartStyle === 'bar' ? 'line chart' : 'bar chart'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
className="px-1.5"
|
||||
icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
|
||||
onClick={() => onChartStyleChange(chartStyle === 'bar' ? 'line' : 'bar')}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'top',
|
||||
text: `View as ${chartStyle === 'bar' ? 'line chart' : 'bar chart'}`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{setShowMaxValue && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type={showMaxValue ? 'default' : 'dashed'}
|
||||
className="px-1.5"
|
||||
icon={
|
||||
<GitCommitHorizontalIcon
|
||||
className={showMaxValue ? 'text-foreground-light' : 'text-foreground-lighter'}
|
||||
/>
|
||||
}
|
||||
onClick={() => setShowMaxValue(!showMaxValue)}
|
||||
<ButtonTooltip
|
||||
type={showMaxValue ? 'default' : 'dashed'}
|
||||
className="px-1.5"
|
||||
icon={
|
||||
<GitCommitHorizontalIcon
|
||||
className={showMaxValue ? 'text-foreground-light' : 'text-foreground-lighter'}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
{showMaxValue ? 'Hide' : 'Show'} limit
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
onClick={() => setShowMaxValue(!showMaxValue)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'top',
|
||||
text: `${showMaxValue ? 'Hide' : 'Show'} limit`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChartHeader
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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<D = Datum> extends CommonChartProps<D> {
|
||||
attributes: MultiAttribute[]
|
||||
@@ -64,9 +63,10 @@ export interface ComposedChartProps<D = Datum> extends CommonChartProps<D> {
|
||||
hideHighlightedValue?: boolean
|
||||
syncId?: string
|
||||
docsUrl?: string
|
||||
sql?: string
|
||||
}
|
||||
|
||||
export default function ComposedChart({
|
||||
export function ComposedChart({
|
||||
data,
|
||||
attributes,
|
||||
yAxisKey,
|
||||
@@ -100,6 +100,7 @@ export default function ComposedChart({
|
||||
hideHighlightedValue,
|
||||
syncId,
|
||||
docsUrl,
|
||||
sql,
|
||||
}: ComposedChartProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState(
|
||||
@@ -325,6 +326,7 @@ export default function ComposedChart({
|
||||
shouldFormatBytes={shouldFormatBytes}
|
||||
isNetworkChart={isNetworkChart}
|
||||
attributes={attributes}
|
||||
sql={sql}
|
||||
/>
|
||||
<Container className="relative z-10">
|
||||
<RechartComposedChart
|
||||
|
||||
@@ -37,7 +37,7 @@ export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-
|
||||
|
||||
export type MultiAttribute = {
|
||||
attribute: string
|
||||
provider: Provider
|
||||
provider?: Provider
|
||||
label?: string
|
||||
color?: {
|
||||
light: string
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from '
|
||||
import { cn, WarningIcon } from 'ui'
|
||||
|
||||
import Panel from 'components/ui/Panel'
|
||||
import ComposedChart from './ComposedChart'
|
||||
import { ComposedChart } from './ComposedChart'
|
||||
|
||||
import { AnalyticsInterval, DataPoint } from 'data/analytics/constants'
|
||||
import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries'
|
||||
@@ -353,7 +353,7 @@ const useAttributeQueries = (
|
||||
return [...infraQueries, ...dailyStatsQueries, ...referenceLineQueries]
|
||||
}
|
||||
|
||||
export default function LazyComposedChartHandler(props: ComposedChartHandlerProps) {
|
||||
export function LazyComposedChartHandler(props: ComposedChartHandlerProps) {
|
||||
if (props.hide) return null
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Loader2 } from 'lucide-react'
|
||||
import { cn, WarningIcon } from 'ui'
|
||||
|
||||
import Panel from 'components/ui/Panel'
|
||||
import ComposedChart from './ComposedChart'
|
||||
import { ComposedChart } from './ComposedChart'
|
||||
|
||||
import { AnalyticsInterval, DataPoint } from 'data/analytics/constants'
|
||||
import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query'
|
||||
@@ -40,6 +40,7 @@ interface LogChartHandlerProps {
|
||||
isVisible?: boolean
|
||||
titleTooltip?: string
|
||||
docsUrl?: string
|
||||
syncId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +88,7 @@ const LazyChartWrapper = ({ children }: PropsWithChildren) => {
|
||||
*
|
||||
* 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<LogChartHandlerProps>) => {
|
||||
const [chartStyle, setChartStyle] = useState<string>(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}
|
||||
/>
|
||||
</Panel.Content>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<any> {
|
||||
xAxisKey: string
|
||||
|
||||
@@ -15,6 +15,9 @@ interface PanelProps {
|
||||
titleClasses?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Card component from ui package instead
|
||||
*/
|
||||
function Panel(props: PropsWithChildren<PanelProps>) {
|
||||
const content = (
|
||||
<div
|
||||
|
||||
@@ -44,6 +44,7 @@ const PasswordStrengthBar = ({
|
||||
>
|
||||
Generate a password
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { analyticsKeys } from './keys'
|
||||
export type FunctionsReqStatsVariables = {
|
||||
projectRef?: string
|
||||
functionId?: string
|
||||
interval?: operations['FunctionRequestLogsController_getStatus']['parameters']['query']['interval']
|
||||
interval?: operations['FunctionsLogsController_getRequestStats']['parameters']['query']['interval']
|
||||
}
|
||||
|
||||
export type FunctionsReqStatsResponse = any
|
||||
|
||||
@@ -6,7 +6,7 @@ import { analyticsKeys } from './keys'
|
||||
export type FunctionsResourceUsageVariables = {
|
||||
projectRef?: string
|
||||
functionId?: string
|
||||
interval?: operations['FunctionResourceLogsController_getStatus']['parameters']['query']['interval']
|
||||
interval?: operations['FunctionsLogsController_getRequestStats']['parameters']['query']['interval']
|
||||
}
|
||||
|
||||
export type FunctionsResourceUsageResponse = any
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function deleteContents(
|
||||
headers: { Version: '2' },
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
query: { ids },
|
||||
query: { ids: ids.join(',') },
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export type LintRuleDeleteVariables = {
|
||||
|
||||
export async function deleteLintRule({ projectRef, ids }: LintRuleDeleteVariables) {
|
||||
const { data, error } = await del('/platform/projects/{ref}/notifications/advisor/exceptions', {
|
||||
params: { path: { ref: projectRef }, query: { ids } },
|
||||
params: { path: { ref: projectRef }, query: { ids: ids.join(',') } },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
@@ -81,17 +81,20 @@ export function setProjectStatus(
|
||||
projectRef: Project['ref'],
|
||||
status: Project['status']
|
||||
) {
|
||||
client.setQueriesData<Project[] | undefined>(
|
||||
client.setQueriesData<PaginatedProjectsResponse | undefined>(
|
||||
projectKeys.list(),
|
||||
(old) => {
|
||||
if (!old) return old
|
||||
|
||||
return old.map((project) => {
|
||||
if (project.ref === projectRef) {
|
||||
return { ...project, status }
|
||||
}
|
||||
return project
|
||||
})
|
||||
return {
|
||||
...old,
|
||||
projects: old.projects.map((project) => {
|
||||
if (project.ref === projectRef) {
|
||||
return { ...project, status }
|
||||
}
|
||||
return project
|
||||
}),
|
||||
}
|
||||
},
|
||||
{ updatedAt: Date.now() }
|
||||
)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { replicationKeys } from './keys'
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
|
||||
export type BigQueryDestinationConfig = {
|
||||
projectId: string
|
||||
datasetId: string
|
||||
serviceAccountKey: string
|
||||
maxStalenessMins: number
|
||||
maxStalenessMins?: number
|
||||
}
|
||||
|
||||
export type CreateDestinationPipelineParams = {
|
||||
@@ -21,7 +21,7 @@ export type CreateDestinationPipelineParams = {
|
||||
sourceId: number
|
||||
pipelineConfig: {
|
||||
publicationName: string
|
||||
batch: {
|
||||
batch?: {
|
||||
maxSize: number
|
||||
maxFillMs: number
|
||||
}
|
||||
@@ -35,10 +35,7 @@ async function createDestinationPipeline(
|
||||
destinationConfig: {
|
||||
bigQuery: { projectId, datasetId, serviceAccountKey, maxStalenessMins },
|
||||
},
|
||||
pipelineConfig: {
|
||||
publicationName,
|
||||
batch: { maxSize, maxFillMs },
|
||||
},
|
||||
pipelineConfig: { publicationName, batch },
|
||||
sourceId,
|
||||
}: CreateDestinationPipelineParams,
|
||||
signal?: AbortSignal
|
||||
@@ -48,23 +45,27 @@ async function createDestinationPipeline(
|
||||
const { data, error } = await post('/platform/replication/{ref}/destinations-pipelines', {
|
||||
params: { path: { ref: projectRef } },
|
||||
body: {
|
||||
source_id: sourceId,
|
||||
destination_name: destinationName,
|
||||
destination_config: {
|
||||
big_query: {
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
service_account_key: serviceAccountKey,
|
||||
max_staleness_mins: maxStalenessMins,
|
||||
...(maxStalenessMins != null && { max_staleness_mins: maxStalenessMins }),
|
||||
},
|
||||
},
|
||||
pipeline_config: {
|
||||
publication_name: publicationName,
|
||||
batch: {
|
||||
max_size: maxSize,
|
||||
max_fill_ms: maxFillMs,
|
||||
},
|
||||
...(batch
|
||||
? {
|
||||
batch: {
|
||||
max_size: batch.maxSize,
|
||||
max_fill_ms: batch.maxFillMs,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
source_id: sourceId,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
@@ -92,8 +93,12 @@ export const useCreateDestinationPipelineMutation = ({
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries(replicationKeys.destinations(projectRef))
|
||||
await queryClient.invalidateQueries(replicationKeys.pipelines(projectRef))
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(replicationKeys.destinations(projectRef)),
|
||||
queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)),
|
||||
])
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { del, handleError } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { replicationKeys } from './keys'
|
||||
import { handleError, del } from 'data/fetchers'
|
||||
|
||||
export type DeleteDestinationPipelineParams = {
|
||||
projectRef: string
|
||||
@@ -47,8 +47,19 @@ export const useDeleteDestinationPipelineMutation = ({
|
||||
(vars) => deleteDestinationPipeline(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries(replicationKeys.destinations(projectRef))
|
||||
const { projectRef, destinationId, pipelineId } = variables
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(replicationKeys.destinations(projectRef)),
|
||||
queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)),
|
||||
queryClient.invalidateQueries(replicationKeys.pipelineById(projectRef, pipelineId)),
|
||||
queryClient.invalidateQueries(replicationKeys.pipelinesStatus(projectRef, pipelineId)),
|
||||
queryClient.invalidateQueries(
|
||||
replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId)
|
||||
),
|
||||
queryClient.invalidateQueries(replicationKeys.destinationById(projectRef, destinationId)),
|
||||
])
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { replicationKeys } from './keys'
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
|
||||
export type BigQueryDestinationConfig = {
|
||||
projectId: string
|
||||
datasetId: string
|
||||
serviceAccountKey: string
|
||||
maxStalenessMins: number
|
||||
maxStalenessMins?: number
|
||||
}
|
||||
|
||||
export type UpdateDestinationPipelineParams = {
|
||||
@@ -23,7 +23,7 @@ export type UpdateDestinationPipelineParams = {
|
||||
sourceId: number
|
||||
pipelineConfig: {
|
||||
publicationName: string
|
||||
batch: {
|
||||
batch?: {
|
||||
maxSize: number
|
||||
maxFillMs: number
|
||||
}
|
||||
@@ -39,10 +39,7 @@ async function updateDestinationPipeline(
|
||||
destinationConfig: {
|
||||
bigQuery: { projectId, datasetId, serviceAccountKey, maxStalenessMins },
|
||||
},
|
||||
pipelineConfig: {
|
||||
publicationName,
|
||||
batch: { maxSize, maxFillMs },
|
||||
},
|
||||
pipelineConfig: { publicationName, batch },
|
||||
sourceId,
|
||||
}: UpdateDestinationPipelineParams,
|
||||
signal?: AbortSignal
|
||||
@@ -60,15 +57,17 @@ async function updateDestinationPipeline(
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
service_account_key: serviceAccountKey,
|
||||
max_staleness_mins: maxStalenessMins,
|
||||
...(maxStalenessMins != null && { max_staleness_mins: maxStalenessMins }),
|
||||
},
|
||||
},
|
||||
pipeline_config: {
|
||||
publication_name: publicationName,
|
||||
batch: {
|
||||
max_size: maxSize,
|
||||
max_fill_ms: maxFillMs,
|
||||
},
|
||||
...(batch && {
|
||||
batch: {
|
||||
max_size: batch.maxSize,
|
||||
max_fill_ms: batch.maxFillMs,
|
||||
},
|
||||
}),
|
||||
},
|
||||
source_id: sourceId,
|
||||
},
|
||||
@@ -99,8 +98,12 @@ export const useUpdateDestinationPipelineMutation = ({
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries(replicationKeys.destinations(projectRef))
|
||||
await queryClient.invalidateQueries(replicationKeys.pipelines(projectRef))
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(replicationKeys.destinations(projectRef)),
|
||||
queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)),
|
||||
])
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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<string>(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<string>(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<string>(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<string>(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<string>(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,
|
||||
}
|
||||
}
|
||||
@@ -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, (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(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<string>()
|
||||
|
||||
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<string, any>, 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<string, any>, 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<string, any>, 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 }
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -7,5 +7,7 @@
|
||||
|
||||
"logs:default_query": null,
|
||||
|
||||
"connect:frameworks": null
|
||||
"connect:frameworks": null,
|
||||
|
||||
"infra:cloud_providers": ["AWS", "AWS_K8S", "FLY"]
|
||||
}
|
||||
@@ -72,5 +72,7 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"infra:cloud_providers": ["AWS_NIMBUS"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -28,16 +28,16 @@ const useCustomContent = <T extends CustomContent[]>(
|
||||
contents: T
|
||||
): {
|
||||
[key in CustomContentToCamelCase<T[number]>]:
|
||||
| (typeof customContentStaticObj)[CustomContent]
|
||||
| CustomContentTypes[CustomContentToCamelCase<T[number]>]
|
||||
| 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<T[number]>]:
|
||||
| (typeof customContentStaticObj)[CustomContent]
|
||||
| CustomContentTypes[CustomContentToCamelCase<T[number]>]
|
||||
| null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 3223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user