mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
Merge branch 'master' into dnywh/chore/key-value-field-array
# Conflicts: # apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx
This commit is contained in:
commit
474ea1a3cb
441 files changed
+15543
-18544
No files matched your search
@@ -14,9 +14,9 @@ reviewing PRs that touch tracking or when implementing new tracking.
|
||||
|
||||
**Format:** `[object]_[verb]` in snake_case
|
||||
|
||||
**Approved verbs only:**
|
||||
opened, clicked, submitted, created, removed, updated, retrieved, intended, evaluated, added,
|
||||
enabled, disabled, copied, exposed, failed, converted
|
||||
**Approved verbs only** (canonical list — derived from `packages/common/telemetry-constants.ts`):
|
||||
opened, clicked, submitted, created, removed, updated, intended, evaluated, added,
|
||||
enabled, disabled, copied, exposed, failed, converted, closed, completed, applied, sent, moved
|
||||
|
||||
**Flag these:**
|
||||
- Unapproved verbs (saved, viewed, seen, pressed, etc.)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copilot Code Review Instructions
|
||||
|
||||
## Repo Context
|
||||
|
||||
This is a TypeScript/Next.js/React monorepo:
|
||||
|
||||
- `apps/studio/` — Supabase Dashboard (primary review target)
|
||||
- `apps/www/` — Marketing site
|
||||
- `apps/docs/` — Documentation
|
||||
- `packages/common/` — Shared code including telemetry definitions
|
||||
|
||||
## Topic-Specific Guidelines
|
||||
|
||||
Detailed review rules are in path-specific instruction files under `.github/instructions/`:
|
||||
|
||||
- **Telemetry**: `studio-telemetry.instructions.md` — event naming, property conventions, feature flag measurement
|
||||
- **Testing**: `studio-testing.instructions.md` — test strategy, extraction patterns, coverage expectations
|
||||
|
||||
These files are scoped to `apps/studio/` and applied automatically by Copilot during reviews.
|
||||
|
||||
## References
|
||||
|
||||
For the full, authoritative versions of these standards:
|
||||
|
||||
- Telemetry: `.claude/skills/telemetry-standards/SKILL.md`
|
||||
- Testing: `.claude/skills/studio-testing/SKILL.md`
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
applyTo: "apps/studio/**,packages/common/telemetry*"
|
||||
---
|
||||
|
||||
# Studio Telemetry Review Rules
|
||||
|
||||
All comments are **advisory** — suggest, do not request changes.
|
||||
|
||||
## When to Flag Missing Telemetry
|
||||
|
||||
Use judgment — not every PR needs telemetry. But **always flag** when:
|
||||
|
||||
1. **Changes to `packages/common/telemetry-constants.ts`** — validate event naming, property conventions, and JSDoc accuracy.
|
||||
2. **PostHog feature flags without measurement.** If a PR uses `usePHFlag` or PostHog-backed hooks like `useDataApiGrantTogglesEnabled` to gate behavior, the flag state should be captured in a telemetry event so the rollout can be measured. Flag if the flag value isn't included in a relevant `track()` call. (Note: `useFlag` from `common` reads ConfigCat flags, not PostHog — different system, different guidance.)
|
||||
3. **Feature-flagged rollouts without outcome tracking.** If a flag gates new behavior, there should be telemetry on both the flag state *and* how users respond to the new behavior (e.g., toggle clicks, opt-in actions).
|
||||
4. **Growth-oriented components adding user interactions without tracking** — onboarding flows, setup wizards, upgrade CTAs, A/B experiment variants.
|
||||
|
||||
When tracking is missing, comment: _"This adds a user interaction (or feature flag) that may benefit from tracking."_ Then propose an event name and `useTrack()` call.
|
||||
|
||||
## Feature Flag Telemetry Pattern
|
||||
|
||||
When capturing a PostHog flag value for telemetry, read the raw flag via `usePHFlag('flagName')` — **not** through wrapper hooks that coerce `undefined` to `false`. Use conditional spread so the property is omitted (not false) when the flag store hasn't loaded:
|
||||
|
||||
```typescript
|
||||
const flagValue = usePHFlag<boolean>('myBooleanFlag') // for boolean flags
|
||||
track('event_name', {
|
||||
...(flagValue !== undefined && { myFlagEnabled: flagValue }),
|
||||
})
|
||||
```
|
||||
|
||||
For string-valued flags (e.g., experiment variants), use `usePHFlag<string>('flagName')` instead.
|
||||
|
||||
## Event Naming
|
||||
|
||||
Format: `[object]_[verb]` in snake_case.
|
||||
|
||||
Prefer verbs already in use in `packages/common/telemetry-constants.ts`: `opened`, `clicked`, `submitted`, `created`, `removed`, `updated`, `intended`, `evaluated`, `added`, `enabled`, `disabled`, `copied`, `exposed`, `failed`, `converted`, `closed`, `completed`, `applied`, `sent`, `moved`.
|
||||
|
||||
Flag: unapproved verbs (`saved`, `viewed`, `pressed`), wrong order (`click_product_card`), wrong casing (`productCardClicked`), passive view tracking on page load (exception: `_exposed` events for A/B experiments).
|
||||
|
||||
## Event Properties
|
||||
|
||||
- **camelCase** for new events; match existing convention when extending
|
||||
- Self-explanatory names — flag generic (`label`, `value`, `name`, `data`)
|
||||
- Check `telemetry-constants.ts` for consistency with similar events
|
||||
- Never track PII
|
||||
|
||||
## Event Implementation
|
||||
|
||||
- Use `useTrack` from `lib/telemetry/track` — avoid introducing new `useSendEventMutation` usage
|
||||
- New events need a TypeScript interface in `telemetry-constants.ts` with `@group Events` and `@source` JSDoc tags (add `@page` when applicable for page-specific events), added to the `TelemetryEvent` union
|
||||
|
||||
```typescript
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
const track = useTrack()
|
||||
track('product_card_clicked', { productType: 'database', planTier: 'pro' })
|
||||
```
|
||||
|
||||
Canonical standards: `.claude/skills/telemetry-standards/SKILL.md`
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
applyTo: "apps/studio/**"
|
||||
---
|
||||
|
||||
# Studio Testing Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Push logic out of React components into pure `.utils.ts` functions, then test those functions exhaustively. Only use component tests for complex UI interactions.
|
||||
|
||||
## When to Comment
|
||||
|
||||
- PR adds **business logic inline in a component** that could be extracted to a `ComponentName.utils.ts` file next to the component and unit tested at `tests/components/.../ComponentName.utils.test.ts`
|
||||
- PR adds a **utility function without test coverage**
|
||||
- PR uses **component tests for pure logic** that should be a unit test on a pure function
|
||||
- PR adds a **feature used in both self-hosted and platform** without E2E test consideration
|
||||
|
||||
## Which Test Type to Suggest
|
||||
|
||||
- **Pure transformation** (parse, format, validate, compute) → extract to `.utils.ts` + unit test with vitest
|
||||
- **Complex UI interaction** → component test with `customRender` (or E2E if shared with self-hosted)
|
||||
- **E2E tests** should cover both click interactions AND keyboard shortcuts
|
||||
- **No tests at all** for non-trivial changes → nudge to add coverage
|
||||
|
||||
## Reference
|
||||
|
||||
See `.claude/skills/studio-testing/SKILL.md` for the full testing standard.
|
||||
@@ -22,8 +22,10 @@ jobs:
|
||||
with:
|
||||
sparse-checkout: |
|
||||
apps
|
||||
packages
|
||||
blocks
|
||||
examples
|
||||
i18n
|
||||
packages
|
||||
patches
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
@@ -40,56 +42,3 @@ jobs:
|
||||
- name: Run prettier
|
||||
run: |-
|
||||
pnpm run test:prettier
|
||||
|
||||
# i18n is not a node package, so we handle that one separately
|
||||
format-i18n:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
sparse-checkout: |
|
||||
i18n
|
||||
patches
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
- name: Download dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
- name: Run prettier
|
||||
run: |-
|
||||
pnpm exec prettier -c 'i18n/**/*.{js,jsx,ts,tsx,css,md,mdx,json}'
|
||||
|
||||
format-sql:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
sparse-checkout: |
|
||||
apps/docs/pages
|
||||
apps/docs/content
|
||||
patches
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
- name: Download dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
- name: Run prettier
|
||||
run: |-
|
||||
# Check mdx files which contain sql code blocks
|
||||
grep -lr '```sql' apps/docs/{pages,content}/**/*.mdx | xargs pnpm exec prettier -c
|
||||
@@ -25,4 +25,4 @@ jobs:
|
||||
docker/
|
||||
- name: Run docker-compose up
|
||||
# Ensure all services can be started and healthy with default config
|
||||
run: cd docker && cp .env.example .env && docker compose up --wait
|
||||
run: cd docker && cp .env.example .env && docker compose up --wait --wait-timeout 180
|
||||
@@ -96,6 +96,7 @@ Implementation checklist:
|
||||
- Render a separate discard confirmation dialog when dirty.
|
||||
- Keep `Cancel` non-destructive; use `Discard`/`Discard changes` for one-click destructive exits.
|
||||
- Guard controlled close attempts only; do not try to block route changes or arbitrary unmounts.
|
||||
- If dismissal is route-driven or tied to page unload, use a navigation guard that composes the same discard-confirmation UI instead of extending the dialog/sheet close guard.
|
||||
|
||||
Studio implementation (preferred in Studio code):
|
||||
|
||||
|
||||
+28
-22
@@ -1,5 +1,5 @@
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { InternalServerError } from '@/lib/api/apiHelpers'
|
||||
import { getActiveIncidents, type IncidentCache } from '@/lib/api/incident-status'
|
||||
@@ -42,22 +42,26 @@ async function fetchIncidentCache(incidentIds: Array<string>): Promise<Map<strin
|
||||
return cacheMap
|
||||
}
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!IS_PLATFORM) {
|
||||
return res.status(404).end()
|
||||
}
|
||||
export async function OPTIONS() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
Allow: 'GET, HEAD, OPTIONS',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { method } = req
|
||||
export async function HEAD() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS },
|
||||
})
|
||||
}
|
||||
|
||||
if (method === 'HEAD') {
|
||||
res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS)
|
||||
return res.status(200).end()
|
||||
}
|
||||
|
||||
if (method !== 'GET') {
|
||||
res.setHeader('Allow', ['GET', 'HEAD'])
|
||||
return res.status(405).json({ error: `Method ${method} Not Allowed` })
|
||||
}
|
||||
export async function GET() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
|
||||
try {
|
||||
const allIncidents = await getActiveIncidents()
|
||||
@@ -75,17 +79,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
cache: cacheMap.get(incident.id) ?? null,
|
||||
}))
|
||||
|
||||
res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS)
|
||||
|
||||
return res.status(200).json(enrichedIncidents)
|
||||
return NextResponse.json(enrichedIncidents, {
|
||||
headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS },
|
||||
})
|
||||
} catch (error) {
|
||||
let errorCode = 500
|
||||
const headers = new Headers()
|
||||
|
||||
if (error instanceof InternalServerError) {
|
||||
if (typeof error.details?.status === 'number') errorCode = error.details.status
|
||||
if (errorCode === 420) errorCode = 429
|
||||
if (errorCode === 429 && typeof error.details?.retryAfter === 'string') {
|
||||
res.setHeader('Retry-After', error.details.retryAfter)
|
||||
headers.set('Retry-After', error.details.retryAfter)
|
||||
}
|
||||
console.error('Failed to fetch active StatusPage incidents: %O', {
|
||||
message: error.message,
|
||||
@@ -95,8 +100,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
console.error('Unexpected error fetching active StatusPage incidents: %O', error)
|
||||
}
|
||||
|
||||
return res.status(errorCode).json({ error: 'Unable to fetch incidents at this time' })
|
||||
return NextResponse.json(
|
||||
{ error: 'Unable to fetch incidents at this time' },
|
||||
{ status: errorCode, headers }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default handler
|
||||
@@ -242,8 +242,8 @@ export function useSyncTableEditorStateFromLocalStorageWithUrl({
|
||||
// Use nextjs useSearchParams to get the latest URL params
|
||||
const searchParams = useSearchParams()
|
||||
const urlParams = useMemo(() => {
|
||||
const sort = searchParams.getAll('sort')
|
||||
const filter = searchParams.getAll('filter')
|
||||
const sort = searchParams?.getAll('sort') ?? []
|
||||
const filter = searchParams?.getAll('filter') ?? []
|
||||
return { sort, filter }
|
||||
}, [searchParams])
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
|
||||
import { getForeignKeyCascadeAction } from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
|
||||
import type { XYCoord } from 'dnd-core'
|
||||
import { ArrowRight, Key, Lightbulb, Link, Lock } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useDrag, useDrop } from 'react-dnd'
|
||||
|
||||
import { getForeignKeyCascadeAction } from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import {
|
||||
useColumnHasIndexSuggestion,
|
||||
useTableIndexAdvisor,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { organizationKeys } from 'data/organizations/keys'
|
||||
@@ -9,9 +7,16 @@ import { useMfaChallengeAndVerifyMutation } from 'data/profile/mfa-challenge-and
|
||||
import { useMfaEnrollMutation } from 'data/profile/mfa-enroll-mutation'
|
||||
import { useMfaUnenrollMutation } from 'data/profile/mfa-unenroll-mutation'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { Input } from 'ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm, type SubmitHandler } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Input_Shadcn_ } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { z } from 'zod'
|
||||
|
||||
type TOTP = { qr_code: string; secret: string; uri: string }
|
||||
|
||||
interface AddNewFactorModalProps {
|
||||
visible: boolean
|
||||
@@ -19,32 +24,24 @@ interface AddNewFactorModalProps {
|
||||
}
|
||||
|
||||
export const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps) => {
|
||||
// Generate a name with a number between 0 and 1000
|
||||
const [name, setName] = useState(`App ${Math.floor(Math.random() * 1000)}`)
|
||||
const { data, mutate: enroll, isPending: isEnrolling, reset } = useMfaEnrollMutation()
|
||||
|
||||
useEffect(() => {
|
||||
// reset has to be called because the state is kept between if the process is canceled during
|
||||
// the second step.
|
||||
if (!visible) {
|
||||
setName(`App ${Math.floor(Math.random() * 1000)}`)
|
||||
reset()
|
||||
}
|
||||
if (!visible) reset()
|
||||
}, [reset, visible])
|
||||
|
||||
return (
|
||||
<>
|
||||
<FirstStep
|
||||
visible={visible && !Boolean(data)}
|
||||
name={name}
|
||||
setName={setName}
|
||||
enroll={enroll}
|
||||
isEnrolling={isEnrolling}
|
||||
enroll={enroll}
|
||||
reset={reset}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<SecondStep
|
||||
visible={visible && Boolean(data)}
|
||||
factorName={name}
|
||||
factorName={data?.friendly_name ?? ''}
|
||||
factor={data as Extract<typeof data, { type: 'totp' }>}
|
||||
isLoading={isEnrolling}
|
||||
onClose={onClose}
|
||||
@@ -55,14 +52,33 @@ export const AddNewFactorModal = ({ visible, onClose }: AddNewFactorModalProps)
|
||||
|
||||
interface FirstStepProps {
|
||||
visible: boolean
|
||||
name: string
|
||||
setName: Dispatch<SetStateAction<string>>
|
||||
enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void
|
||||
isEnrolling: boolean
|
||||
reset: () => void
|
||||
enroll: (params: { factorType: 'totp'; friendlyName?: string }) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FirstStep = ({ visible, name, enroll, setName, isEnrolling, onClose }: FirstStepProps) => {
|
||||
const FirstStep = ({ visible, isEnrolling, reset, enroll, onClose }: FirstStepProps) => {
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(1, 'Please provide a name to identify this app'),
|
||||
})
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: { name: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
|
||||
enroll({ factorType: 'totp', friendlyName: values.name })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
// Generate a name with a number between 0 and 1000
|
||||
form.reset({ name: `App ${Math.floor(Math.random() * 1000)}` })
|
||||
}
|
||||
}, [form, visible])
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
size="medium"
|
||||
@@ -70,22 +86,34 @@ const FirstStep = ({ visible, name, enroll, setName, isEnrolling, onClose }: Fir
|
||||
title="Add a new authenticator app as a factor"
|
||||
confirmLabel="Generate QR"
|
||||
confirmLabelLoading="Generating QR"
|
||||
disabled={name.length === 0}
|
||||
loading={isEnrolling}
|
||||
onCancel={onClose}
|
||||
onConfirm={() => {
|
||||
enroll({
|
||||
factorType: 'totp',
|
||||
friendlyName: name,
|
||||
})
|
||||
}}
|
||||
onConfirm={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<Input
|
||||
label="Provide a name to identify this app"
|
||||
descriptionText="A string will be randomly generated if a name is not provided"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id="verify-otp-form"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
key="name"
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="name"
|
||||
label="Provide a name to identify this app"
|
||||
description="A string will be randomly generated if a name is not provided"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ id="name" {...field} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</ConfirmationModal>
|
||||
)
|
||||
}
|
||||
@@ -96,11 +124,7 @@ interface SecondStepProps {
|
||||
factor?: {
|
||||
id: string
|
||||
type: 'totp'
|
||||
totp: {
|
||||
qr_code: string
|
||||
secret: string
|
||||
uri: string
|
||||
}
|
||||
totp: TOTP
|
||||
}
|
||||
isLoading: boolean
|
||||
onClose: () => void
|
||||
@@ -113,14 +137,23 @@ const SecondStep = ({
|
||||
isLoading,
|
||||
onClose,
|
||||
}: SecondStepProps) => {
|
||||
const [code, setCode] = useState('')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [lastVisitedOrganization] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
|
||||
''
|
||||
)
|
||||
|
||||
const FormSchema = z.object({
|
||||
code: z.string().min(1, 'Please provide a code from your authenticator app'),
|
||||
})
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: { code: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const [factor, setFactor] = useState<{ id: string; type: 'totp'; totp: TOTP } | null>(null)
|
||||
|
||||
const { mutate: unenroll } = useMfaUnenrollMutation({ onSuccess: () => onClose() })
|
||||
const { mutate: challengeAndVerify, isPending: isVerifying } = useMfaChallengeAndVerifyMutation({
|
||||
onError: (error) => {
|
||||
@@ -137,15 +170,10 @@ const SecondStep = ({
|
||||
},
|
||||
})
|
||||
|
||||
const [factor, setFactor] = useState<{
|
||||
id: string
|
||||
type: 'totp'
|
||||
totp: {
|
||||
qr_code: string
|
||||
secret: string
|
||||
uri: string
|
||||
}
|
||||
} | null>(null)
|
||||
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
|
||||
if (!factor) return toast.error('Factor required')
|
||||
challengeAndVerify({ factorId: factor.id, code: values.code })
|
||||
}
|
||||
|
||||
// this useEffect is to keep the factor until a new one comes. This is a fix to an issue which
|
||||
// happens when closing the modal, the outer factor is reset to null too soon and the modal
|
||||
@@ -153,6 +181,7 @@ const SecondStep = ({
|
||||
useEffect(() => {
|
||||
if (outerFactor && factor?.id !== outerFactor.id) {
|
||||
setFactor(outerFactor)
|
||||
form.reset({ code: '' })
|
||||
}
|
||||
}, [outerFactor])
|
||||
|
||||
@@ -160,6 +189,7 @@ const SecondStep = ({
|
||||
<ConfirmationModal
|
||||
size="medium"
|
||||
visible={visible}
|
||||
className="py-5"
|
||||
title={`Verify new factor ${factorName}`}
|
||||
confirmLabel="Confirm"
|
||||
confirmLabelLoading="Confirming"
|
||||
@@ -170,50 +200,68 @@ const SecondStep = ({
|
||||
// unenrolling.
|
||||
if (factor) unenroll({ factorId: factor.id })
|
||||
}}
|
||||
onConfirm={() => factor && challengeAndVerify({ factorId: factor.id, code })}
|
||||
onConfirm={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="py-4 pb-0 text-sm">
|
||||
<span>
|
||||
Use an authenticator app to scan the following QR code, and provide the code from the app
|
||||
to complete the enrolment.
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Use an authenticator app to scan the following QR code, and provide the code from the app to
|
||||
complete the enrolment.
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<div className="pb-4 px-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{factor && (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex justify-center py-6">
|
||||
<div className="h-48 w-48 bg-white rounded">
|
||||
<img width={190} height={190} src={factor.totp.qr_code} alt={factor.totp.uri} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<InformationBox
|
||||
title="Unable to scan?"
|
||||
description={
|
||||
<Input
|
||||
copy
|
||||
disabled
|
||||
id="ref"
|
||||
size="small"
|
||||
label="You can also enter this secret key into your authenticator app"
|
||||
value={factor.totp.secret}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<Input
|
||||
label="Authentication code"
|
||||
value={code}
|
||||
placeholder="XXXXXX"
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
<InformationBox
|
||||
title="Unable to scan?"
|
||||
description={
|
||||
<Input
|
||||
copy
|
||||
disabled
|
||||
id="ref"
|
||||
size="small"
|
||||
label="You can also enter this secret key into your authenticator app"
|
||||
value={factor.totp.secret}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id="verify-otp-form"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
key="code"
|
||||
name="code"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="code" label="Authentication code">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="code"
|
||||
autoFocus
|
||||
{...field}
|
||||
placeholder="XXXXXX"
|
||||
className="font-mono"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmationModal>
|
||||
)
|
||||
|
||||
@@ -65,10 +65,11 @@ export const FeaturePreviewModal = () => {
|
||||
const featurePreviewContext = useFeaturePreviewContext()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const [isDismissedTableFilterBar, setIsDismissedTableFilterBar] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.TABLE_EDITOR_NEW_FILTER_BANNER_DISMISSED(ref ?? ''),
|
||||
false
|
||||
)
|
||||
const [isDismissedTableQueueOperations, setIsDismissedTableQueueOperations] =
|
||||
useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.TABLE_EDITOR_QUEUE_OPERATIONS_BANNER_DISMISSED(ref ?? ''),
|
||||
false
|
||||
)
|
||||
|
||||
const { flags, onUpdateFlag } = featurePreviewContext
|
||||
const allFeaturePreviews = (
|
||||
@@ -90,10 +91,10 @@ export const FeaturePreviewModal = () => {
|
||||
})
|
||||
|
||||
if (
|
||||
selectedFeature.key === LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR &&
|
||||
!isDismissedTableFilterBar
|
||||
selectedFeature.key === LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS &&
|
||||
!isDismissedTableQueueOperations
|
||||
) {
|
||||
setIsDismissedTableFilterBar(true)
|
||||
setIsDismissedTableQueueOperations(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { FormSchema } from './EditHookPanel.constants'
|
||||
|
||||
describe('EditHookPanel FormSchema', () => {
|
||||
it('rejects incomplete http_request hostnames', () => {
|
||||
const result = FormSchema.safeParse({
|
||||
name: 'Test hook',
|
||||
table_id: 'public.messages',
|
||||
http_method: 'POST' as const,
|
||||
timeout_ms: 1000,
|
||||
events: ['INSERT'],
|
||||
httpHeaders: [],
|
||||
httpParameters: [],
|
||||
function_type: 'http_request' as const,
|
||||
http_url: 'https://webhook',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((issue) => issue.message === 'Please provide a valid URL')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects http_request URLs without an explicit protocol', () => {
|
||||
const result = FormSchema.safeParse({
|
||||
name: 'Test hook',
|
||||
table_id: 'public.messages',
|
||||
http_method: 'POST' as const,
|
||||
timeout_ms: 1000,
|
||||
events: ['INSERT'],
|
||||
httpHeaders: [],
|
||||
httpParameters: [],
|
||||
function_type: 'http_request' as const,
|
||||
http_url: 'hooks.example.com/webhook',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some(
|
||||
(issue) => issue.message === 'Please prefix your URL with http:// or https://'
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { isValidHttpUrl } from '@/lib/helpers'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
const httpRequestSchema = z.object({
|
||||
function_type: z.literal('http_request'),
|
||||
http_url: z
|
||||
.string()
|
||||
.min(1, 'Please provide a URL')
|
||||
.refine((val) => val.startsWith('http'), 'Please prefix your URL with http or https')
|
||||
.refine((val) => isValidHttpUrl(val), 'Please provide a valid URL'),
|
||||
http_url: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Please provide a URL',
|
||||
invalidMessage: 'Please provide a valid URL',
|
||||
prefixMessage: 'Please prefix your URL with http:// or https://',
|
||||
}),
|
||||
})
|
||||
|
||||
const supabaseFunctionSchema = z.object({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useParams } from 'common'
|
||||
import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants'
|
||||
import { createLintSummaryPrompt, EntityTypeIcon } from 'components/interfaces/Linter/Linter.utils'
|
||||
import { useQueryPerformanceQuery } from 'components/interfaces/Reports/Reports.queries'
|
||||
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
@@ -31,6 +30,8 @@ import {
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { useQueryPerformanceQuery } from '../QueryPerformance/useQueryPerformanceQuery'
|
||||
|
||||
interface SlowQuery {
|
||||
rolname: string
|
||||
mean_time: number
|
||||
@@ -43,9 +44,7 @@ export const AdvisorWidget = () => {
|
||||
const [selectedTab, setSelectedTab] = useState<'security' | 'performance'>('security')
|
||||
const { data: lints, isPending: isLoadingLints } = useProjectLintsQuery({ projectRef })
|
||||
const { data: slowestQueriesData, isLoading: isLoadingSlowestQueries } = useQueryPerformanceQuery(
|
||||
{
|
||||
preset: 'slowestExecutionTime',
|
||||
}
|
||||
{ preset: 'slowestExecutionTime' }
|
||||
)
|
||||
const snap = useAiAssistantStateSnapshot()
|
||||
const { openSidebar } = useSidebarManagerSnapshot()
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { FormSchema } from './CreateCronJobSheet.constants'
|
||||
|
||||
describe('CreateCronJobSheet FormSchema', () => {
|
||||
it('rejects incomplete http_request hostnames', () => {
|
||||
const result = FormSchema.safeParse({
|
||||
name: 'Send webhook',
|
||||
supportsSeconds: false,
|
||||
schedule: '* * * * *',
|
||||
values: {
|
||||
type: 'http_request' as const,
|
||||
method: 'POST' as const,
|
||||
endpoint: 'https://webhook',
|
||||
timeoutMs: 1000,
|
||||
httpHeaders: [],
|
||||
snippet: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((issue) => issue.message === 'Please provide a valid URL')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects http_request URLs without an explicit protocol', () => {
|
||||
const result = FormSchema.safeParse({
|
||||
name: 'Send webhook',
|
||||
supportsSeconds: false,
|
||||
schedule: '* * * * *',
|
||||
values: {
|
||||
type: 'http_request' as const,
|
||||
method: 'POST' as const,
|
||||
endpoint: 'hooks.example.com/webhook',
|
||||
timeoutMs: 1000,
|
||||
httpHeaders: [],
|
||||
snippet: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some(
|
||||
(issue) => issue.message === 'Please prefix your URL with http:// or https://'
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
+6
-7
@@ -1,8 +1,8 @@
|
||||
import { toString as CronToString } from 'cronstrue'
|
||||
import z from 'zod'
|
||||
|
||||
import { urlRegex } from 'components/interfaces/Auth/Auth.constants'
|
||||
import { cronPattern, secondsPattern } from '../CronJobs.constants'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
const convertCronToString = (schedule: string) => {
|
||||
// pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the
|
||||
@@ -40,12 +40,11 @@ const edgeFunctionSchema = z.object({
|
||||
const httpRequestSchema = z.object({
|
||||
type: z.literal('http_request'),
|
||||
method: z.enum(['GET', 'POST']),
|
||||
endpoint: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Please provide a URL')
|
||||
.regex(urlRegex(), 'Please provide a valid URL')
|
||||
.refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'),
|
||||
endpoint: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Please provide a URL',
|
||||
invalidMessage: 'Please provide a valid URL',
|
||||
prefixMessage: 'Please prefix your URL with http:// or https://',
|
||||
}),
|
||||
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
|
||||
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
|
||||
httpBody: z
|
||||
|
||||
@@ -46,12 +46,21 @@ export type HTTPHeader = { name: string; value: string }
|
||||
|
||||
export type HTTPParameter = { name: string; value: string }
|
||||
|
||||
export const CRON_TABLE_COLUMNS = [
|
||||
{ id: 'jobname', name: 'Name', minWidth: 0, width: 200 },
|
||||
{ id: 'schedule', name: 'Schedule', width: 100 },
|
||||
{ id: 'latest_run', name: 'Last run', width: 265 },
|
||||
{ id: 'next_run', name: 'Next run', minWidth: 180 },
|
||||
{ id: 'command', name: 'Command', minWidth: 320 },
|
||||
{ id: 'active', name: 'Active', width: 70, minWidth: 70, maxWidth: 70 },
|
||||
{ id: 'actions', name: '', minWidth: 75, width: 75 },
|
||||
type CronTableColumn = {
|
||||
id: string
|
||||
name: string
|
||||
width?: number
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
resizable?: boolean
|
||||
}
|
||||
|
||||
export const CRON_TABLE_COLUMNS: CronTableColumn[] = [
|
||||
{ id: 'jobname', name: 'Name', minWidth: 160, width: 200, resizable: true },
|
||||
{ id: 'schedule', name: 'Schedule', width: 100, resizable: true },
|
||||
{ id: 'latest_run', name: 'Last run', width: 265, resizable: true },
|
||||
{ id: 'next_run', name: 'Next run', minWidth: 180, resizable: true },
|
||||
{ id: 'command', name: 'Command', minWidth: 320, resizable: true },
|
||||
{ id: 'active', name: 'Active', width: 70, minWidth: 70, maxWidth: 70, resizable: false },
|
||||
{ id: 'actions', name: '', minWidth: 75, width: 75, resizable: false },
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cronPattern, secondsPattern } from './CronJobs.constants'
|
||||
import { parseCronJobCommand } from './CronJobs.utils'
|
||||
import { formatCronJobColumns, parseCronJobCommand } from './CronJobs.utils'
|
||||
|
||||
describe('parseCronJobCommand', () => {
|
||||
it('should return a default object when the command is null', () => {
|
||||
@@ -279,3 +279,23 @@ describe('parseCronJobCommand', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCronJobColumns', () => {
|
||||
it('enables resizing for informational columns and keeps utility columns fixed', () => {
|
||||
const columns = formatCronJobColumns({
|
||||
onSelectEdit: () => undefined,
|
||||
onSelectDelete: () => undefined,
|
||||
})
|
||||
|
||||
const columnsByKey = Object.fromEntries(columns.map((column) => [String(column.key), column]))
|
||||
|
||||
expect(columnsByKey.jobname.resizable).toBe(true)
|
||||
expect(columnsByKey.jobname.minWidth).toBeGreaterThan(0)
|
||||
expect(columnsByKey.schedule.resizable).toBe(true)
|
||||
expect(columnsByKey.latest_run.resizable).toBe(true)
|
||||
expect(columnsByKey.next_run.resizable).toBe(true)
|
||||
expect(columnsByKey.command.resizable).toBe(true)
|
||||
expect(columnsByKey.active.resizable).toBe(false)
|
||||
expect(columnsByKey.actions.resizable).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -236,7 +236,7 @@ export const formatCronJobColumns = ({
|
||||
minWidth: col.minWidth ?? 100,
|
||||
maxWidth: col.maxWidth,
|
||||
width: col.width,
|
||||
resizable: false,
|
||||
resizable: col.resizable ?? false,
|
||||
sortable: false,
|
||||
draggable: false,
|
||||
headerCellClass: undefined,
|
||||
|
||||
@@ -5,13 +5,13 @@ import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { EMPTY_ARR } from 'lib/void'
|
||||
import { useMemo } from 'react'
|
||||
import { parseSchemaComment } from 'stripe-experiment-sync/supabase'
|
||||
|
||||
import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils'
|
||||
import { INTEGRATIONS } from './Integrations.constants'
|
||||
import {
|
||||
isInstalled as checkIsInstalled,
|
||||
findStripeSchema,
|
||||
parseStripeSchema,
|
||||
} from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status'
|
||||
|
||||
export const useInstalledIntegrations = () => {
|
||||
@@ -77,7 +77,7 @@ export const useInstalledIntegrations = () => {
|
||||
}
|
||||
if (integration.id === 'stripe_sync_engine') {
|
||||
const stripeSchema = findStripeSchema(schemas)
|
||||
const parsedSchema = parseStripeSchema(stripeSchema)
|
||||
const parsedSchema = parseSchemaComment(stripeSchema?.comment)
|
||||
return checkIsInstalled(parsedSchema.status)
|
||||
}
|
||||
if (integration.type === 'wrapper') {
|
||||
|
||||
+51
-29
@@ -1,37 +1,50 @@
|
||||
import { useParams } from 'common'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
import { ContactSupportButton } from '@/components/ui/AlertError'
|
||||
import { useStripeSyncStatus } from './useStripeSyncStatus'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
export const InstallationError = ({
|
||||
error,
|
||||
handleUninstall,
|
||||
handleOpenInstallSheet,
|
||||
isUpgrade,
|
||||
installing,
|
||||
uninstalling,
|
||||
}: {
|
||||
error: 'install' | 'uninstall'
|
||||
handleUninstall: () => void
|
||||
handleOpenInstallSheet: () => void
|
||||
isUpgrade?: boolean
|
||||
installing?: boolean
|
||||
uninstalling?: boolean
|
||||
}) => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const {
|
||||
schemaComment: { errorMessage },
|
||||
} = useStripeSyncStatus({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
if (error === 'uninstall') {
|
||||
return (
|
||||
<Admonition
|
||||
type="warning"
|
||||
layout="responsive"
|
||||
title="Failed to uninstall Stripe Sync Engine"
|
||||
<AlertError
|
||||
layout="horizontal"
|
||||
subject="Failed to uninstall Stripe Sync Engine"
|
||||
error={errorMessage ? { message: errorMessage } : undefined}
|
||||
description="There was an error during the uninstallation of the Stripe Sync Engine, please try again. If the problem persists, contact support."
|
||||
actions={
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button type="default" onClick={handleUninstall}>
|
||||
Retry uninstall
|
||||
</Button>
|
||||
<ContactSupportButton
|
||||
projectRef={ref}
|
||||
subject="Failed to uninstall Stripe Sync Engine"
|
||||
/>
|
||||
</div>
|
||||
additionalActions={
|
||||
<Button
|
||||
type="default"
|
||||
onClick={handleUninstall}
|
||||
disabled={uninstalling}
|
||||
loading={uninstalling}
|
||||
>
|
||||
Retry uninstallation
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -39,18 +52,27 @@ export const InstallationError = ({
|
||||
|
||||
if (error === 'install') {
|
||||
return (
|
||||
<Admonition
|
||||
type="warning"
|
||||
layout="responsive"
|
||||
title="Failed to install Stripe Sync Engine"
|
||||
description="There was an error during the installation of the Stripe Sync Engine, please try reinstalling the integration. If the problem persists, contact support."
|
||||
actions={
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button type="default" onClick={handleOpenInstallSheet}>
|
||||
Retry install
|
||||
</Button>
|
||||
<ContactSupportButton projectRef={ref} subject="Failed to install Stripe Sync Engine" />
|
||||
</div>
|
||||
<AlertError
|
||||
subject={
|
||||
isUpgrade
|
||||
? 'Failed to upgrade Stripe Sync Engine'
|
||||
: 'Failed to install Stripe Sync Engine'
|
||||
}
|
||||
error={errorMessage ? { message: errorMessage } : undefined}
|
||||
description={
|
||||
isUpgrade
|
||||
? 'There was an error during the upgrade of the Stripe Sync Engine, please try again. If the problem persists, contact support.'
|
||||
: 'There was an error during the installation of the Stripe Sync Engine, please try reinstalling the integration. If the problem persists, contact support.'
|
||||
}
|
||||
additionalActions={
|
||||
<Button
|
||||
type="default"
|
||||
onClick={handleOpenInstallSheet}
|
||||
disabled={installing}
|
||||
loading={installing}
|
||||
>
|
||||
{isUpgrade ? 'Retry upgrade' : 'Retry installation'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
+95
-48
@@ -45,8 +45,6 @@ import {
|
||||
} from './stripe-sync-status'
|
||||
import { StripeSyncChangesCard } from './StripeSyncChangesCard'
|
||||
import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { useSSLEnforcementQuery } from '@/data/ssl-enforcement/ssl-enforcement-query'
|
||||
|
||||
const installFormSchema = z.object({
|
||||
stripeSecretKey: z.string().min(1, 'Stripe API key is required'),
|
||||
@@ -71,7 +69,10 @@ export const StripeSyncInstallationPage = () => {
|
||||
})
|
||||
|
||||
const {
|
||||
parsedSchema: { status: installationStatus },
|
||||
schemaComment,
|
||||
schemaComment: { status: installationStatus },
|
||||
latestAvailableVersion,
|
||||
timedOut,
|
||||
} = useStripeSyncStatus({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
@@ -91,6 +92,22 @@ export const StripeSyncInstallationPage = () => {
|
||||
const installDone = isInstallDone(installationStatus)
|
||||
const uninstallDone = isUninstallDone(installationStatus)
|
||||
|
||||
// Detect if this is an upgrade (both old and new versions present)
|
||||
let oldVersion
|
||||
let newVersion
|
||||
if (installed) {
|
||||
// when installed we compare the installed version against the latest available
|
||||
oldVersion = schemaComment?.newVersion
|
||||
newVersion = latestAvailableVersion
|
||||
} else {
|
||||
// otherwise compare the old and new versions from the schema
|
||||
oldVersion = schemaComment?.oldVersion
|
||||
newVersion = schemaComment?.newVersion
|
||||
}
|
||||
|
||||
const upgradeAvailable = !!(oldVersion && newVersion && oldVersion !== newVersion)
|
||||
const upgradeDone = latestAvailableVersion == schemaComment?.newVersion
|
||||
|
||||
const {
|
||||
mutate: installStripeSync,
|
||||
isPending: isInstallRequested,
|
||||
@@ -98,7 +115,9 @@ export const StripeSyncInstallationPage = () => {
|
||||
reset: resetInstallError,
|
||||
} = useStripeSyncInstallMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Stripe Sync installation started')
|
||||
toast.success(
|
||||
upgradeAvailable ? 'Stripe Sync upgrade started' : 'Stripe Sync installation started'
|
||||
)
|
||||
setShouldShowInstallSheet(false)
|
||||
form.reset()
|
||||
setIsInstallInitiated(true)
|
||||
@@ -115,11 +134,12 @@ export const StripeSyncInstallationPage = () => {
|
||||
})
|
||||
|
||||
// Combine schema status with mutation/initiated states for UI
|
||||
const installing = installInProgress || isInstallRequested || isInstallInitiated
|
||||
const uninstalling = uninstallInProgress || isUninstallRequested || isUninstallInitiated
|
||||
const installing = (installInProgress || isInstallRequested || isInstallInitiated) && !timedOut
|
||||
const uninstalling =
|
||||
(uninstallInProgress || isUninstallRequested || isUninstallInitiated) && !timedOut
|
||||
const canInstall = checkCanInstall(installationStatus) && !installed && !installing
|
||||
|
||||
const hasError = (uninstallError || installError) && !uninstalling && !installing
|
||||
const hasError = (uninstallError || installError) && ((!uninstalling && !installing) || timedOut)
|
||||
|
||||
// Poll for schema changes during transitions
|
||||
useSchemasQuery(
|
||||
@@ -127,19 +147,12 @@ export const StripeSyncInstallationPage = () => {
|
||||
{ refetchInterval: installing || uninstalling ? 5000 : false }
|
||||
)
|
||||
|
||||
const { data: sslEnforcementConfiguration, isSuccess: isSuccessSslEnforcement } =
|
||||
useSSLEnforcementQuery({
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
const isSSLEnforced =
|
||||
sslEnforcementConfiguration?.appliedSuccessfully &&
|
||||
sslEnforcementConfiguration?.currentConfig.database
|
||||
|
||||
const handleUninstall = useCallback(() => {
|
||||
if (!project?.ref) return
|
||||
|
||||
uninstallStripeSync({
|
||||
projectRef: project.ref,
|
||||
startTime: Date.now(),
|
||||
})
|
||||
}, [project?.ref, uninstallStripeSync])
|
||||
|
||||
@@ -173,12 +186,13 @@ export const StripeSyncInstallationPage = () => {
|
||||
}
|
||||
}, [installError, track])
|
||||
|
||||
// Clear install initiated flag once schema reflects completion or error
|
||||
// Clear install initiated flag once schema reflects successful completion
|
||||
// For errors, the flag is cleared when user manually retries (handleOpenInstallSheet)
|
||||
useEffect(() => {
|
||||
if (isInstallInitiated && installDone) {
|
||||
if (isInstallInitiated && installDone && upgradeDone && !installError) {
|
||||
setIsInstallInitiated(false)
|
||||
}
|
||||
}, [isInstallInitiated, installDone])
|
||||
}, [isInstallInitiated, installDone, upgradeDone, installError])
|
||||
|
||||
// Clear uninstall initiated flag once schema is removed or error
|
||||
useEffect(() => {
|
||||
@@ -195,20 +209,30 @@ export const StripeSyncInstallationPage = () => {
|
||||
error={uninstallError ? 'uninstall' : 'install'}
|
||||
handleUninstall={handleUninstall}
|
||||
handleOpenInstallSheet={handleOpenInstallSheet}
|
||||
isUpgrade={upgradeAvailable}
|
||||
installing={installing}
|
||||
uninstalling={uninstalling}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
status={
|
||||
<StatusDisplay
|
||||
status={installationStatus}
|
||||
isInstallRequested={isInstallRequested || isInstallInitiated}
|
||||
isUninstallRequested={isUninstallRequested || isUninstallInitiated}
|
||||
isInstallRequested={isInstallRequested}
|
||||
isInstallInitiated={isInstallInitiated}
|
||||
isUninstallRequested={isUninstallRequested}
|
||||
isUninstallInitiated={isUninstallInitiated}
|
||||
isUpgrade={upgradeAvailable}
|
||||
timedOut={timedOut}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
!installed && !uninstalling && !uninstallError ? (
|
||||
<>
|
||||
<StripeSyncChangesCard installationStatus={installationStatus} />
|
||||
<StripeSyncChangesCard
|
||||
installationStatus={installationStatus}
|
||||
isUpgrade={upgradeAvailable}
|
||||
/>
|
||||
<div className="flex gap-x-2 justify-end mt-4">
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
@@ -225,7 +249,13 @@ export const StripeSyncInstallationPage = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{installError ? 'Retry installation' : 'Install integration'}
|
||||
{installError
|
||||
? upgradeAvailable
|
||||
? 'Retry upgrade'
|
||||
: 'Retry installation'
|
||||
: upgradeAvailable
|
||||
? 'Upgrade integration'
|
||||
: 'Install integration'}
|
||||
</ButtonTooltip>
|
||||
{installError && (
|
||||
<Button type="default" loading={isUninstallRequested} onClick={handleUninstall}>
|
||||
@@ -236,12 +266,32 @@ export const StripeSyncInstallationPage = () => {
|
||||
</>
|
||||
) : installed || uninstalling || uninstallError ? (
|
||||
<>
|
||||
<StripeSyncChangesCard installationStatus={installationStatus} />
|
||||
<div className="flex justify-end mt-4">
|
||||
<StripeSyncChangesCard
|
||||
installationStatus={installationStatus}
|
||||
isUpgrade={upgradeAvailable}
|
||||
/>
|
||||
<div className="flex gap-x-2 justify-end mt-4">
|
||||
{upgradeAvailable && !uninstallError && !uninstalling && (
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
onClick={() => setShouldShowInstallSheet(true)}
|
||||
disabled={installing || !canManageSecrets}
|
||||
loading={installing}
|
||||
tooltip={{
|
||||
content: {
|
||||
text: !canManageSecrets
|
||||
? 'You need additional permissions to upgrade the Stripe Sync Engine.'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Upgrade integration
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
onClick={() => setShowUninstallModal(true)}
|
||||
disabled={!canManageSecrets}
|
||||
disabled={installing || uninstalling || !canManageSecrets}
|
||||
loading={uninstalling}
|
||||
tooltip={{
|
||||
content: {
|
||||
@@ -251,7 +301,7 @@ export const StripeSyncInstallationPage = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
Uninstall integration
|
||||
{uninstallError ? 'Retry uninstallation' : 'Uninstall integration'}
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</>
|
||||
@@ -265,33 +315,24 @@ export const StripeSyncInstallationPage = () => {
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(({ stripeSecretKey }) => {
|
||||
if (!project?.ref) return
|
||||
installStripeSync({ projectRef: project.ref, stripeSecretKey })
|
||||
installStripeSync({
|
||||
projectRef: project.ref,
|
||||
stripeSecretKey,
|
||||
startTime: Date.now(),
|
||||
})
|
||||
})}
|
||||
className="overflow-auto flex-grow px-0 flex flex-col"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Install Stripe Sync Engine</SheetTitle>
|
||||
<SheetTitle>
|
||||
{upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-1 flex flex-col gap-y-6">
|
||||
<StripeSyncChangesCard installationStatus={installationStatus} />
|
||||
|
||||
{isSuccessSslEnforcement && isSSLEnforced && (
|
||||
<Admonition type="warning">
|
||||
<h5 className="mb-0.5">
|
||||
This integration currently requires{' '}
|
||||
<InlineLink
|
||||
href={`/project/${project?.ref ?? '_'}/database/settings#ssl-configuration`}
|
||||
>
|
||||
SSL Enforcement
|
||||
</InlineLink>{' '}
|
||||
to be disabled during initial setup.
|
||||
</h5>
|
||||
<p className="text-foreground-light">
|
||||
Support for SSL Enforcement will be added in a future update. Once installed,
|
||||
all webhook and sync operations use HTTPS/SSL.
|
||||
</p>
|
||||
</Admonition>
|
||||
)}
|
||||
<StripeSyncChangesCard
|
||||
installationStatus={installationStatus}
|
||||
isUpgrade={upgradeAvailable}
|
||||
/>
|
||||
|
||||
<h3 className="heading-default">Configuration</h3>
|
||||
|
||||
@@ -368,7 +409,13 @@ export const StripeSyncInstallationPage = () => {
|
||||
loading={isInstallRequested}
|
||||
disabled={!form.formState.isValid || isInstallRequested}
|
||||
>
|
||||
{isInstallRequested ? 'Installing' : 'Install'}
|
||||
{isInstallRequested
|
||||
? upgradeAvailable
|
||||
? 'Upgrading'
|
||||
: 'Installing'
|
||||
: upgradeAvailable
|
||||
? 'Upgrade integration'
|
||||
: 'Install integration'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
|
||||
+24
-15
@@ -1,4 +1,5 @@
|
||||
import { AlertCircle, Check, RefreshCwIcon } from 'lucide-react'
|
||||
import { SchemaInstallationStatus } from 'stripe-experiment-sync/supabase'
|
||||
|
||||
import {
|
||||
hasInstallError,
|
||||
@@ -6,17 +7,24 @@ import {
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
isUninstalling,
|
||||
StripeInstallationStatus,
|
||||
} from './stripe-sync-status'
|
||||
|
||||
export const StatusDisplay = ({
|
||||
status,
|
||||
isInstallRequested,
|
||||
isInstallInitiated,
|
||||
isUninstallRequested,
|
||||
isUninstallInitiated,
|
||||
isUpgrade,
|
||||
timedOut,
|
||||
}: {
|
||||
status: StripeInstallationStatus
|
||||
status: SchemaInstallationStatus
|
||||
isInstallRequested: boolean
|
||||
isInstallInitiated: boolean
|
||||
isUninstallRequested: boolean
|
||||
isUninstallInitiated: boolean
|
||||
isUpgrade?: boolean
|
||||
timedOut: boolean
|
||||
}) => {
|
||||
const installed = isInstalled(status)
|
||||
const installError = hasInstallError(status)
|
||||
@@ -24,17 +32,10 @@ export const StatusDisplay = ({
|
||||
const installInProgress = isInstalling(status)
|
||||
const uninstallInProgress = isUninstalling(status)
|
||||
|
||||
const installing = installInProgress || isInstallRequested
|
||||
const uninstalling = uninstallInProgress || isUninstallRequested
|
||||
const installing = (installInProgress || isInstallRequested || isInstallInitiated) && !timedOut
|
||||
const uninstalling =
|
||||
(uninstallInProgress || isUninstallRequested || isUninstallInitiated) && !timedOut
|
||||
|
||||
if (uninstallError) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 text-foreground-light text-sm">
|
||||
<AlertCircle size={14} className="text-destructive" />
|
||||
Uninstallation error
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (uninstalling) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 text-foreground-light text-sm">
|
||||
@@ -43,11 +44,11 @@ export const StatusDisplay = ({
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (installError) {
|
||||
if (uninstallError) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 text-foreground-light text-sm">
|
||||
<AlertCircle size={14} className="text-destructive" />
|
||||
Installation error
|
||||
Uninstallation error
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -55,7 +56,15 @@ export const StatusDisplay = ({
|
||||
return (
|
||||
<span className="flex items-center gap-2 text-foreground-light text-sm">
|
||||
<RefreshCwIcon size={14} className="animate-spin text-foreground-lighter" />
|
||||
Installing...
|
||||
{isUpgrade ? 'Upgrading...' : 'Installing...'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (installError) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 text-foreground-light text-sm">
|
||||
<AlertCircle size={14} className="text-destructive" />
|
||||
{isUpgrade ? 'Upgrade error' : 'Installation error'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
+67
-32
@@ -1,5 +1,6 @@
|
||||
import { EdgeFunctions } from 'icons'
|
||||
import { Layers, Table } from 'lucide-react'
|
||||
import { Layers, Loader2, Table } from 'lucide-react'
|
||||
import { SchemaInstallationStatus } from 'stripe-experiment-sync/supabase'
|
||||
import { Card, CardContent, cn } from 'ui'
|
||||
|
||||
import {
|
||||
@@ -10,12 +11,12 @@ import {
|
||||
isInstalling,
|
||||
isUninstallDone,
|
||||
isUninstalling,
|
||||
StripeInstallationStatus,
|
||||
} from './stripe-sync-status'
|
||||
|
||||
type StripeSyncChangesCardProps = {
|
||||
installationStatus: StripeInstallationStatus
|
||||
installationStatus: SchemaInstallationStatus
|
||||
className?: string
|
||||
isUpgrade?: boolean
|
||||
}
|
||||
|
||||
const ListItemClassName = 'flex items-center gap-x-3 py-2 px-3 border-b'
|
||||
@@ -23,6 +24,7 @@ const ListItemClassName = 'flex items-center gap-x-3 py-2 px-3 border-b'
|
||||
export const StripeSyncChangesCard = ({
|
||||
installationStatus,
|
||||
className,
|
||||
isUpgrade,
|
||||
}: StripeSyncChangesCardProps) => {
|
||||
const installed = isInstalled(installationStatus)
|
||||
const installError = hasInstallError(installationStatus)
|
||||
@@ -32,53 +34,70 @@ export const StripeSyncChangesCard = ({
|
||||
const installDone = isInstallDone(installationStatus)
|
||||
const uninstallDone = isUninstallDone(installationStatus)
|
||||
|
||||
const title =
|
||||
uninstallDone || installError
|
||||
// Special case: installed integration with upgrade available (shown in sheet context)
|
||||
const isInProgress = installInProgress || uninstallInProgress
|
||||
const isInstalledWithUpgrade = installed && isUpgrade
|
||||
|
||||
const title = isInstalledWithUpgrade
|
||||
? 'This integration will upgrade your Supabase project:'
|
||||
: uninstallDone || installError
|
||||
? 'This integration will modify your Supabase project:'
|
||||
: installInProgress || uninstallInProgress
|
||||
: isInProgress
|
||||
? 'This integration is modifying your Supabase project:'
|
||||
: installDone || installed || uninstallError
|
||||
? 'This integration has modified your Supabase project:'
|
||||
: ''
|
||||
|
||||
const dbLine =
|
||||
uninstallDone || installError
|
||||
? 'Creates a new database schema named '
|
||||
const dbLine = isInstalledWithUpgrade
|
||||
? 'Upgrades the database schema named'
|
||||
: uninstallDone || installError
|
||||
? 'Creates a new database schema named'
|
||||
: installInProgress
|
||||
? 'Creating a new database schema named '
|
||||
? isUpgrade
|
||||
? 'Upgrading database schema named'
|
||||
: 'Creating a new database schema named'
|
||||
: installDone || installed || uninstallError
|
||||
? 'Created a new database schema named '
|
||||
? 'Created a new database schema named'
|
||||
: uninstallInProgress
|
||||
? 'Dropping database schema named '
|
||||
? 'Dropping database schema named'
|
||||
: ''
|
||||
|
||||
const tableAndViewLine =
|
||||
uninstallDone || installError
|
||||
? 'Creates tables and views in the '
|
||||
const tableAndViewLine = isInstalledWithUpgrade
|
||||
? 'Upgrades tables and views in the'
|
||||
: uninstallDone || installError
|
||||
? 'Creates tables and views in the'
|
||||
: installInProgress
|
||||
? 'Creating tables and views in the '
|
||||
? isUpgrade
|
||||
? 'Upgrading tables and views in the'
|
||||
: 'Creating tables and views in the'
|
||||
: installDone || installed || uninstallError
|
||||
? 'Created tables and views in the '
|
||||
? 'Created tables and views in the'
|
||||
: uninstallInProgress
|
||||
? 'Dropping tables and views in the '
|
||||
? 'Dropping tables and views in the'
|
||||
: ''
|
||||
|
||||
const edgeFunctionsLine =
|
||||
uninstallDone || installError
|
||||
const edgeFunctionsLine = isInstalledWithUpgrade
|
||||
? 'Upgrades Edge Functions to handle incoming webhooks from Stripe'
|
||||
: uninstallDone || installError
|
||||
? 'Deploys Edge Functions to handle incoming webhooks from Stripe'
|
||||
: installInProgress
|
||||
? 'Deploying Edge Functions to handle incoming webhooks from Stripe'
|
||||
? isUpgrade
|
||||
? 'Upgrading Edge Functions to handle incoming webhooks from Stripe'
|
||||
: 'Deploying Edge Functions to handle incoming webhooks from Stripe'
|
||||
: installDone || installed || uninstallError
|
||||
? 'Deployed Edge Functions to handle incoming webhooks from Stripe'
|
||||
: uninstallInProgress
|
||||
? 'Undeploying Edge Functions to handle incoming webhooks from Stripe'
|
||||
: ''
|
||||
|
||||
const scheduleLine =
|
||||
uninstallDone || installError
|
||||
const scheduleLine = isInstalledWithUpgrade
|
||||
? 'Upgrades automatic Stripe data syncs using Supabase Queues'
|
||||
: uninstallDone || installError
|
||||
? 'Schedules automatic Stripe data syncs using Supabase Queues'
|
||||
: installInProgress
|
||||
? 'Scheduling automatic Stripe data syncs using Supabase Queues'
|
||||
? isUpgrade
|
||||
? 'Upgrading automatic Stripe data syncs using Supabase Queues'
|
||||
: 'Scheduling automatic Stripe data syncs using Supabase Queues'
|
||||
: installDone || installed || uninstallError
|
||||
? 'Scheduled automatic Stripe data syncs using Supabase Queues'
|
||||
: uninstallInProgress
|
||||
@@ -92,28 +111,44 @@ export const StripeSyncChangesCard = ({
|
||||
<CardContent className="p-0">
|
||||
<ul className="text-foreground-light text-sm">
|
||||
<li className={ListItemClassName}>
|
||||
<Table size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
|
||||
{isInProgress ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Table size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{dbLine} <code className="text-code-inline">stripe</code>
|
||||
</span>
|
||||
</li>
|
||||
<li className={ListItemClassName}>
|
||||
<Table size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
|
||||
{isInProgress ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Table size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{tableAndViewLine} <code className="text-code-inline">stripe</code> schema for
|
||||
synced Stripe data
|
||||
</span>
|
||||
</li>
|
||||
<li className={ListItemClassName}>
|
||||
<EdgeFunctions
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className="text-foreground-lighter shrink-0"
|
||||
/>
|
||||
{isInProgress ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<EdgeFunctions
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className="text-foreground-lighter shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span>{edgeFunctionsLine}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-x-3 py-2 px-3">
|
||||
<Layers size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
|
||||
{isInProgress ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Layers size={16} strokeWidth={1.5} className="text-foreground-lighter shrink-0" />
|
||||
)}
|
||||
<span>{scheduleLine}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
+76
-66
@@ -1,10 +1,9 @@
|
||||
import { formatRelative } from 'date-fns'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { BadgeCheck, RefreshCwIcon, Table2 } from 'lucide-react'
|
||||
import { BadgeCheck, RefreshCwIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Button, Card, CardContent } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from 'ui'
|
||||
import { Admonition, ShimmeringLoader, TimestampInfo } from 'ui-patterns'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
@@ -19,13 +18,11 @@ import { isInstalled, isSyncRunning, isUninstalling } from './stripe-sync-status
|
||||
import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
|
||||
|
||||
export const StripeSyncSettingsPage = () => {
|
||||
const router = useRouter()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const {
|
||||
parsedSchema: { status: installationStatus },
|
||||
schemaComment: { status: installationStatus },
|
||||
syncState,
|
||||
isLoading: isLoadingInstallationStatus,
|
||||
} = useStripeSyncStatus({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
@@ -33,79 +30,92 @@ export const StripeSyncSettingsPage = () => {
|
||||
const installed = isInstalled(installationStatus)
|
||||
const isSyncing = isSyncRunning(syncState)
|
||||
const uninstalling = isUninstalling(installationStatus)
|
||||
const tableEditorUrl = `/project/${project?.ref}/editor?schema=stripe`
|
||||
|
||||
if (!installed || uninstalling) {
|
||||
return (
|
||||
<PageContainer className="mx-0">
|
||||
<PageSection>
|
||||
<Admonition type="default" title="Stripe Sync Engine is not installed" />
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer className="mx-0">
|
||||
{syncState && installed && !uninstalling && (
|
||||
<PageSection id="sync-status">
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Sync Status</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Admonition type="default" showIcon={false}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCwIcon size={14} className="animate-spin" />
|
||||
<div>Sync in progress...</div>
|
||||
</div>
|
||||
<div className="text-foreground-light text-sm">
|
||||
Started{' '}
|
||||
{syncState.started_at
|
||||
? formatRelative(new Date(syncState.started_at), new Date())
|
||||
: 'recently'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgeCheck size={14} className="text-brand" />
|
||||
<div>All up to date</div>
|
||||
<Button asChild type="text">
|
||||
<Link href={tableEditorUrl}>View data</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-foreground-light text-sm">
|
||||
Last synced{' '}
|
||||
{syncState.closed_at
|
||||
? formatRelative(new Date(syncState.closed_at), new Date())
|
||||
: 'recently'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Admonition>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)}
|
||||
<PageSection id="stripe-schema">
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Stripe Schema</PageSectionTitle>
|
||||
<PageSectionTitle>Manage Stripe data</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
Access and manage the synced Stripe data in your database.
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<Card className="max-w-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground-lighter">
|
||||
{!syncState ? (
|
||||
<ShimmeringLoader className="py-2" />
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<div className="flex items-center gap-x-3 text-foreground-light">
|
||||
<RefreshCwIcon size={14} className="animate-spin" />
|
||||
<p>Sync in progress</p>
|
||||
</div>
|
||||
{syncState.started_at && (
|
||||
<p className="text-foreground-light">
|
||||
Started{' '}
|
||||
<TimestampInfo
|
||||
utcTimestamp={syncState.started_at}
|
||||
label={
|
||||
syncState.started_at
|
||||
? formatRelative(new Date(syncState.started_at), new Date())
|
||||
: 'recently'
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-x-3 text-foreground-light">
|
||||
<BadgeCheck size={14} />
|
||||
<p>All up to date</p>
|
||||
</div>
|
||||
{syncState.closed_at && (
|
||||
<p className="text-foreground-light">
|
||||
Last synced{' '}
|
||||
<TimestampInfo
|
||||
utcTimestamp={syncState.closed_at}
|
||||
label={
|
||||
syncState.closed_at
|
||||
? formatRelative(new Date(syncState.closed_at), new Date())
|
||||
: 'recently'
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="@container">
|
||||
<div className="flex flex-col items-start justify-between gap-4 @md:flex-row @md:items-center">
|
||||
<div className="flex gap-x-4">
|
||||
<Table2 className="w-5 h-5 shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h5 className="text-sm mb-1">Open Stripe schema in Table Editor</h5>
|
||||
<p className="text-sm text-foreground-light text-balance">
|
||||
The Stripe Sync Engine stores all synced data in the{' '}
|
||||
<code className="text-code-inline !break-keep">stripe</code> schema. You can
|
||||
view and query this data directly in the Table Editor.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h5 className="text-sm">View Stripe data in Table Editor</h5>
|
||||
<p className="text-sm text-foreground-light text-balance">
|
||||
The Stripe Sync Engine stores all synced data in the{' '}
|
||||
<code className="text-code-inline !break-keep">stripe</code> schema. You can
|
||||
view and query this data directly in the Table Editor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild type="default" className="ml-8 @md:ml-0">
|
||||
<Link href={`/project/${project?.ref}/editor?schema=stripe`}>
|
||||
Open Table Editor
|
||||
|
||||
+21
-125
@@ -3,177 +3,73 @@ import type {
|
||||
StripeSyncStateData,
|
||||
} from 'data/database-integrations/stripe/sync-state-query'
|
||||
import type { Schema } from 'data/database/schemas-query'
|
||||
import {
|
||||
INSTALLATION_ERROR_SUFFIX,
|
||||
INSTALLATION_INSTALLED_SUFFIX,
|
||||
INSTALLATION_STARTED_SUFFIX,
|
||||
STRIPE_SCHEMA_COMMENT_PREFIX,
|
||||
} from 'stripe-experiment-sync/supabase'
|
||||
|
||||
/**
|
||||
* All possible Stripe Sync installation states.
|
||||
*/
|
||||
export type StripeInstallationStatus =
|
||||
| 'installing'
|
||||
| 'installed'
|
||||
| 'install_error'
|
||||
| 'uninstalling'
|
||||
| 'uninstalled'
|
||||
| 'uninstall_error'
|
||||
|
||||
/**
|
||||
* Parsed Stripe schema status including version and error message
|
||||
*/
|
||||
export interface ParsedStripeSchema {
|
||||
/** The installation status */
|
||||
status: StripeInstallationStatus
|
||||
|
||||
/** The version from the schema comment (e.g., 'v1.2.3') */
|
||||
version?: string
|
||||
|
||||
/** Error message if status is install_error or uninstall_error */
|
||||
errorMessage?: string
|
||||
}
|
||||
import { SchemaInstallationStatus, StripeSchemaComment } from 'stripe-experiment-sync/supabase'
|
||||
|
||||
/**
|
||||
* Complete Stripe Sync status including schema, installation state, and sync state
|
||||
*/
|
||||
export interface StripeSyncStatusResult {
|
||||
/** The parsed schema with status, version and error */
|
||||
parsedSchema: ParsedStripeSchema
|
||||
schemaComment: StripeSchemaComment
|
||||
|
||||
/** Current sync run state (only available when installationStatus is installed) */
|
||||
syncState: StripeSyncState | undefined
|
||||
|
||||
/** Whether the status is still being determined (schemas query is loading) */
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// TODO: The current version of the package 'stripe-experiment-sync' doesn't export
|
||||
// these constants, but we plan to export them in future version. For now we
|
||||
// declare the same constants here to deploy this code without waiting for
|
||||
// a new version. We'll import them when we bump 'stripe-experiment-sync' package
|
||||
// version.
|
||||
const UNINSTALLATION_STARTED_SUFFIX = 'uninstallation:started'
|
||||
const UNINSTALLATION_ERROR_SUFFIX = 'uninstallation:error'
|
||||
/** Latest version of stripe sync engine available for installation */
|
||||
latestAvailableVersion: string
|
||||
|
||||
/** True when installation or uninstallation has timed out */
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
export function findStripeSchema(schemas: Schema[] | undefined): Schema | undefined {
|
||||
return schemas?.find((s) => s.name === 'stripe')
|
||||
}
|
||||
|
||||
function isStripeSyncSchema(schema: Schema | undefined): boolean {
|
||||
return !!schema?.comment?.startsWith(STRIPE_SCHEMA_COMMENT_PREFIX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the installation status from a stripe schema.
|
||||
*
|
||||
* Schema comment format: {PREFIX} {version} {SUFFIX}[ - {error message}]
|
||||
* Example: "stripe_sync v1.2.3 installation:error - Could not apply migration"
|
||||
*
|
||||
* @param stripeSchema - The stripe schema from the database, if it exists
|
||||
* @returns The parsed status including version and optional error message
|
||||
*/
|
||||
export function parseStripeSchema(stripeSchema: Schema | undefined): ParsedStripeSchema {
|
||||
if (!isStripeSyncSchema(stripeSchema)) {
|
||||
return { status: 'uninstalled' }
|
||||
}
|
||||
|
||||
const comment = stripeSchema!.comment!
|
||||
|
||||
// Remove prefix and leading space
|
||||
const afterPrefix = comment.slice(STRIPE_SCHEMA_COMMENT_PREFIX.length).trimStart()
|
||||
|
||||
// Split at first space to get version and trailing segment
|
||||
const firstSpaceIndex = afterPrefix.indexOf(' ')
|
||||
if (firstSpaceIndex === -1) {
|
||||
return { status: 'uninstalled' }
|
||||
}
|
||||
|
||||
const version = afterPrefix.slice(0, firstSpaceIndex)
|
||||
const trailing = afterPrefix.slice(firstSpaceIndex + 1)
|
||||
|
||||
// Helper to extract error message if present after ' - ' separator
|
||||
const extractError = (afterSuffix: string): string | undefined => {
|
||||
if (afterSuffix.startsWith(' - ')) {
|
||||
return afterSuffix.slice(3) // Remove ' - ' (3 characters)
|
||||
}
|
||||
return afterSuffix
|
||||
}
|
||||
|
||||
// Check status in priority order
|
||||
if (trailing.startsWith(UNINSTALLATION_ERROR_SUFFIX)) {
|
||||
return {
|
||||
status: 'uninstall_error',
|
||||
version,
|
||||
errorMessage: extractError(trailing.slice(UNINSTALLATION_ERROR_SUFFIX.length)),
|
||||
}
|
||||
}
|
||||
|
||||
if (trailing.startsWith(INSTALLATION_ERROR_SUFFIX)) {
|
||||
return {
|
||||
status: 'install_error',
|
||||
version,
|
||||
errorMessage: extractError(trailing.slice(INSTALLATION_ERROR_SUFFIX.length)),
|
||||
}
|
||||
}
|
||||
|
||||
if (trailing.startsWith(UNINSTALLATION_STARTED_SUFFIX)) {
|
||||
return { status: 'uninstalling', version }
|
||||
}
|
||||
|
||||
if (trailing.startsWith(INSTALLATION_STARTED_SUFFIX)) {
|
||||
return { status: 'installing', version }
|
||||
}
|
||||
|
||||
if (trailing.startsWith(INSTALLATION_INSTALLED_SUFFIX)) {
|
||||
return { status: 'installed', version }
|
||||
}
|
||||
|
||||
return { status: 'uninstalled' }
|
||||
}
|
||||
|
||||
export function isInstalled(status: StripeInstallationStatus): boolean {
|
||||
export function isInstalled(status: SchemaInstallationStatus): boolean {
|
||||
return status === 'installed'
|
||||
}
|
||||
|
||||
export function isUninstalled(status: StripeInstallationStatus): boolean {
|
||||
export function isUninstalled(status: SchemaInstallationStatus): boolean {
|
||||
return status === 'uninstalled'
|
||||
}
|
||||
|
||||
export function hasInstallError(status: StripeInstallationStatus): boolean {
|
||||
return status === 'install_error'
|
||||
export function hasInstallError(status: SchemaInstallationStatus): boolean {
|
||||
return status === 'install error'
|
||||
}
|
||||
|
||||
export function hasUninstallError(status: StripeInstallationStatus): boolean {
|
||||
return status === 'uninstall_error'
|
||||
export function hasUninstallError(status: SchemaInstallationStatus): boolean {
|
||||
return status === 'uninstall error'
|
||||
}
|
||||
|
||||
export function hasError(status: StripeInstallationStatus): boolean {
|
||||
export function hasError(status: SchemaInstallationStatus): boolean {
|
||||
return hasInstallError(status) || hasUninstallError(status)
|
||||
}
|
||||
|
||||
export function isInstalling(status: StripeInstallationStatus): boolean {
|
||||
export function isInstalling(status: SchemaInstallationStatus): boolean {
|
||||
return status === 'installing'
|
||||
}
|
||||
|
||||
export function isUninstalling(status: StripeInstallationStatus): boolean {
|
||||
export function isUninstalling(status: SchemaInstallationStatus): boolean {
|
||||
return status === 'uninstalling'
|
||||
}
|
||||
|
||||
export function isInProgress(status: StripeInstallationStatus): boolean {
|
||||
export function isInProgress(status: SchemaInstallationStatus): boolean {
|
||||
return isInstalling(status) || isUninstalling(status)
|
||||
}
|
||||
|
||||
export function isInstallDone(status: StripeInstallationStatus): boolean {
|
||||
export function isInstallDone(status: SchemaInstallationStatus): boolean {
|
||||
return isInstalled(status) || hasInstallError(status)
|
||||
}
|
||||
|
||||
export function isUninstallDone(status: StripeInstallationStatus): boolean {
|
||||
export function isUninstallDone(status: SchemaInstallationStatus): boolean {
|
||||
return isUninstalled(status) || hasUninstallError(status)
|
||||
}
|
||||
|
||||
export function canInstall(status: StripeInstallationStatus): boolean {
|
||||
export function canInstall(status: SchemaInstallationStatus): boolean {
|
||||
return isUninstalled(status) || hasError(status)
|
||||
}
|
||||
|
||||
|
||||
+46
-15
@@ -1,15 +1,19 @@
|
||||
import { useStripeSyncingState } from 'data/database-integrations/stripe/sync-state-query'
|
||||
import { SchemasVariables, useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useEffect } from 'react'
|
||||
import { checkDomainOfScale } from 'recharts/types/util/ChartUtils'
|
||||
import { getCurrentVersion, parseSchemaComment } from 'stripe-experiment-sync/supabase'
|
||||
|
||||
import {
|
||||
findStripeSchema,
|
||||
isInProgress,
|
||||
isInstalled,
|
||||
parseStripeSchema,
|
||||
type StripeSyncStatusResult,
|
||||
} from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status'
|
||||
|
||||
// Maximum time allowed for installation or uninstallation operations before the UI times out
|
||||
const OPERATION_TIME_OUT_MS: number = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
/**
|
||||
* Unified hook for Stripe Sync installation status.
|
||||
*
|
||||
@@ -21,6 +25,8 @@ export function useStripeSyncStatus({
|
||||
projectRef,
|
||||
connectionString,
|
||||
}: SchemasVariables): StripeSyncStatusResult {
|
||||
const latestAvailableVersion = getCurrentVersion()
|
||||
|
||||
// Query schemas once
|
||||
const {
|
||||
data: schemas,
|
||||
@@ -30,10 +36,42 @@ export function useStripeSyncStatus({
|
||||
|
||||
// Find and parse stripe schema status
|
||||
const stripeSchema = findStripeSchema(schemas)
|
||||
const parsedSchema = parseStripeSchema(stripeSchema)
|
||||
const rawSchemaComment = parseSchemaComment(stripeSchema?.comment)
|
||||
|
||||
const installed = isInstalled(parsedSchema.status)
|
||||
const inProgress = isInProgress(parsedSchema.status)
|
||||
const now = Date.now()
|
||||
const timedOut = rawSchemaComment.startTime
|
||||
? now - rawSchemaComment.startTime > OPERATION_TIME_OUT_MS
|
||||
: false
|
||||
|
||||
const status = timedOut
|
||||
? rawSchemaComment.status === 'installing'
|
||||
? 'install error'
|
||||
: rawSchemaComment.status === 'uninstalling'
|
||||
? 'uninstall error'
|
||||
: rawSchemaComment.status
|
||||
: rawSchemaComment.status
|
||||
|
||||
const errorMessage = timedOut
|
||||
? rawSchemaComment.status === 'installing'
|
||||
? 'Installation timed out'
|
||||
: rawSchemaComment.status === 'uninstalling'
|
||||
? 'Uninstallation timed out'
|
||||
: rawSchemaComment.errorMessage
|
||||
: rawSchemaComment.errorMessage
|
||||
|
||||
const schemaComment = { ...rawSchemaComment, status, errorMessage }
|
||||
|
||||
const installed = isInstalled(schemaComment.status)
|
||||
const inProgress = isInProgress(schemaComment.status)
|
||||
|
||||
// Query sync state only when installed
|
||||
const { data: syncState, isPending: isLoadingStripeSyncState } = useStripeSyncingState(
|
||||
{ projectRef: projectRef!, connectionString },
|
||||
{
|
||||
refetchInterval: 4000,
|
||||
enabled: !!projectRef && installed,
|
||||
}
|
||||
)
|
||||
|
||||
// Poll schemas during install/uninstall operations
|
||||
useEffect(() => {
|
||||
@@ -48,18 +86,11 @@ export function useStripeSyncStatus({
|
||||
return () => clearInterval(interval)
|
||||
}, [inProgress, refetch])
|
||||
|
||||
// Query sync state only when installed
|
||||
const { data: syncState, isLoading: isSyncStateLoading } = useStripeSyncingState(
|
||||
{ projectRef: projectRef!, connectionString },
|
||||
{
|
||||
refetchInterval: 4000,
|
||||
enabled: !!projectRef && installed,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
parsedSchema,
|
||||
schemaComment,
|
||||
syncState: installed ? syncState : undefined,
|
||||
isLoading: isSchemasLoading,
|
||||
isLoading: isSchemasLoading || isLoadingStripeSyncState,
|
||||
latestAvailableVersion,
|
||||
timedOut,
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { urlRegex } from '../Auth/Auth.constants'
|
||||
import {
|
||||
DATADOG_REGIONS,
|
||||
LAST9_REGIONS,
|
||||
@@ -51,19 +50,18 @@ import {
|
||||
getHeadersSectionDescription as getHeadersDescription,
|
||||
validateNewHeader,
|
||||
} from './LogDrains.utils'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
const FORM_ID = 'log-drain-destination-form'
|
||||
|
||||
const formUnion = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('webhook'),
|
||||
url: z
|
||||
.string()
|
||||
.regex(urlRegex(), 'Endpoint URL is required and must be a valid URL')
|
||||
.refine(
|
||||
(url) => url.startsWith('http://') || url.startsWith('https://'),
|
||||
'Endpoint URL must start with http:// or https://'
|
||||
),
|
||||
url: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Endpoint URL is required',
|
||||
invalidMessage: 'Endpoint URL must be a valid URL',
|
||||
prefixMessage: 'Endpoint URL must start with http:// or https://',
|
||||
}),
|
||||
http: z.enum(['http1', 'http2']),
|
||||
gzip: z.boolean(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
@@ -75,13 +73,11 @@ const formUnion = z.discriminatedUnion('type', [
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('loki'),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, { message: 'Loki URL is required' })
|
||||
.refine(
|
||||
(url) => url.startsWith('http://') || url.startsWith('https://'),
|
||||
'Loki URL must start with http:// or https://'
|
||||
),
|
||||
url: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Loki URL is required',
|
||||
invalidMessage: 'Loki URL must be a valid URL',
|
||||
prefixMessage: 'Loki URL must start with http:// or https://',
|
||||
}),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
@@ -130,13 +126,11 @@ const formUnion = z.discriminatedUnion('type', [
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('otlp'),
|
||||
endpoint: z
|
||||
.string()
|
||||
.min(1, { message: 'OTLP endpoint is required' })
|
||||
.refine(
|
||||
(url) => url.startsWith('http://') || url.startsWith('https://'),
|
||||
'OTLP endpoint must start with http:// or https://'
|
||||
),
|
||||
endpoint: httpEndpointUrlSchema({
|
||||
requiredMessage: 'OTLP endpoint is required',
|
||||
invalidMessage: 'OTLP endpoint must be a valid URL',
|
||||
prefixMessage: 'OTLP endpoint must start with http:// or https://',
|
||||
}),
|
||||
protocol: z.string().optional().default('http/protobuf'),
|
||||
gzip: z.boolean().optional().default(true),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
|
||||
@@ -223,6 +223,18 @@ describe('otlpConfigSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects endpoint with an incomplete hostname', () => {
|
||||
const config = {
|
||||
type: 'otlp' as const,
|
||||
endpoint: 'https://webhook',
|
||||
}
|
||||
const result = otlpConfigSchema.safeParse(config)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toContain('must be a valid URL')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects wrong type field', () => {
|
||||
const config = {
|
||||
type: 'webhook' as const,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { LogDrainType } from './LogDrains.constants'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
/**
|
||||
* Get the description text for the custom headers section based on log drain type
|
||||
@@ -62,13 +63,11 @@ export function validateNewHeader(
|
||||
*/
|
||||
export const otlpConfigSchema = z.object({
|
||||
type: z.literal('otlp'),
|
||||
endpoint: z
|
||||
.string()
|
||||
.min(1, { message: 'OTLP endpoint is required' })
|
||||
.refine(
|
||||
(url) => url.startsWith('http://') || url.startsWith('https://'),
|
||||
'OTLP endpoint must start with http:// or https://'
|
||||
),
|
||||
endpoint: httpEndpointUrlSchema({
|
||||
requiredMessage: 'OTLP endpoint is required',
|
||||
invalidMessage: 'OTLP endpoint must be a valid URL',
|
||||
prefixMessage: 'OTLP endpoint must start with http:// or https://',
|
||||
}),
|
||||
protocol: z.string().optional().default('http/protobuf'),
|
||||
gzip: z.boolean().optional().default(true),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
|
||||
+118
-2
@@ -55,6 +55,16 @@ const renderEndpointSheet = (
|
||||
return { onClose, onSubmit }
|
||||
}
|
||||
|
||||
const submitForm = () =>
|
||||
fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
|
||||
|
||||
const getUrlInput = () => screen.getByPlaceholderText('https://api.example.com/webhooks/supabase')
|
||||
|
||||
const findEventTypeCheckbox = (eventType: string) =>
|
||||
screen.findByRole('checkbox', {
|
||||
name: new RegExp(eventType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
})
|
||||
|
||||
describe('PlatformWebhooksEndpointSheet', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -85,7 +95,7 @@ describe('PlatformWebhooksEndpointSheet', () => {
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('Billing events')
|
||||
await user.clear(nameInput)
|
||||
fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
|
||||
submitForm()
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
@@ -108,7 +118,7 @@ describe('PlatformWebhooksEndpointSheet', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Add header' }))
|
||||
await user.type(screen.getByPlaceholderText('Header name'), 'X-Webhook-Secret')
|
||||
await user.type(screen.getByPlaceholderText('Header value'), 'super-secret')
|
||||
fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
|
||||
submitForm()
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
@@ -118,4 +128,110 @@ describe('PlatformWebhooksEndpointSheet', () => {
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks submit when the endpoint URL is empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Please provide a URL')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks submit when the endpoint URL is malformed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://not a url')
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks submit when the endpoint URL uses an incomplete hostname', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://webhook')
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks submit when the endpoint URL does not include a protocol', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'hooks.example.com/billing')
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
submitForm()
|
||||
|
||||
expect(
|
||||
await screen.findByText('Please prefix your URL with http:// or https://')
|
||||
).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error when no event types are selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://hooks.example.com/billing')
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Select at least one event type')).toBeInTheDocument()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the event type error after selecting an event and allows submit', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://hooks.example.com/billing')
|
||||
submitForm()
|
||||
|
||||
expect(await screen.findByText('Select at least one event type')).toBeInTheDocument()
|
||||
|
||||
await user.click(await findEventTypeCheckbox('project.updated'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Select at least one event type')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
submitForm()
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://hooks.example.com/billing',
|
||||
eventTypes: ['project.updated'],
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('allows submit when subscribe all is enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderEndpointSheet()
|
||||
|
||||
await user.type(getUrlInput(), 'https://hooks.example.com/billing')
|
||||
await user.click(screen.getByRole('checkbox', { name: /subscribe to all events/i }))
|
||||
submitForm()
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subscribeAll: true,
|
||||
eventTypes: PROJECT_EVENT_TYPES,
|
||||
url: 'https://hooks.example.com/billing',
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
+182
-175
@@ -39,11 +39,16 @@ import type {
|
||||
WebhookScope,
|
||||
} from './PlatformWebhooks.types'
|
||||
import { generateWebhookEndpointName } from './PlatformWebhooks.utils'
|
||||
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
|
||||
|
||||
const endpointFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().max(64, 'Name cannot exceed 64 characters'),
|
||||
url: z.string().trim().url('Please enter a valid URL'),
|
||||
url: httpEndpointUrlSchema({
|
||||
requiredMessage: 'Please provide a URL',
|
||||
invalidMessage: 'Please provide a valid URL',
|
||||
prefixMessage: 'Please prefix your URL with http:// or https://',
|
||||
}),
|
||||
description: z.string().trim().max(512, 'Description cannot exceed 512 characters'),
|
||||
enabled: z.boolean().default(true),
|
||||
subscribeAll: z.boolean().default(false),
|
||||
@@ -341,188 +346,190 @@ export const PlatformWebhooksEndpointSheet = ({
|
||||
<Separator />
|
||||
|
||||
<div className="px-5 space-y-3">
|
||||
<FormItemLayout
|
||||
label="Event types"
|
||||
description={
|
||||
scope === 'organization' ? (
|
||||
<>
|
||||
Project events are triggered when any project in this organization matches
|
||||
the event type. Add a{' '}
|
||||
<InlineLink href="/project/_/settings/webhooks">
|
||||
project endpoint
|
||||
</InlineLink>{' '}
|
||||
to listen to events on an individual project only.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Project events are triggered for this project only. Add an{' '}
|
||||
<InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
|
||||
organization endpoint
|
||||
</InlineLink>{' '}
|
||||
to listen to events from any project in your organization.
|
||||
</>
|
||||
)
|
||||
}
|
||||
layout="vertical"
|
||||
className="gap-3"
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="subscribeAll"
|
||||
render={({ field }) => {
|
||||
const subscribeAllId = 'subscribe-all-events'
|
||||
return (
|
||||
<div className="rounded-md border bg-surface-100 overflow-hidden">
|
||||
<Label
|
||||
htmlFor={subscribeAllId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
|
||||
field.value ? 'bg-surface-100' : 'bg-surface-200'
|
||||
)}
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="eventTypes"
|
||||
render={({ field, fieldState }) => {
|
||||
const selectedTypes = field.value ?? []
|
||||
const hasEventTypeError = !!fieldState.error
|
||||
|
||||
return (
|
||||
<FormItemLayout
|
||||
label="Event types"
|
||||
description={
|
||||
scope === 'organization' ? (
|
||||
<>
|
||||
Project events are triggered when any project in this organization
|
||||
matches the event type. Add a{' '}
|
||||
<InlineLink href="/project/_/settings/webhooks">
|
||||
project endpoint
|
||||
</InlineLink>{' '}
|
||||
to listen to events on an individual project only.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Project events are triggered for this project only. Add an{' '}
|
||||
<InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
|
||||
organization endpoint
|
||||
</InlineLink>{' '}
|
||||
to listen to events from any project in your organization.
|
||||
</>
|
||||
)
|
||||
}
|
||||
layout="vertical"
|
||||
className="gap-2"
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="subscribeAll"
|
||||
render={({ field }) => {
|
||||
const subscribeAllId = 'subscribe-all-events'
|
||||
return (
|
||||
<div className="rounded-md border bg-surface-100 overflow-hidden">
|
||||
<Label
|
||||
htmlFor={subscribeAllId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
|
||||
field.value ? 'bg-surface-100' : 'bg-surface-200'
|
||||
)}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Checkbox
|
||||
id={subscribeAllId}
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
const nextValue = Boolean(checked)
|
||||
field.onChange(nextValue)
|
||||
|
||||
if (nextValue) {
|
||||
form.setValue('eventTypes', eventTypes, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
form.setValue('eventTypes', [], {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
<span className="text-sm text-foreground">
|
||||
Subscribe to all events{' '}
|
||||
<code className="text-code-inline">(*)</code>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControl_Shadcn_>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={openEventGroups}
|
||||
onValueChange={setOpenEventGroups}
|
||||
className="mt-2 space-y-2"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Checkbox
|
||||
id={subscribeAllId}
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
const nextValue = Boolean(checked)
|
||||
field.onChange(nextValue)
|
||||
{groupedEventTypes.map((group) => {
|
||||
const selectedInGroup = group.eventTypes.filter((eventType) =>
|
||||
selectedTypes.includes(eventType)
|
||||
)
|
||||
const allSelected = selectedInGroup.length === group.eventTypes.length
|
||||
const isGroupOpen = openEventGroups.includes(group.id)
|
||||
|
||||
if (nextValue) {
|
||||
form.setValue('eventTypes', eventTypes, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
form.setValue('eventTypes', [], {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
<span className="text-sm text-foreground">
|
||||
Subscribe to all events <code className="text-code-inline">(*)</code>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="eventTypes"
|
||||
render={({ field }) => {
|
||||
const selectedTypes = field.value ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl_Shadcn_>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={openEventGroups}
|
||||
onValueChange={setOpenEventGroups}
|
||||
className="mt-2 space-y-2"
|
||||
>
|
||||
{groupedEventTypes.map((group) => {
|
||||
const selectedInGroup = group.eventTypes.filter((eventType) =>
|
||||
selectedTypes.includes(eventType)
|
||||
)
|
||||
const allSelected =
|
||||
selectedInGroup.length === group.eventTypes.length
|
||||
const isGroupOpen = openEventGroups.includes(group.id)
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
className="overflow-hidden rounded-md border"
|
||||
return (
|
||||
<AccordionItem
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border',
|
||||
hasEventTypeError && 'border-destructive-400'
|
||||
)}
|
||||
>
|
||||
<AccordionTrigger
|
||||
hideIcon
|
||||
className="group px-4 py-3 hover:no-underline"
|
||||
>
|
||||
<AccordionTrigger
|
||||
hideIcon
|
||||
className="group px-4 py-3 hover:no-underline"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-foreground-light">
|
||||
{group.label}
|
||||
</p>
|
||||
{selectedInGroup.length > 0 && (
|
||||
<span className="text-xs text-foreground-muted">
|
||||
{selectedInGroup.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isGroupOpen && group.eventTypes.length > 1 && (
|
||||
<span
|
||||
className="text-xs text-foreground-muted hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-foreground-light">
|
||||
{group.label}
|
||||
</p>
|
||||
{selectedInGroup.length > 0 && (
|
||||
<span className="text-xs text-foreground-muted">
|
||||
{selectedInGroup.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isGroupOpen && group.eventTypes.length > 1 && (
|
||||
<span
|
||||
className="text-xs text-foreground-muted hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
field.onChange(
|
||||
toggleEventTypeGroup(
|
||||
selectedTypes,
|
||||
group.eventTypes,
|
||||
!allSelected
|
||||
)
|
||||
)
|
||||
}}
|
||||
>
|
||||
{allSelected ? 'Clear all' : 'Select all'}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
|
||||
<div className="divide-y border-t">
|
||||
{group.eventTypes.map((eventType) => {
|
||||
const checked = selectedTypes.includes(eventType)
|
||||
const eventTypeId = toControlId('event-type', eventType)
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={eventType}
|
||||
htmlFor={eventTypeId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
|
||||
checked && 'bg-surface-100'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={eventTypeId}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => {
|
||||
field.onChange(
|
||||
toggleEventTypeGroup(
|
||||
toggleEventType(
|
||||
selectedTypes,
|
||||
group.eventTypes,
|
||||
!allSelected
|
||||
eventType,
|
||||
Boolean(next)
|
||||
)
|
||||
)
|
||||
}}
|
||||
>
|
||||
{allSelected ? 'Clear all' : 'Select all'}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
|
||||
<div className="divide-y border-t">
|
||||
{group.eventTypes.map((eventType) => {
|
||||
const checked = selectedTypes.includes(eventType)
|
||||
const eventTypeId = toControlId('event-type', eventType)
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={eventType}
|
||||
htmlFor={eventTypeId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
|
||||
checked && 'bg-surface-100'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={eventTypeId}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => {
|
||||
field.onChange(
|
||||
toggleEventType(
|
||||
selectedTypes,
|
||||
eventType,
|
||||
Boolean(next)
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<code className="text-code-inline">{eventType}</code>
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</FormControl_Shadcn_>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
/>
|
||||
<code className="text-code-inline">{eventType}</code>
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -19,3 +19,40 @@ export interface QueryPerformanceRow {
|
||||
_count?: number
|
||||
first_seen?: string
|
||||
}
|
||||
|
||||
export type QueryPerformancePreset =
|
||||
| 'mostFrequentlyInvoked'
|
||||
| 'mostTimeConsuming'
|
||||
| 'slowestExecutionTime'
|
||||
| 'queryHitRate'
|
||||
| 'unified'
|
||||
| 'slowQueriesCount'
|
||||
| 'queryMetrics'
|
||||
|
||||
export type QueryPerformanceSort = {
|
||||
column:
|
||||
| 'query'
|
||||
| 'rolname'
|
||||
| 'total_time'
|
||||
| 'prop_total_time'
|
||||
| 'calls'
|
||||
| 'avg_rows'
|
||||
| 'max_time'
|
||||
| 'mean_time'
|
||||
| 'min_time'
|
||||
order: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type QuerySource = 'dashboard' | 'non-dashboard'
|
||||
|
||||
export type QueryPerformanceSQLParams = {
|
||||
preset: QueryPerformancePreset
|
||||
orderBy?: QueryPerformanceSort
|
||||
searchQuery?: string
|
||||
roles?: string[]
|
||||
sources?: QuerySource[]
|
||||
minCalls?: number
|
||||
minTotalTime?: number
|
||||
runIndexAdvisor?: boolean
|
||||
filterIndexAdvisor?: boolean
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import { parseAsArrayOf, parseAsJson, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import {
|
||||
NumericFilter,
|
||||
ReportsNumericFilter,
|
||||
} from 'components/interfaces/Reports/v2/ReportsNumericFilter'
|
||||
import { useDebouncedValue } from 'hooks/misc/useDebouncedValue'
|
||||
import { parseAsArrayOf, parseAsJson, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { FilterInput } from './components/FilterInput'
|
||||
import { FilterPill } from './components/FilterPill'
|
||||
import { IndexAdvisorFilter } from './components/IndexAdvisorFilter'
|
||||
import { RolesFilterDropdown } from './components/RolesFilterDropdown'
|
||||
import { SortIndicator } from './components/SortIndicator'
|
||||
import { SourceFilterDropdown } from './components/SourceFilterDropdown'
|
||||
import { useIndexAdvisorStatus } from './hooks/useIsIndexAdvisorStatus'
|
||||
import { useQueryPerformanceSort } from './hooks/useQueryPerformanceSort'
|
||||
import { useDebouncedValue } from 'hooks/misc/useDebouncedValue'
|
||||
|
||||
export const QueryPerformanceFilterBar = ({
|
||||
actions,
|
||||
showRolesFilter = false,
|
||||
showSourceFilter = false,
|
||||
}: {
|
||||
actions?: ReactNode
|
||||
showRolesFilter?: boolean
|
||||
showSourceFilter?: boolean
|
||||
}) => {
|
||||
const { sort, clearSort } = useQueryPerformanceSort()
|
||||
const { isIndexAdvisorEnabled } = useIndexAdvisorStatus()
|
||||
@@ -27,6 +31,7 @@ export const QueryPerformanceFilterBar = ({
|
||||
{
|
||||
search: searchQuery,
|
||||
roles: defaultFilterRoles,
|
||||
sources: defaultFilterSources,
|
||||
callsFilter: callsFilterRaw,
|
||||
totalTimeFilter: totalTimeFilterRaw,
|
||||
indexAdvisor,
|
||||
@@ -35,6 +40,7 @@ export const QueryPerformanceFilterBar = ({
|
||||
] = useQueryStates({
|
||||
search: parseAsString.withDefault(''),
|
||||
roles: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
sources: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
callsFilter: parseAsJson<NumericFilter | null>((value) =>
|
||||
value === null || value === undefined ? null : (value as NumericFilter)
|
||||
),
|
||||
@@ -47,8 +53,9 @@ export const QueryPerformanceFilterBar = ({
|
||||
const callsFilter = callsFilterRaw ?? null
|
||||
const totalTimeFilter = totalTimeFilterRaw ?? null
|
||||
|
||||
const [filters, setFilters] = useState<{ roles: string[] }>({
|
||||
const [filters, setFilters] = useState<{ roles: string[]; sources: string[] }>({
|
||||
roles: defaultFilterRoles,
|
||||
sources: defaultFilterSources,
|
||||
})
|
||||
const [inputValue, setInputValue] = useState(searchQuery)
|
||||
|
||||
@@ -61,6 +68,11 @@ export const QueryPerformanceFilterBar = ({
|
||||
setSearchParams({ roles })
|
||||
}
|
||||
|
||||
const onFilterSourcesChange = (sources: string[]) => {
|
||||
setFilters({ ...filters, sources })
|
||||
setSearchParams({ sources })
|
||||
}
|
||||
|
||||
const debouncedInputValue = useDebouncedValue(inputValue, 300)
|
||||
|
||||
const onIndexAdvisorToggle = () => {
|
||||
@@ -139,8 +151,8 @@ export const QueryPerformanceFilterBar = ({
|
||||
value={filters.roles.join(', ')}
|
||||
onClear={(e) => {
|
||||
e.stopPropagation()
|
||||
setFilters({ roles: [] })
|
||||
setSearchParams({ roles: [] })
|
||||
setFilters({ ...filters, roles: [] })
|
||||
setSearchParams({ ...filters, roles: [] })
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -150,6 +162,13 @@ export const QueryPerformanceFilterBar = ({
|
||||
/>
|
||||
))}
|
||||
|
||||
{showSourceFilter && (
|
||||
<SourceFilterDropdown
|
||||
activeOptions={filters.sources}
|
||||
onSaveFilters={onFilterSourcesChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isIndexAdvisorEnabled && (
|
||||
<IndexAdvisorFilter
|
||||
isActive={indexAdvisor === 'true'}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { parseAsJson, useQueryStates } from 'nuqs'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
import { Skeleton, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { useQueryPerformanceQuery } from '../Reports/Reports.queries'
|
||||
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
|
||||
import { Info } from 'lucide-react'
|
||||
import { parseAsJson, useQueryStates } from 'nuqs'
|
||||
import React, { useMemo } from 'react'
|
||||
import { cn, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { useQueryPerformanceQuery } from './useQueryPerformanceQuery'
|
||||
|
||||
export const QueryPerformanceMetrics = () => {
|
||||
const { data: queryMetrics, isLoading } = useQueryPerformanceQuery({
|
||||
preset: 'queryMetrics',
|
||||
})
|
||||
const { data: queryMetrics, isLoading } = useQueryPerformanceQuery({ preset: 'queryMetrics' })
|
||||
|
||||
const [, setSearchParams] = useQueryStates({
|
||||
totalTimeFilter: parseAsJson<NumericFilter | null>((value) =>
|
||||
|
||||
+22
-22
@@ -1,31 +1,30 @@
|
||||
import { LoadingLine, cn } from 'ui'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
|
||||
import { Button } from 'ui'
|
||||
import { X, RefreshCw, RotateCcw } from 'lucide-react'
|
||||
import { Markdown } from '../../Markdown'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { toast } from 'sonner'
|
||||
import { PresetHookResult } from 'components/interfaces/Reports/Reports.utils'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { formatDatabaseID } from 'data/read-replicas/replicas.utils'
|
||||
import { PresetHookResult } from 'components/interfaces/Reports/Reports.utils'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
|
||||
import { QueryPerformanceMetrics } from '../QueryPerformanceMetrics'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
|
||||
import { getErrorMessage } from 'lib/get-error-message'
|
||||
import { RefreshCw, RotateCcw, X } from 'lucide-react'
|
||||
import { parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import { Button, cn, LoadingLine } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { Markdown } from '../../Markdown'
|
||||
import { captureQueryPerformanceError } from '../QueryPerformance.utils'
|
||||
import { QueryPerformanceFilterBar } from '../QueryPerformanceFilterBar'
|
||||
import { QueryPerformanceGrid } from '../QueryPerformanceGrid'
|
||||
import { QueryPerformanceMetrics } from '../QueryPerformanceMetrics'
|
||||
import { transformStatementDataToRows } from './WithStatements.utils'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { captureQueryPerformanceError } from '../QueryPerformance.utils'
|
||||
import { getErrorMessage } from 'lib/get-error-message'
|
||||
import { parseAsString, useQueryStates } from 'nuqs'
|
||||
|
||||
interface WithStatementsProps {
|
||||
queryHitRate: PresetHookResult
|
||||
@@ -155,7 +154,8 @@ export const WithStatements = ({
|
||||
)}
|
||||
<QueryPerformanceMetrics />
|
||||
<QueryPerformanceFilterBar
|
||||
showRolesFilter={true}
|
||||
showRolesFilter
|
||||
showSourceFilter
|
||||
actions={
|
||||
<>
|
||||
<ButtonTooltip
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { FilterPopover } from 'components/ui/FilterPopover'
|
||||
|
||||
interface SourceFilterDropdownProps {
|
||||
activeOptions: string[]
|
||||
onSaveFilters: (options: string[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sources = [
|
||||
{
|
||||
name: 'dashboard',
|
||||
displayName: 'Dashboard',
|
||||
},
|
||||
{
|
||||
name: 'non-dashboard',
|
||||
displayName: 'Non-dashboard',
|
||||
},
|
||||
]
|
||||
|
||||
export const SourceFilterDropdown = ({
|
||||
activeOptions,
|
||||
onSaveFilters,
|
||||
className,
|
||||
}: SourceFilterDropdownProps) => {
|
||||
return (
|
||||
<FilterPopover
|
||||
name="Source"
|
||||
options={sources}
|
||||
valueKey="name"
|
||||
labelKey="displayName"
|
||||
activeOptions={activeOptions}
|
||||
onSaveFilters={onSaveFilters}
|
||||
className={className || 'w-60'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useTableIndexAdvisor } from 'components/grid/context/TableIndexAdvisorContext'
|
||||
import { databaseIndexesKeys } from 'data/database-indexes/keys'
|
||||
import { databaseKeys } from 'data/database/keys'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import {
|
||||
QueryPerformanceSort,
|
||||
useQueryPerformanceQuery,
|
||||
} from 'components/interfaces/Reports/Reports.queries'
|
||||
import { databaseIndexesKeys } from 'data/database-indexes/keys'
|
||||
import { databaseKeys } from 'data/database/keys'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import {
|
||||
QUERY_PERFORMANCE_PRESET_MAP,
|
||||
QUERY_PERFORMANCE_REPORT_TYPES,
|
||||
} from '../QueryPerformance.constants'
|
||||
import { type QueryPerformanceSort } from '../QueryPerformance.types'
|
||||
import { useQueryPerformanceQuery } from '../useQueryPerformanceQuery'
|
||||
import { useIndexAdvisorStatus } from './useIsIndexAdvisorStatus'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import { useTableIndexAdvisor } from 'components/grid/context/TableIndexAdvisorContext'
|
||||
|
||||
export function useIndexInvalidation() {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQueryStates, parseAsString } from 'nuqs'
|
||||
import { QueryPerformanceSort } from '../../Reports/Reports.queries'
|
||||
import { parseAsString, useQueryStates } from 'nuqs'
|
||||
|
||||
import { type QueryPerformanceSort } from '../QueryPerformance.types'
|
||||
|
||||
export const useQueryPerformanceSort = () => {
|
||||
const [{ sort, order }, setQueryStates] = useQueryStates({
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { generateQueryPerformanceSql } from './useQueryPerformanceQuery'
|
||||
|
||||
describe('generateQueryPerformanceSql', () => {
|
||||
it('generates sql with no filters', () => {
|
||||
const result = generateQueryPerformanceSql({ preset: 'mostFrequentlyInvoked' })
|
||||
expect(result.sql).toBeDefined()
|
||||
expect(result.whereSql).toBe('')
|
||||
expect(result.orderBySql).toBeUndefined()
|
||||
})
|
||||
|
||||
it('generates ORDER BY clause', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
orderBy: { column: 'calls', order: 'desc' },
|
||||
})
|
||||
expect(result.orderBySql).toBe('ORDER BY calls desc')
|
||||
})
|
||||
|
||||
it('filters by roles', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
roles: ['postgres', 'anon'],
|
||||
})
|
||||
expect(result.whereSql).toContain("auth.rolname in ('postgres', 'anon')")
|
||||
})
|
||||
|
||||
it('filters by search query', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
searchQuery: 'SELECT',
|
||||
})
|
||||
expect(result.whereSql).toContain("statements.query ~* 'SELECT'")
|
||||
})
|
||||
|
||||
it('filters by dashboard source only', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
sources: ['dashboard'],
|
||||
})
|
||||
expect(result.whereSql).toContain("statements.query ~* 'source: dashboard'")
|
||||
})
|
||||
|
||||
it('filters by non-dashboard source only', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
sources: ['non-dashboard'],
|
||||
})
|
||||
expect(result.whereSql).toContain("statements.query !~* 'source: dashboard'")
|
||||
})
|
||||
|
||||
it('does not add source filter when both sources are selected', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
sources: ['dashboard', 'non-dashboard'],
|
||||
})
|
||||
expect(result.whereSql).not.toContain('source: dashboard')
|
||||
})
|
||||
|
||||
it('filters by minimum calls', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
minCalls: 10,
|
||||
})
|
||||
expect(result.whereSql).toContain('statements.calls >= 10')
|
||||
})
|
||||
|
||||
it('does not filter by minCalls when 0', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
minCalls: 0,
|
||||
})
|
||||
expect(result.whereSql).not.toContain('statements.calls')
|
||||
})
|
||||
|
||||
it('filters by minimum total time', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
minTotalTime: 500,
|
||||
})
|
||||
expect(result.whereSql).toContain(
|
||||
'(statements.total_exec_time + statements.total_plan_time) >= 500'
|
||||
)
|
||||
})
|
||||
|
||||
it('combines multiple WHERE conditions with AND', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
roles: ['postgres'],
|
||||
searchQuery: 'SELECT',
|
||||
minCalls: 5,
|
||||
})
|
||||
expect(result.whereSql).toContain("auth.rolname in ('postgres')")
|
||||
expect(result.whereSql).toContain("statements.query ~* 'SELECT'")
|
||||
expect(result.whereSql).toContain('statements.calls >= 5')
|
||||
expect(result.whereSql.split(' AND ').length).toBe(3)
|
||||
})
|
||||
|
||||
it('passes WHERE clause to base sql function', () => {
|
||||
const result = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
roles: ['postgres'],
|
||||
})
|
||||
expect(result.sql).toContain('WHERE')
|
||||
})
|
||||
|
||||
it('does not include user-defined WHERE conditions when no filters set', () => {
|
||||
const result = generateQueryPerformanceSql({ preset: 'mostFrequentlyInvoked' })
|
||||
expect(result.whereSql).toBe('')
|
||||
// The base SQL may still contain its own WHERE clause (e.g. statements.calls > 0)
|
||||
// but the user-defined whereSql should be empty
|
||||
})
|
||||
|
||||
it('passes runIndexAdvisor and filterIndexAdvisor flags', () => {
|
||||
const resultWithAdvisor = generateQueryPerformanceSql({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
runIndexAdvisor: true,
|
||||
filterIndexAdvisor: true,
|
||||
})
|
||||
expect(resultWithAdvisor.sql).toBeDefined()
|
||||
})
|
||||
|
||||
it('works with different presets', () => {
|
||||
const presets = [
|
||||
'mostFrequentlyInvoked',
|
||||
'mostTimeConsuming',
|
||||
'slowestExecutionTime',
|
||||
'queryHitRate',
|
||||
'unified',
|
||||
] as const
|
||||
|
||||
for (const preset of presets) {
|
||||
const result = generateQueryPerformanceSql({ preset })
|
||||
expect(result.sql).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import useDbQuery from 'hooks/analytics/useDbQuery'
|
||||
|
||||
import { PRESET_CONFIG } from '../Reports/Reports.constants'
|
||||
import { Presets } from '../Reports/Reports.types'
|
||||
import { QueryPerformanceSQLParams } from './QueryPerformance.types'
|
||||
|
||||
export function generateQueryPerformanceSql({
|
||||
preset,
|
||||
orderBy,
|
||||
searchQuery = '',
|
||||
roles = [],
|
||||
sources = [],
|
||||
minCalls = 0,
|
||||
minTotalTime = 0,
|
||||
runIndexAdvisor = false,
|
||||
filterIndexAdvisor = false,
|
||||
}: QueryPerformanceSQLParams) {
|
||||
const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
|
||||
const baseSQL = queryPerfQueries.queries[preset]
|
||||
|
||||
const orderBySql = orderBy && `ORDER BY ${orderBy.column} ${orderBy.order}`
|
||||
|
||||
const whereConditions = []
|
||||
if (roles.length > 0) {
|
||||
whereConditions.push(`auth.rolname in (${roles.map((r) => `'${r}'`).join(', ')})`)
|
||||
}
|
||||
if (searchQuery.length > 0) {
|
||||
whereConditions.push(`statements.query ~* '${searchQuery}'`)
|
||||
}
|
||||
if (sources.includes('dashboard') && !sources.includes('non-dashboard')) {
|
||||
whereConditions.push(`statements.query ~* 'source: dashboard'`)
|
||||
}
|
||||
if (sources.includes('non-dashboard') && !sources.includes('dashboard')) {
|
||||
whereConditions.push(`statements.query !~* 'source: dashboard'`)
|
||||
}
|
||||
if (minCalls > 0) {
|
||||
whereConditions.push(`statements.calls >= ${minCalls}`)
|
||||
}
|
||||
if (minTotalTime > 0) {
|
||||
whereConditions.push(
|
||||
`(statements.total_exec_time + statements.total_plan_time) >= ${minTotalTime}`
|
||||
)
|
||||
}
|
||||
|
||||
const whereSql = whereConditions.join(' AND ')
|
||||
|
||||
const sql = baseSQL.sql(
|
||||
[],
|
||||
whereSql.length > 0 ? `WHERE ${whereSql}` : undefined,
|
||||
orderBySql,
|
||||
runIndexAdvisor,
|
||||
filterIndexAdvisor
|
||||
)
|
||||
|
||||
return { sql, whereSql, orderBySql }
|
||||
}
|
||||
|
||||
export const useQueryPerformanceQuery = (props: QueryPerformanceSQLParams) => {
|
||||
const { sql, whereSql, orderBySql } = generateQueryPerformanceSql(props)
|
||||
return useDbQuery({ sql, params: undefined, where: whereSql, orderBy: orderBySql })
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import useDbQuery from 'hooks/analytics/useDbQuery'
|
||||
import { PRESET_CONFIG } from './Reports.constants'
|
||||
import { Presets } from './Reports.types'
|
||||
|
||||
export type QueryPerformanceSort = {
|
||||
column:
|
||||
| 'query'
|
||||
| 'rolname'
|
||||
| 'total_time'
|
||||
| 'prop_total_time'
|
||||
| 'calls'
|
||||
| 'avg_rows'
|
||||
| 'max_time'
|
||||
| 'mean_time'
|
||||
| 'min_time'
|
||||
order: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type QueryPerformanceQueryOpts = {
|
||||
preset:
|
||||
| 'mostFrequentlyInvoked'
|
||||
| 'mostTimeConsuming'
|
||||
| 'slowestExecutionTime'
|
||||
| 'queryHitRate'
|
||||
| 'unified'
|
||||
| 'slowQueriesCount'
|
||||
| 'queryMetrics'
|
||||
searchQuery?: string
|
||||
orderBy?: QueryPerformanceSort
|
||||
roles?: string[]
|
||||
runIndexAdvisor?: boolean
|
||||
minCalls?: number
|
||||
minTotalTime?: number
|
||||
filterIndexAdvisor?: boolean
|
||||
}
|
||||
|
||||
export const useQueryPerformanceQuery = ({
|
||||
preset,
|
||||
orderBy,
|
||||
searchQuery = '',
|
||||
roles,
|
||||
runIndexAdvisor = false,
|
||||
minCalls,
|
||||
minTotalTime,
|
||||
filterIndexAdvisor = false,
|
||||
}: QueryPerformanceQueryOpts) => {
|
||||
const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
|
||||
const baseSQL = queryPerfQueries.queries[preset]
|
||||
|
||||
const whereSql = [
|
||||
roles !== undefined && roles.length > 0
|
||||
? `auth.rolname in (${roles.map((r) => `'${r}'`).join(', ')})`
|
||||
: '',
|
||||
searchQuery.length > 0 ? `statements.query ~* '${searchQuery}'` : '',
|
||||
typeof minCalls === 'number' && minCalls > 0 ? `statements.calls >= ${minCalls}` : '',
|
||||
typeof minTotalTime === 'number' && minTotalTime > 0
|
||||
? `(statements.total_exec_time + statements.total_plan_time) >= ${minTotalTime}`
|
||||
: '',
|
||||
]
|
||||
.filter((x) => x.length > 0)
|
||||
.join(' AND ')
|
||||
|
||||
const orderBySql = orderBy && `ORDER BY ${orderBy.column} ${orderBy.order}`
|
||||
const sql = baseSQL.sql(
|
||||
[],
|
||||
whereSql.length > 0 ? `WHERE ${whereSql}` : undefined,
|
||||
orderBySql,
|
||||
runIndexAdvisor,
|
||||
filterIndexAdvisor
|
||||
)
|
||||
return useDbQuery({
|
||||
sql,
|
||||
params: undefined,
|
||||
where: whereSql,
|
||||
orderBy: orderBySql,
|
||||
})
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import {
|
||||
isUnixMicro,
|
||||
unixMicroToIsoTimestamp,
|
||||
} from 'components/interfaces/Settings/Logs/Logs.utils'
|
||||
import { REPORT_STATUS_CODE_COLORS } from 'data/reports/report.utils'
|
||||
import dayjs from 'dayjs'
|
||||
import useDbQuery, { DbQueryHook } from 'hooks/analytics/useDbQuery'
|
||||
import useLogsQuery, { LogsQueryHook } from 'hooks/analytics/useLogsQuery'
|
||||
import { getHttpStatusCodeInfo } from 'lib/http-status-codes'
|
||||
import type { BaseQueries, PresetConfig, ReportQuery } from './Reports.types'
|
||||
|
||||
import { type BaseQueries, type PresetConfig, type ReportQuery } from './Reports.types'
|
||||
|
||||
/**
|
||||
* Converts a query params string to an object
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getReturnToPath } from 'lib/gotrue'
|
||||
import { Lock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
@@ -34,12 +34,15 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
|
||||
const router = useRouter()
|
||||
const signOut = useSignOut()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [selectedFactor, setSelectedFactor] = useState<Factor | null>(null)
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { code: '' },
|
||||
})
|
||||
|
||||
const { code } = form.watch()
|
||||
|
||||
const {
|
||||
data: factors,
|
||||
error: factorsError,
|
||||
@@ -89,6 +92,10 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
|
||||
}
|
||||
}, [factors?.totp, isSuccessFactors, router, queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
if (code.length === 6) form.handleSubmit(onSubmit)()
|
||||
}, [code])
|
||||
|
||||
const error = useAuthError()
|
||||
|
||||
if (error) {
|
||||
@@ -134,7 +141,7 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
|
||||
</div>
|
||||
<Input_Shadcn_
|
||||
id="code"
|
||||
className="pl-10"
|
||||
className="pl-10 font-mono"
|
||||
{...field}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
@@ -150,7 +157,7 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<Button
|
||||
block
|
||||
type="outline"
|
||||
|
||||
+8
-6
@@ -1,12 +1,12 @@
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
|
||||
import type { PostgresColumn } from '@supabase/postgres-meta'
|
||||
import { isNull } from 'lodash'
|
||||
import type { Dictionary } from 'types'
|
||||
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
|
||||
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
|
||||
import type { RetrievedTableColumn, RetrieveTableResult } from 'data/tables/table-retrieve-query'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { isNull } from 'lodash'
|
||||
import { toast } from 'sonner'
|
||||
import type { Dictionary } from 'types'
|
||||
|
||||
import {
|
||||
ColumnField,
|
||||
CreateColumnPayload,
|
||||
@@ -32,7 +32,9 @@ const isSQLExpression = (input: string) => {
|
||||
return false
|
||||
}
|
||||
|
||||
export const generateColumnField = (field: any = {}): ColumnField => {
|
||||
export const generateColumnField = (
|
||||
field: { name?: string; table?: string; schema?: string; format?: string } = {}
|
||||
): ColumnField => {
|
||||
const { name, table, schema, format } = field
|
||||
return {
|
||||
id: uuidv4(),
|
||||
@@ -173,7 +175,7 @@ export const generateUpdateColumnPayload = (
|
||||
}
|
||||
|
||||
export const validateFields = (field: ColumnField) => {
|
||||
const errors = {} as Dictionary<any>
|
||||
const errors = {} as Dictionary<string>
|
||||
if (field.name.length === 0) {
|
||||
errors['name'] = `Please assign a name for your column`
|
||||
toast.error(errors['name'])
|
||||
|
||||
+26
-6
@@ -1,9 +1,29 @@
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
|
||||
|
||||
export const FOREIGN_KEY_CASCADE_OPTIONS = [
|
||||
{ key: 'no-action', label: 'No action', value: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION },
|
||||
{ key: 'cascade', label: 'Cascade', value: FOREIGN_KEY_CASCADE_ACTION.CASCADE },
|
||||
{ key: 'restrict', label: 'Restrict', value: FOREIGN_KEY_CASCADE_ACTION.RESTRICT },
|
||||
{ key: 'set-default', label: 'Set default', value: FOREIGN_KEY_CASCADE_ACTION.SET_DEFAULT },
|
||||
{ key: 'set-null', label: 'Set NULL', value: FOREIGN_KEY_CASCADE_ACTION.SET_NULL },
|
||||
{
|
||||
key: 'no-action',
|
||||
label: 'No action',
|
||||
value: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION,
|
||||
},
|
||||
{
|
||||
key: 'cascade',
|
||||
label: 'Cascade',
|
||||
value: FOREIGN_KEY_CASCADE_ACTION.CASCADE,
|
||||
},
|
||||
{
|
||||
key: 'restrict',
|
||||
label: 'Restrict',
|
||||
value: FOREIGN_KEY_CASCADE_ACTION.RESTRICT,
|
||||
},
|
||||
{
|
||||
key: 'set-default',
|
||||
label: 'Set default',
|
||||
value: FOREIGN_KEY_CASCADE_ACTION.SET_DEFAULT,
|
||||
},
|
||||
{
|
||||
key: 'set-null',
|
||||
label: 'Set NULL',
|
||||
value: FOREIGN_KEY_CASCADE_ACTION.SET_NULL,
|
||||
},
|
||||
]
|
||||
+356
-332
@@ -1,32 +1,39 @@
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
import { sortBy } from 'lodash'
|
||||
import { ArrowRight, Database, HelpCircle, Loader2, Table, X } from 'lucide-react'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Listbox,
|
||||
SidePanel,
|
||||
} from 'ui'
|
||||
|
||||
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useTableQuery } from 'data/tables/table-retrieve-query'
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { sortBy } from 'lodash'
|
||||
import { ArrowRight, Database, HelpCircle, Loader2, Table, X } from 'lucide-react'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Button,
|
||||
Listbox,
|
||||
SidePanel,
|
||||
} from 'ui'
|
||||
|
||||
import { ActionBar } from '../ActionBar'
|
||||
import { NUMERICAL_TYPES, TEXT_TYPES } from '../SidePanelEditor.constants'
|
||||
import type { ColumnField } from '../SidePanelEditor.types'
|
||||
import { FOREIGN_KEY_CASCADE_OPTIONS } from './ForeignKeySelector.constants'
|
||||
import type { ForeignKey, SelectorErrors, SelectorTypeError } from './ForeignKeySelector.types'
|
||||
import { generateCascadeActionDescription } from './ForeignKeySelector.utils'
|
||||
import {
|
||||
generateCascadeActionDescription,
|
||||
hasForeignKeySelectorChanges,
|
||||
normalizeForeignKeyForDirtyCheck,
|
||||
type ForeignKeyDirtyState,
|
||||
} from './ForeignKeySelector.utils'
|
||||
|
||||
const EMPTY_STATE: ForeignKey = {
|
||||
id: undefined,
|
||||
@@ -63,6 +70,7 @@ export const ForeignKeySelector = ({
|
||||
|
||||
const [fk, setFk] = useState(EMPTY_STATE)
|
||||
const [errors, setErrors] = useState<SelectorErrors>({})
|
||||
const [initialSnapshot, setInitialSnapshot] = useState<ForeignKeyDirtyState>()
|
||||
const hasTypeErrors = (errors.types ?? []).length > 0
|
||||
const hasTypeNotices = (errors.typeNotice ?? []).length > 0
|
||||
|
||||
@@ -93,6 +101,11 @@ export const ForeignKeySelector = ({
|
||||
|
||||
const disableApply = isLoadingSelectedTable || selectedTable === undefined || hasTypeErrors
|
||||
|
||||
const { confirmOnClose, modalProps } = useConfirmOnClose({
|
||||
checkIsDirty: () => hasForeignKeySelectorChanges(initialSnapshot, fk),
|
||||
onClose,
|
||||
})
|
||||
|
||||
const updateSelectedSchema = (schema: string) => {
|
||||
const updatedFk = { ...EMPTY_STATE, id: fk.id, schema }
|
||||
setFk(updatedFk)
|
||||
@@ -203,8 +216,11 @@ export const ForeignKeySelector = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (foreignKey !== undefined) setFk(foreignKey)
|
||||
else setFk({ ...EMPTY_STATE, id: uuidv4() })
|
||||
const initialFk = foreignKey !== undefined ? foreignKey : { ...EMPTY_STATE, id: uuidv4() }
|
||||
|
||||
setErrors({})
|
||||
setFk(initialFk)
|
||||
setInitialSnapshot(normalizeForeignKeyForDirtyCheck(initialFk))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible])
|
||||
@@ -215,137 +231,176 @@ export const ForeignKeySelector = ({
|
||||
}, [fk, visible])
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
className="max-w-[480px]"
|
||||
header={`${foreignKey === undefined ? 'Add' : 'Manage'} foreign key relationship${foreignKey === undefined ? ' to' : 's for'} ${table.name.length > 0 ? table.name : 'new table'}`}
|
||||
customFooter={
|
||||
<ActionBar
|
||||
backButtonLabel="Cancel"
|
||||
disableApply={disableApply}
|
||||
applyButtonLabel="Save"
|
||||
closePanel={onClose}
|
||||
applyFunction={(resolve) => validateSelection(resolve)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="py-6 space-y-6">
|
||||
<InformationBox
|
||||
icon={<HelpCircle size={20} strokeWidth={1.5} />}
|
||||
title="What are foreign keys?"
|
||||
description={`Foreign keys help maintain referential integrity of your data by ensuring that no
|
||||
<>
|
||||
<SidePanel
|
||||
visible={visible}
|
||||
onCancel={confirmOnClose}
|
||||
className="max-w-[480px]"
|
||||
header={`${foreignKey === undefined ? 'Add' : 'Manage'} foreign key relationship${foreignKey === undefined ? ' to' : 's for'} ${table.name.length > 0 ? table.name : 'new table'}`}
|
||||
customFooter={
|
||||
<ActionBar
|
||||
backButtonLabel="Cancel"
|
||||
disableApply={disableApply}
|
||||
applyButtonLabel="Save"
|
||||
closePanel={confirmOnClose}
|
||||
applyFunction={(resolve) => validateSelection(resolve)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="py-6 space-y-6">
|
||||
<InformationBox
|
||||
icon={<HelpCircle size={20} strokeWidth={1.5} />}
|
||||
title="What are foreign keys?"
|
||||
description={`Foreign keys help maintain referential integrity of your data by ensuring that no
|
||||
one can insert rows into the table that do not have a matching entry to another
|
||||
table.`}
|
||||
url="https://www.postgresql.org/docs/current/tutorial-fk.html"
|
||||
urlLabel="Postgres Foreign Key Documentation"
|
||||
/>
|
||||
url="https://www.postgresql.org/docs/current/tutorial-fk.html"
|
||||
urlLabel="Postgres Foreign Key Documentation"
|
||||
/>
|
||||
|
||||
<Listbox
|
||||
id="schema"
|
||||
label="Select a schema"
|
||||
value={fk.schema}
|
||||
onChange={(value: string) => updateSelectedSchema(value)}
|
||||
>
|
||||
{sortedSchemas.map((schema) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
label={schema.name}
|
||||
className="min-w-96"
|
||||
addOnBefore={() => <Database size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* For aria searching to target the schema name instead of schema */}
|
||||
<span className="hidden">{schema.name}</span>
|
||||
<span className="text-foreground">{schema.name}</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
<Listbox
|
||||
id="table"
|
||||
label="Select a table to reference to"
|
||||
value={selectedTable?.id ?? 1}
|
||||
onChange={(value: string) => updateSelectedTable(Number(value))}
|
||||
disabled={isLoadingSelectedTable}
|
||||
>
|
||||
<Listbox.Option key="empty" className="min-w-96" value={1} label="---">
|
||||
---
|
||||
</Listbox.Option>
|
||||
{sortBy(tables, ['schema']).map((table) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={table.id}
|
||||
value={table.id}
|
||||
label={table.name}
|
||||
className="min-w-96"
|
||||
addOnBefore={() => <Table size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* For aria searching to target the table name instead of schema */}
|
||||
<span className="hidden">{table.name}</span>
|
||||
<span className="text-foreground-lighter">{table.schema}</span>
|
||||
<span className="text-foreground">{table.name}</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
{fk.schema && fk.table && (
|
||||
<>
|
||||
{isLoadingSelectedTable ? (
|
||||
<div className="flex py-6 flex-col items-center justify-center space-y-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<p className="text-sm text-foreground-light">Loading table columns</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<label className="text-foreground-light text-sm">
|
||||
Select columns from{' '}
|
||||
<code className="text-code-inline">
|
||||
{fk.schema}.{fk.table}
|
||||
</code>{' '}
|
||||
to reference to
|
||||
</label>
|
||||
<div className="grid grid-cols-10 gap-y-2">
|
||||
<div className="col-span-5 text-xs text-foreground-lighter">
|
||||
{selectedSchema}.{table.name.length > 0 ? table.name : '[unnamed table]'}
|
||||
<Listbox
|
||||
id="schema"
|
||||
label="Select a schema"
|
||||
value={fk.schema}
|
||||
onChange={(value: string) => updateSelectedSchema(value)}
|
||||
>
|
||||
{sortedSchemas.map((schema) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
label={schema.name}
|
||||
className="min-w-96"
|
||||
addOnBefore={() => <Database size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* For aria searching to target the schema name instead of schema */}
|
||||
<span className="hidden">{schema.name}</span>
|
||||
<span className="text-foreground">{schema.name}</span>
|
||||
</div>
|
||||
<div className="col-span-4 text-xs text-foreground-lighter text-right">
|
||||
{fk.schema}.{fk.table}
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
<Listbox
|
||||
id="table"
|
||||
label="Select a table to reference to"
|
||||
value={selectedTable?.id ?? 1}
|
||||
onChange={(value: string) => updateSelectedTable(Number(value))}
|
||||
disabled={isLoadingSelectedTable}
|
||||
>
|
||||
<Listbox.Option key="empty" className="min-w-96" value={1} label="---">
|
||||
---
|
||||
</Listbox.Option>
|
||||
{sortBy(tables, ['schema']).map((table) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={table.id}
|
||||
value={table.id}
|
||||
label={table.name}
|
||||
className="min-w-96"
|
||||
addOnBefore={() => <Table size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* For aria searching to target the table name instead of schema */}
|
||||
<span className="hidden">{table.name}</span>
|
||||
<span className="text-foreground-lighter">{table.schema}</span>
|
||||
<span className="text-foreground">{table.name}</span>
|
||||
</div>
|
||||
{fk.columns.length === 0 && (
|
||||
<Alert_Shadcn_ className="col-span-10 py-2 px-3">
|
||||
<AlertDescription_Shadcn_>
|
||||
There are no foreign key relations between the tables
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{fk.columns.map((_, idx) => (
|
||||
<Fragment key={`${fk.schema}-${fk.table}-${idx}`}>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="column"
|
||||
value={fk.columns[idx].source}
|
||||
onChange={(value: string) => updateSelectedColumn(idx, 'source', value)}
|
||||
>
|
||||
<Listbox.Option
|
||||
key="empty"
|
||||
value={''}
|
||||
label="---"
|
||||
className="!w-[170px]"
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
{fk.schema && fk.table && (
|
||||
<>
|
||||
{isLoadingSelectedTable ? (
|
||||
<div className="flex py-6 flex-col items-center justify-center space-y-2">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<p className="text-sm text-foreground-light">Loading table columns</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<label className="text-foreground-light text-sm">
|
||||
Select columns from{' '}
|
||||
<code className="text-code-inline">
|
||||
{fk.schema}.{fk.table}
|
||||
</code>{' '}
|
||||
to reference to
|
||||
</label>
|
||||
<div className="grid grid-cols-10 gap-y-2">
|
||||
<div className="col-span-5 text-xs text-foreground-lighter">
|
||||
{selectedSchema}.{table.name.length > 0 ? table.name : '[unnamed table]'}
|
||||
</div>
|
||||
<div className="col-span-4 text-xs text-foreground-lighter text-right">
|
||||
{fk.schema}.{fk.table}
|
||||
</div>
|
||||
{fk.columns.length === 0 && (
|
||||
<Alert_Shadcn_ className="col-span-10 py-2 px-3">
|
||||
<AlertDescription_Shadcn_>
|
||||
There are no foreign key relations between the tables
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{fk.columns.map((_, idx) => (
|
||||
<Fragment key={`${fk.schema}-${fk.table}-${idx}`}>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="column"
|
||||
value={fk.columns[idx].source}
|
||||
onChange={(value: string) =>
|
||||
updateSelectedColumn(idx, 'source', value)
|
||||
}
|
||||
>
|
||||
---
|
||||
</Listbox.Option>
|
||||
{(table?.columns ?? [])
|
||||
.filter((x) => x.name.length !== 0)
|
||||
.map((column) => (
|
||||
<Listbox.Option
|
||||
key="empty"
|
||||
value={''}
|
||||
label="---"
|
||||
className="!w-[170px]"
|
||||
>
|
||||
---
|
||||
</Listbox.Option>
|
||||
{(table?.columns ?? [])
|
||||
.filter((x) => x.name.length !== 0)
|
||||
.map((column) => (
|
||||
<Listbox.Option
|
||||
key={column.id}
|
||||
value={column.name}
|
||||
label={column.name}
|
||||
className="!w-[170px]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">{column.name}</span>
|
||||
<span className="text-foreground-lighter">
|
||||
{column.format === '' ? '-' : column.format}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-center items-center">
|
||||
<ArrowRight />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="column"
|
||||
value={fk.columns[idx].target}
|
||||
onChange={(value: string) =>
|
||||
updateSelectedColumn(idx, 'target', value)
|
||||
}
|
||||
>
|
||||
<Listbox.Option
|
||||
key="empty"
|
||||
value={''}
|
||||
label="---"
|
||||
className="!w-[170px]"
|
||||
>
|
||||
---
|
||||
</Listbox.Option>
|
||||
{(selectedTable?.columns ?? []).map((column) => (
|
||||
<Listbox.Option
|
||||
key={column.id}
|
||||
value={column.name}
|
||||
@@ -354,208 +409,177 @@ export const ForeignKeySelector = ({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">{column.name}</span>
|
||||
<span className="text-foreground-lighter">
|
||||
{column.format === '' ? '-' : column.format}
|
||||
</span>
|
||||
<span className="text-foreground-lighter">{column.format}</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-center items-center">
|
||||
<ArrowRight />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="column"
|
||||
value={fk.columns[idx].target}
|
||||
onChange={(value: string) => updateSelectedColumn(idx, 'target', value)}
|
||||
>
|
||||
<Listbox.Option
|
||||
key="empty"
|
||||
value={''}
|
||||
label="---"
|
||||
className="!w-[170px]"
|
||||
>
|
||||
---
|
||||
</Listbox.Option>
|
||||
{(selectedTable?.columns ?? []).map((column) => (
|
||||
<Listbox.Option
|
||||
key={column.id}
|
||||
value={column.name}
|
||||
label={column.name}
|
||||
className="!w-[170px]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">{column.name}</span>
|
||||
<span className="text-foreground-lighter">{column.format}</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-end items-center">
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<X />}
|
||||
disabled={fk.columns.length === 1}
|
||||
onClick={() => onRemoveColumn(idx)}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-end items-center">
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<X />}
|
||||
disabled={fk.columns.length === 1}
|
||||
onClick={() => onRemoveColumn(idx)}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button type="default" onClick={addColumn}>
|
||||
Add another column
|
||||
</Button>
|
||||
{errors.columns && <p className="text-red-900 text-sm">{errors.columns}</p>}
|
||||
{hasTypeErrors && (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<AlertTitle_Shadcn_>Column types do not match</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The following columns cannot be referenced as they are not of the same
|
||||
type:
|
||||
</AlertDescription_Shadcn_>
|
||||
<ul className="list-disc pl-5 mt-2 text-foreground-light">
|
||||
{(errors?.types ?? []).map((x, idx: number) => {
|
||||
if (x === undefined) return null
|
||||
return (
|
||||
<li key={`type-error-${idx}`}>
|
||||
<code className="text-code-inline">{x.source}</code> (
|
||||
{x.sourceType}) and{' '}
|
||||
<code className="text-code-inline">{x.target}</code>(
|
||||
{x.targetType})
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{hasTypeNotices && (
|
||||
<Alert_Shadcn_>
|
||||
<AlertTitle_Shadcn_>Column types will be updated</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The following columns will have their types updated to match their
|
||||
referenced column
|
||||
</AlertDescription_Shadcn_>
|
||||
<ul className="list-disc pl-5 mt-2 text-foreground-light">
|
||||
{(errors?.typeNotice ?? []).map((x, idx: number) => {
|
||||
if (x === undefined) return null
|
||||
return (
|
||||
<li key={`type-error-${idx}`}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<code className="text-code-inline">{x.source}</code>{' '}
|
||||
<ArrowRight size={14} /> {x.targetType}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button type="default" onClick={addColumn}>
|
||||
Add another column
|
||||
</Button>
|
||||
{errors.columns && <p className="text-red-900 text-sm">{errors.columns}</p>}
|
||||
{hasTypeErrors && (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<AlertTitle_Shadcn_>Column types do not match</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The following columns cannot be referenced as they are not of the same
|
||||
type:
|
||||
</AlertDescription_Shadcn_>
|
||||
<ul className="list-disc pl-5 mt-2 text-foreground-light">
|
||||
{(errors?.types ?? []).map((x, idx: number) => {
|
||||
if (x === undefined) return null
|
||||
return (
|
||||
<li key={`type-error-${idx}`}>
|
||||
<code className="text-code-inline">{x.source}</code> ({x.sourceType}
|
||||
) and <code className="text-code-inline">{x.target}</code>(
|
||||
{x.targetType})
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{hasTypeNotices && (
|
||||
<Alert_Shadcn_>
|
||||
<AlertTitle_Shadcn_>Column types will be updated</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The following columns will have their types updated to match their
|
||||
referenced column
|
||||
</AlertDescription_Shadcn_>
|
||||
<ul className="list-disc pl-5 mt-2 text-foreground-light">
|
||||
{(errors?.typeNotice ?? []).map((x, idx: number) => {
|
||||
if (x === undefined) return null
|
||||
return (
|
||||
<li key={`type-error-${idx}`}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<code className="text-code-inline">{x.source}</code>{' '}
|
||||
<ArrowRight size={14} /> {x.targetType}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!isLoadingSelectedTable && (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
{!isLoadingSelectedTable && (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
|
||||
<InformationBox
|
||||
icon={<HelpCircle size="20" strokeWidth={1.5} />}
|
||||
title="Which action is most appropriate?"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
The choice of the action depends on what kinds of objects the related
|
||||
tables represent:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-4 space-y-1">
|
||||
<li>
|
||||
<code className="text-code-inline">Cascade</code>: if the referencing
|
||||
table represents something that is a component of what is represented by
|
||||
the referenced table and cannot exist independently
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-code-inline">Restrict</code> or{' '}
|
||||
<code className="text-code-inline">No action</code>: if the two tables
|
||||
represent independent objects
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-code-inline">Set NULL</code> or{' '}
|
||||
<code className="text-code-inline">Set default</code>: if a foreign-key
|
||||
relationship represents optional information
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
Typically, restricting and cascading deletes are the most common options,
|
||||
but the default behavior is no action
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
url="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK"
|
||||
urlLabel="More information"
|
||||
/>
|
||||
<InformationBox
|
||||
icon={<HelpCircle size="20" strokeWidth={1.5} />}
|
||||
title="Which action is most appropriate?"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
The choice of the action depends on what kinds of objects the related
|
||||
tables represent:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-4 space-y-1">
|
||||
<li>
|
||||
<code className="text-code-inline">Cascade</code>: if the referencing
|
||||
table represents something that is a component of what is represented
|
||||
by the referenced table and cannot exist independently
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-code-inline">Restrict</code> or{' '}
|
||||
<code className="text-code-inline">No action</code>: if the two tables
|
||||
represent independent objects
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-code-inline">Set NULL</code> or{' '}
|
||||
<code className="text-code-inline">Set default</code>: if a
|
||||
foreign-key relationship represents optional information
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
Typically, restricting and cascading deletes are the most common
|
||||
options, but the default behavior is no action
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
url="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK"
|
||||
urlLabel="More information"
|
||||
/>
|
||||
|
||||
<Listbox
|
||||
id="updateAction"
|
||||
value={fk.updateAction}
|
||||
label="Action if referenced row is updated"
|
||||
descriptionText={
|
||||
<p>
|
||||
{generateCascadeActionDescription(
|
||||
'update',
|
||||
fk.updateAction,
|
||||
`${fk.schema}.${fk.table}`
|
||||
)}
|
||||
</p>
|
||||
}
|
||||
onChange={(value: string) => updateCascadeAction('updateAction', value)}
|
||||
>
|
||||
{FOREIGN_KEY_CASCADE_OPTIONS.filter((option) =>
|
||||
['no-action', 'cascade', 'restrict'].includes(option.key)
|
||||
).map((option) => (
|
||||
<Listbox.Option key={option.key} value={option.value} label={option.label}>
|
||||
<p className="text-foreground">{option.label}</p>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
|
||||
<Listbox
|
||||
id="deletionAction"
|
||||
value={fk.deletionAction}
|
||||
className="[&>div>label]:flex [&>div>label]:items-center"
|
||||
label="Action if referenced row is removed"
|
||||
// @ts-ignore
|
||||
labelOptional={
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/postgres/cascade-deletes`} />
|
||||
}
|
||||
descriptionText={
|
||||
<>
|
||||
<Listbox
|
||||
id="updateAction"
|
||||
value={fk.updateAction}
|
||||
label="Action if referenced row is updated"
|
||||
descriptionText={
|
||||
<p>
|
||||
{generateCascadeActionDescription(
|
||||
'delete',
|
||||
fk.deletionAction,
|
||||
'update',
|
||||
fk.updateAction,
|
||||
`${fk.schema}.${fk.table}`
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
onChange={(value: string) => updateCascadeAction('deletionAction', value)}
|
||||
>
|
||||
{FOREIGN_KEY_CASCADE_OPTIONS.map((option) => (
|
||||
<Listbox.Option key={option.key} value={option.value} label={option.label}>
|
||||
<p className="text-foreground">{option.label}</p>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
}
|
||||
onChange={(value: string) => updateCascadeAction('updateAction', value)}
|
||||
>
|
||||
{FOREIGN_KEY_CASCADE_OPTIONS.filter((option) =>
|
||||
['no-action', 'cascade', 'restrict'].includes(option.key)
|
||||
).map((option) => (
|
||||
<Listbox.Option key={option.key} value={option.value} label={option.label}>
|
||||
<p className="text-foreground">{option.label}</p>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
|
||||
<Listbox
|
||||
id="deletionAction"
|
||||
value={fk.deletionAction}
|
||||
className="[&>div>label]:flex [&>div>label]:items-center"
|
||||
label="Action if referenced row is removed"
|
||||
// @ts-ignore
|
||||
labelOptional={
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/postgres/cascade-deletes`} />
|
||||
}
|
||||
descriptionText={
|
||||
<>
|
||||
<p>
|
||||
{generateCascadeActionDescription(
|
||||
'delete',
|
||||
fk.deletionAction,
|
||||
`${fk.schema}.${fk.table}`
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
onChange={(value: string) => updateCascadeAction('deletionAction', value)}
|
||||
>
|
||||
{FOREIGN_KEY_CASCADE_OPTIONS.map((option) => (
|
||||
<Listbox.Option key={option.key} value={option.value} label={option.label}>
|
||||
<p className="text-foreground">{option.label}</p>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
<DiscardChangesConfirmationDialog {...modalProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ForeignKey } from './ForeignKeySelector.types'
|
||||
import {
|
||||
hasForeignKeySelectorChanges,
|
||||
normalizeForeignKeyForDirtyCheck,
|
||||
} from './ForeignKeySelector.utils'
|
||||
|
||||
const BASE_FOREIGN_KEY: ForeignKey = {
|
||||
id: 'fk-1',
|
||||
name: 'messages_author_id_fkey',
|
||||
tableId: 42,
|
||||
schema: 'public',
|
||||
table: 'profiles',
|
||||
columns: [{ source: 'author_id', target: 'id' }],
|
||||
deletionAction: 'NO ACTION',
|
||||
updateAction: 'NO ACTION',
|
||||
}
|
||||
|
||||
describe('ForeignKeySelector dirty state', () => {
|
||||
it('is clean on first open', () => {
|
||||
const initialState = normalizeForeignKeyForDirtyCheck(BASE_FOREIGN_KEY)
|
||||
|
||||
expect(hasForeignKeySelectorChanges(initialState, BASE_FOREIGN_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('is dirty after semantic foreign key changes', () => {
|
||||
const initialState = normalizeForeignKeyForDirtyCheck(BASE_FOREIGN_KEY)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
schema: 'storage',
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
table: 'users',
|
||||
tableId: 99,
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
columns: [{ source: 'owner_id', target: 'id' }],
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
deletionAction: 'CASCADE',
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
updateAction: 'CASCADE',
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores derived column type metadata when checking dirty state', () => {
|
||||
const initialState = normalizeForeignKeyForDirtyCheck(BASE_FOREIGN_KEY)
|
||||
|
||||
expect(
|
||||
hasForeignKeySelectorChanges(initialState, {
|
||||
...BASE_FOREIGN_KEY,
|
||||
columns: [
|
||||
{
|
||||
source: 'author_id',
|
||||
sourceType: 'uuid',
|
||||
target: 'id',
|
||||
targetType: 'uuid',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
+36
-1
@@ -1,10 +1,23 @@
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
|
||||
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
|
||||
import { isEqual } from 'lodash'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { getForeignKeyCascadeAction } from '../ColumnEditor/ColumnEditor.utils'
|
||||
import type { ForeignKey } from './ForeignKeySelector.types'
|
||||
|
||||
export interface ForeignKeyDirtyState {
|
||||
id?: number | string
|
||||
name?: string
|
||||
tableId?: number
|
||||
schema: string
|
||||
table: string
|
||||
columns: { source: string; target: string }[]
|
||||
deletionAction: string
|
||||
updateAction: string
|
||||
}
|
||||
|
||||
export const formatForeignKeys = (fks: ForeignKeyConstraint[]): ForeignKey[] => {
|
||||
return fks.map((x) => {
|
||||
return {
|
||||
@@ -20,6 +33,28 @@ export const formatForeignKeys = (fks: ForeignKeyConstraint[]): ForeignKey[] =>
|
||||
})
|
||||
}
|
||||
|
||||
export const normalizeForeignKeyForDirtyCheck = (foreignKey: ForeignKey): ForeignKeyDirtyState => {
|
||||
return {
|
||||
id: foreignKey.id,
|
||||
name: foreignKey.name,
|
||||
tableId: foreignKey.tableId,
|
||||
schema: foreignKey.schema,
|
||||
table: foreignKey.table,
|
||||
columns: foreignKey.columns.map(({ source, target }) => ({ source, target })),
|
||||
deletionAction: foreignKey.deletionAction,
|
||||
updateAction: foreignKey.updateAction,
|
||||
}
|
||||
}
|
||||
|
||||
export const hasForeignKeySelectorChanges = (
|
||||
initialState: ForeignKeyDirtyState | undefined,
|
||||
foreignKey: ForeignKey
|
||||
) => {
|
||||
if (initialState === undefined) return false
|
||||
|
||||
return !isEqual(initialState, normalizeForeignKeyForDirtyCheck(foreignKey))
|
||||
}
|
||||
|
||||
export const generateCascadeActionDescription = (
|
||||
action: 'update' | 'delete',
|
||||
cascadeAction: string,
|
||||
|
||||
+3
-5
@@ -1,8 +1,9 @@
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from '@supabase/pg-meta'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
|
||||
import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types'
|
||||
import type { ColumnField } from './SidePanelEditor.types'
|
||||
import { createTable } from './SidePanelEditor.utils'
|
||||
|
||||
// Define mock functions at module level
|
||||
const mockExecuteSql = vi.fn()
|
||||
@@ -51,9 +52,6 @@ vi.mock('components/ui/SparkBar', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
// Import after mocks are set up
|
||||
import { createTable } from './SidePanelEditor.utils'
|
||||
|
||||
// Helper to create a column field with defaults
|
||||
const createColumnField = (overrides: Partial<ColumnField> = {}): ColumnField => ({
|
||||
id: 'col-1',
|
||||
@@ -306,7 +304,7 @@ describe('createTable', () => {
|
||||
const sqlCall = mockExecuteSql.mock.calls[0][0]
|
||||
expect(sqlCall.sql).toContain('ADD FOREIGN KEY')
|
||||
expect(sqlCall.sql).toContain('REFERENCES')
|
||||
expect(sqlCall.sql).toContain('"users"')
|
||||
expect(sqlCall.sql).toContain('users')
|
||||
expect(sqlCall.sql).toContain('ON DELETE CASCADE')
|
||||
})
|
||||
|
||||
|
||||
+37
-113
@@ -1,5 +1,16 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
import pgMeta, {
|
||||
getAddForeignKeySQL,
|
||||
getAddPrimaryKeySQL,
|
||||
getDropConstraintSQL,
|
||||
getDuplicateIdentitySequenceSQL,
|
||||
getDuplicateRowsSQL,
|
||||
getDuplicateTableSQL,
|
||||
getEnableRLSSQL,
|
||||
getRemoveForeignKeySQL,
|
||||
getUpdateIdentitySequenceSQL,
|
||||
type ForeignKey,
|
||||
} from '@supabase/pg-meta'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import type { PostgresPrimaryKey } from '@supabase/postgres-meta'
|
||||
import type { SupaRow } from 'components/grid/types'
|
||||
@@ -10,7 +21,6 @@ import { deleteDatabaseColumn } from 'data/database-columns/database-column-dele
|
||||
import { updateDatabaseColumn } from 'data/database-columns/database-column-update-mutation'
|
||||
import { createDatabasePolicy } from 'data/database-policies/database-policy-create-mutation'
|
||||
import type { Constraint } from 'data/database/constraints-query'
|
||||
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
|
||||
import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
|
||||
import { databaseKeys } from 'data/database/keys'
|
||||
import { entityTypeKeys } from 'data/entity-types/keys'
|
||||
@@ -24,10 +34,10 @@ import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { executeWithRetry } from 'data/table-rows/table-rows-query'
|
||||
import { tableKeys } from 'data/tables/keys'
|
||||
import {
|
||||
RetrieveTableResult,
|
||||
RetrievedTableColumn,
|
||||
getTable,
|
||||
getTableQuery,
|
||||
RetrievedTableColumn,
|
||||
RetrieveTableResult,
|
||||
} from 'data/tables/table-retrieve-query'
|
||||
import {
|
||||
UpdateTableBody,
|
||||
@@ -45,7 +55,6 @@ import {
|
||||
generateCreateColumnPayload,
|
||||
generateUpdateColumnPayload,
|
||||
} from './ColumnEditor/ColumnEditor.utils'
|
||||
import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types'
|
||||
import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types'
|
||||
import { checkIfRelationChanged } from './TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils'
|
||||
import type { ImportContent } from './TableEditor/TableEditor.types'
|
||||
@@ -81,23 +90,6 @@ export function getRowFromSidePanel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The functions below are basically just queries but may be supported directly
|
||||
* from the pg-meta library in the future
|
||||
*/
|
||||
const getAddPrimaryKeySQL = ({
|
||||
schema,
|
||||
table,
|
||||
columns,
|
||||
}: {
|
||||
schema: string
|
||||
table: string
|
||||
columns: string[]
|
||||
}) => {
|
||||
const primaryKeyColumns = columns.map((col) => `"${col}"`).join(', ')
|
||||
return `ALTER TABLE "${schema}"."${table}" ADD PRIMARY KEY (${primaryKeyColumns})`
|
||||
}
|
||||
|
||||
const addPrimaryKey = async (
|
||||
projectRef: string,
|
||||
connectionString: string | undefined | null,
|
||||
@@ -121,7 +113,7 @@ const dropConstraint = async (
|
||||
table: string,
|
||||
name: string
|
||||
) => {
|
||||
const query = `ALTER TABLE "${schema}"."${table}" DROP CONSTRAINT "${name}"`
|
||||
const query = getDropConstraintSQL({ schema, table, name })
|
||||
return await executeSql({
|
||||
projectRef: projectRef,
|
||||
connectionString: connectionString,
|
||||
@@ -130,49 +122,6 @@ const dropConstraint = async (
|
||||
})
|
||||
}
|
||||
|
||||
const getAddForeignKeySQL = ({
|
||||
table,
|
||||
foreignKeys,
|
||||
}: {
|
||||
table: { schema: string; name: string }
|
||||
foreignKeys: ForeignKey[]
|
||||
}) => {
|
||||
const getOnDeleteSql = (action: string) =>
|
||||
action === FOREIGN_KEY_CASCADE_ACTION.CASCADE
|
||||
? 'ON DELETE CASCADE'
|
||||
: action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT
|
||||
? 'ON DELETE RESTRICT'
|
||||
: action === FOREIGN_KEY_CASCADE_ACTION.SET_DEFAULT
|
||||
? 'ON DELETE SET DEFAULT'
|
||||
: action === FOREIGN_KEY_CASCADE_ACTION.SET_NULL
|
||||
? 'ON DELETE SET NULL'
|
||||
: ''
|
||||
const getOnUpdateSql = (action: string) =>
|
||||
action === FOREIGN_KEY_CASCADE_ACTION.CASCADE
|
||||
? 'ON UPDATE CASCADE'
|
||||
: action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT
|
||||
? 'ON UPDATE RESTRICT'
|
||||
: ''
|
||||
return (
|
||||
foreignKeys
|
||||
.map((relation) => {
|
||||
const { deletionAction, updateAction } = relation
|
||||
const onDeleteSql = getOnDeleteSql(deletionAction)
|
||||
const onUpdateSql = getOnUpdateSql(updateAction)
|
||||
return `
|
||||
ALTER TABLE "${table.schema}"."${table.name}"
|
||||
ADD FOREIGN KEY (${relation.columns.map((column) => `"${column.source}"`).join(',')})
|
||||
REFERENCES "${relation.schema}"."${relation.table}" (${relation.columns.map((column) => `"${column.target}"`).join(',')})
|
||||
${onUpdateSql}
|
||||
${onDeleteSql}
|
||||
`
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
})
|
||||
.join(';') + ';'
|
||||
)
|
||||
}
|
||||
|
||||
const addForeignKey = async ({
|
||||
projectRef,
|
||||
connectionString,
|
||||
@@ -193,27 +142,6 @@ const addForeignKey = async ({
|
||||
})
|
||||
}
|
||||
|
||||
const getRemoveForeignKeySQL = ({
|
||||
table,
|
||||
foreignKeys,
|
||||
}: {
|
||||
table: { schema: string; name: string }
|
||||
foreignKeys: ForeignKey[]
|
||||
}) => {
|
||||
return (
|
||||
foreignKeys
|
||||
.map((relation) =>
|
||||
`
|
||||
ALTER TABLE IF EXISTS "${table.schema}"."${table.name}"
|
||||
DROP CONSTRAINT IF EXISTS "${relation.name}"
|
||||
`
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
)
|
||||
.join(';') + ';'
|
||||
)
|
||||
}
|
||||
|
||||
const removeForeignKey = async ({
|
||||
projectRef,
|
||||
connectionString,
|
||||
@@ -259,22 +187,6 @@ const updateForeignKey = async ({
|
||||
})
|
||||
}
|
||||
|
||||
const getUpdateIdentitySequenceSQL = ({
|
||||
schema,
|
||||
table,
|
||||
column,
|
||||
}: {
|
||||
schema: string
|
||||
table: string
|
||||
column: string
|
||||
}) => {
|
||||
return `SELECT setval('"${schema}"."${table}_${column}_seq"', (SELECT COALESCE(MAX("${column}"), 1) FROM "${schema}"."${table}"))`
|
||||
}
|
||||
|
||||
const getEnableRLSSQL = ({ schema, table }: { schema: string; table: string }) => {
|
||||
return `ALTER TABLE "${schema}"."${table}" ENABLE ROW LEVEL SECURITY`
|
||||
}
|
||||
|
||||
/**
|
||||
* The methods below involve several contexts due to the UI flow of the
|
||||
* dashboard and hence do not sit within their own stores
|
||||
@@ -350,7 +262,7 @@ export const createColumn = async ({
|
||||
toast.success(`Successfully created column "${formattedPayload.name}"`, { id: toastId })
|
||||
}
|
||||
return { error: undefined }
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
toast.error(`An error occurred while creating the column "${payload.name}"`, { id: toastId })
|
||||
return { error }
|
||||
}
|
||||
@@ -458,12 +370,12 @@ export const duplicateTable = async (
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: [
|
||||
`CREATE TABLE "${sourceTableSchema}"."${duplicatedTableName}" (LIKE "${sourceTableSchema}"."${sourceTableName}" INCLUDING ALL);`,
|
||||
payload.comment != undefined
|
||||
? `comment on table "${sourceTableSchema}"."${duplicatedTableName}" is '${payload.comment}';`
|
||||
: '',
|
||||
].join('\n'),
|
||||
sql: getDuplicateTableSQL({
|
||||
sourceTableName,
|
||||
sourceTableSchema,
|
||||
duplicatedTableName,
|
||||
comment: payload.comment,
|
||||
}),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: tableKeys.list(projectRef, sourceTableSchema) })
|
||||
|
||||
@@ -482,7 +394,11 @@ export const duplicateTable = async (
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `INSERT INTO "${sourceTableSchema}"."${duplicatedTableName}" SELECT * FROM "${sourceTableSchema}"."${sourceTableName}";`,
|
||||
sql: getDuplicateRowsSQL({
|
||||
sourceTableName,
|
||||
sourceTableSchema,
|
||||
duplicatedTableName,
|
||||
}),
|
||||
})
|
||||
|
||||
// Insert into does not copy over auto increment sequences, so we manually do it next if any
|
||||
@@ -492,7 +408,12 @@ export const duplicateTable = async (
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `SELECT setval('"${sourceTableSchema}"."${duplicatedTableName}_${column.name}_seq"', (SELECT MAX("${column.name}") FROM "${sourceTableSchema}"."${sourceTableName}"));`,
|
||||
sql: getDuplicateIdentitySequenceSQL({
|
||||
sourceTableName,
|
||||
sourceTableSchema,
|
||||
duplicatedTableName,
|
||||
columnName: column.name,
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -560,7 +481,10 @@ export const createTable = async ({
|
||||
|
||||
// 2. Enable RLS if configured
|
||||
if (isRLSEnabled) {
|
||||
const enableRLSSQL = getEnableRLSSQL({ schema: payload.schema, table: payload.name })
|
||||
const enableRLSSQL = getEnableRLSSQL({
|
||||
schema: payload.schema,
|
||||
table: payload.name,
|
||||
})
|
||||
sqlStatements.push(enableRLSSQL)
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -21,6 +21,7 @@ import { Admonition } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { defaultPrivilegesQueryOptions } from '@/data/privileges/default-privileges-query'
|
||||
import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
|
||||
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
|
||||
@@ -282,6 +283,7 @@ export const ApiAccessToggle = ({
|
||||
isNewRecord,
|
||||
handler,
|
||||
}: ApiAccessToggleComponentProps): ReactNode => {
|
||||
const track = useTrack()
|
||||
const isPending = handler.isPending
|
||||
const isError = handler.isError
|
||||
const isSchemaExposed = handler.data?.schemaExposed
|
||||
@@ -295,6 +297,13 @@ export const ApiAccessToggle = ({
|
||||
if (!handler.isSuccess) return
|
||||
if (!isSchemaExposed) return
|
||||
|
||||
if (isNewRecord) {
|
||||
track('table_api_access_toggle_clicked', {
|
||||
newState: checked ? 'enabled' : 'disabled',
|
||||
schemaName: schemaName ?? 'unknown',
|
||||
})
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
handler.data?.restorePreviousPrivileges()
|
||||
} else {
|
||||
|
||||
+7
-7
@@ -16,7 +16,7 @@ import { useFloatingToolbarNavSize } from './useFloatingToolbarNavSize'
|
||||
import { useFloatingToolbarSheet } from './useFloatingToolbarSheet'
|
||||
import { useFloatingToolbarSidebarClick } from './useFloatingToolbarSidebarClick'
|
||||
|
||||
const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) => {
|
||||
export const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) => {
|
||||
const navRef = useRef<HTMLElement | null>(null)
|
||||
const sheet = useFloatingToolbarSheet(hideMobileMenu)
|
||||
const drag = useFloatingToolbarDrag(navRef)
|
||||
@@ -58,6 +58,7 @@ const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean })
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<ButtonTooltip
|
||||
key="search"
|
||||
type={isSearchOpen ? 'secondary' : 'outline'}
|
||||
size="tiny"
|
||||
id="search-trigger"
|
||||
@@ -80,24 +81,25 @@ const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean })
|
||||
>
|
||||
<Search size={16} strokeWidth={1} />
|
||||
</ButtonTooltip>
|
||||
<span data-sidebar-id={SIDEBAR_KEYS.HELP_PANEL}>
|
||||
<span key="help" data-sidebar-id={SIDEBAR_KEYS.HELP_PANEL}>
|
||||
<HelpButton />
|
||||
</span>
|
||||
<span data-sidebar-id={SIDEBAR_KEYS.ADVISOR_PANEL}>
|
||||
<span key="advisor" data-sidebar-id={SIDEBAR_KEYS.ADVISOR_PANEL}>
|
||||
<AdvisorButton projectRef={projectRef} />
|
||||
</span>
|
||||
{!!projectRef && (
|
||||
<>
|
||||
<span data-sidebar-id={SIDEBAR_KEYS.EDITOR_PANEL}>
|
||||
<span key="editor" data-sidebar-id={SIDEBAR_KEYS.EDITOR_PANEL}>
|
||||
<InlineEditorButton />
|
||||
</span>
|
||||
<span data-sidebar-id={SIDEBAR_KEYS.AI_ASSISTANT}>
|
||||
<span key="assistant" data-sidebar-id={SIDEBAR_KEYS.AI_ASSISTANT}>
|
||||
<AssistantButton />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{sheet.showMenuButton && sheet.isSheetOpen && (
|
||||
<Button
|
||||
key="menu"
|
||||
title="Menu dropdown button"
|
||||
type={sheet.isMenuOpen ? 'secondary' : 'default'}
|
||||
className={cn(
|
||||
@@ -129,5 +131,3 @@ const FloatingMobileToolbar = ({ hideMobileMenu }: { hideMobileMenu?: boolean })
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default FloatingMobileToolbar
|
||||
@@ -4,7 +4,7 @@ import { ConnectButton } from 'components/interfaces/ConnectButton/ConnectButton
|
||||
import { LocalDropdown } from 'components/interfaces/LocalDropdown'
|
||||
import { SidebarContent } from 'components/interfaces/Sidebar'
|
||||
import { UserDropdown } from 'components/interfaces/UserDropdown'
|
||||
import FloatingMobileToolbar from 'components/layouts/Navigation/FloatingMobileToolbar/FloatingMobileToolbar'
|
||||
import { FloatingMobileToolbar } from 'components/layouts/Navigation/FloatingMobileToolbar/FloatingMobileToolbar'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { Project } from 'data/projects/project-detail-query'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
generateOtherRoutes,
|
||||
generateProductRoutes,
|
||||
generateSettingsRoutes,
|
||||
generateToolRoutes,
|
||||
} from './NavigationBar.utils'
|
||||
|
||||
const REF = 'test-project-ref'
|
||||
|
||||
const activeProject = { status: 'ACTIVE_HEALTHY' } as Project
|
||||
const buildingProject = { status: 'COMING_UP' } as Project
|
||||
const inactiveProject = { status: 'INACTIVE' } as Project
|
||||
|
||||
const keys = (routes: { key: string }[]) => routes.map((r) => r.key)
|
||||
|
||||
describe('generateToolRoutes', () => {
|
||||
it('always returns Table Editor and SQL Editor', () => {
|
||||
const routes = generateToolRoutes(REF, activeProject)
|
||||
expect(keys(routes)).toEqual(['editor', 'sql'])
|
||||
})
|
||||
|
||||
it('marks routes as disabled when project is not active', () => {
|
||||
const routes = generateToolRoutes(REF, inactiveProject)
|
||||
expect(routes.every((r) => r.disabled)).toBe(true)
|
||||
})
|
||||
|
||||
it('points links to the building URL when project is building', () => {
|
||||
const routes = generateToolRoutes(REF, buildingProject)
|
||||
expect(routes.every((r) => r.link === `/project/${REF}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns links as false when ref is undefined', () => {
|
||||
const routes = generateToolRoutes(undefined, activeProject)
|
||||
expect(routes.every((r) => r.link === undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateProductRoutes', () => {
|
||||
it('includes all product routes when all features are enabled', () => {
|
||||
const routes = generateProductRoutes(REF, activeProject, {
|
||||
auth: true,
|
||||
storage: true,
|
||||
edgeFunctions: true,
|
||||
realtime: true,
|
||||
})
|
||||
expect(keys(routes)).toEqual(['database', 'auth', 'storage', 'functions', 'realtime'])
|
||||
})
|
||||
|
||||
it('includes all product routes by default (features default to true)', () => {
|
||||
const routes = generateProductRoutes(REF, activeProject)
|
||||
expect(keys(routes)).toEqual(['database', 'auth', 'storage', 'functions', 'realtime'])
|
||||
})
|
||||
|
||||
it('excludes auth when auth feature is disabled', () => {
|
||||
const routes = generateProductRoutes(REF, activeProject, { auth: false })
|
||||
expect(keys(routes)).not.toContain('auth')
|
||||
|
||||
expect(keys(routes)).toContain('database')
|
||||
expect(keys(routes)).toContain('storage')
|
||||
})
|
||||
|
||||
it('excludes storage when storage feature is disabled', () => {
|
||||
const routes = generateProductRoutes(REF, activeProject, { storage: false })
|
||||
expect(keys(routes)).not.toContain('storage')
|
||||
})
|
||||
|
||||
it('excludes edge functions when edgeFunctions feature is disabled', () => {
|
||||
const routes = generateProductRoutes(REF, activeProject, { edgeFunctions: false })
|
||||
expect(keys(routes)).not.toContain('functions')
|
||||
})
|
||||
|
||||
it('excludes realtime when realtime feature is disabled', () => {
|
||||
const routes = generateProductRoutes(REF, activeProject, { realtime: false })
|
||||
expect(keys(routes)).not.toContain('realtime')
|
||||
})
|
||||
|
||||
it('links auth to overview page when authOverviewPage is enabled', () => {
|
||||
const routes = generateProductRoutes(REF, activeProject, { authOverviewPage: true })
|
||||
const authRoute = routes.find((r) => r.key === 'auth')
|
||||
expect(authRoute?.link).toBe(`/project/${REF}/auth/overview`)
|
||||
})
|
||||
|
||||
it('links auth to users page by default', () => {
|
||||
const routes = generateProductRoutes(REF, activeProject)
|
||||
const authRoute = routes.find((r) => r.key === 'auth')
|
||||
expect(authRoute?.link).toBe(`/project/${REF}/auth/users`)
|
||||
})
|
||||
|
||||
it('always includes database even when all optional features are disabled', () => {
|
||||
const routes = generateProductRoutes(REF, activeProject, {
|
||||
auth: false,
|
||||
storage: false,
|
||||
edgeFunctions: false,
|
||||
realtime: false,
|
||||
})
|
||||
expect(keys(routes)).toEqual(['database'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateOtherRoutes', () => {
|
||||
it('always includes advisors, logs, and integrations', () => {
|
||||
const routes = generateOtherRoutes(REF, activeProject, { isPlatform: true })
|
||||
expect(keys(routes)).toContain('advisors')
|
||||
expect(keys(routes)).toContain('logs')
|
||||
expect(keys(routes)).toContain('integrations')
|
||||
})
|
||||
|
||||
it('includes observability on platform when reports are enabled', () => {
|
||||
const routes = generateOtherRoutes(REF, activeProject, {
|
||||
isPlatform: true,
|
||||
showReports: true,
|
||||
})
|
||||
expect(keys(routes)).toContain('observability')
|
||||
})
|
||||
|
||||
it('excludes observability on platform when reports are disabled', () => {
|
||||
const routes = generateOtherRoutes(REF, activeProject, {
|
||||
isPlatform: true,
|
||||
showReports: false,
|
||||
})
|
||||
expect(keys(routes)).not.toContain('observability')
|
||||
})
|
||||
|
||||
it('excludes observability in self-hosted mode even when reports are enabled', () => {
|
||||
const routes = generateOtherRoutes(REF, activeProject, {
|
||||
isPlatform: false,
|
||||
showReports: true,
|
||||
})
|
||||
expect(keys(routes)).not.toContain('observability')
|
||||
})
|
||||
|
||||
it('excludes observability in self-hosted mode when reports are disabled', () => {
|
||||
const routes = generateOtherRoutes(REF, activeProject, {
|
||||
isPlatform: false,
|
||||
showReports: false,
|
||||
})
|
||||
expect(keys(routes)).not.toContain('observability')
|
||||
})
|
||||
|
||||
it('includes API Docs when apiDocsSidePanel is enabled', () => {
|
||||
const routes = generateOtherRoutes(REF, activeProject, {
|
||||
isPlatform: true,
|
||||
apiDocsSidePanel: true,
|
||||
})
|
||||
expect(keys(routes)).toContain('api')
|
||||
})
|
||||
|
||||
it('excludes API Docs when apiDocsSidePanel is disabled', () => {
|
||||
const routes = generateOtherRoutes(REF, activeProject, {
|
||||
isPlatform: true,
|
||||
apiDocsSidePanel: false,
|
||||
})
|
||||
expect(keys(routes)).not.toContain('api')
|
||||
})
|
||||
|
||||
it('links logs to unified logs page when unifiedLogs is enabled', () => {
|
||||
const routes = generateOtherRoutes(REF, activeProject, {
|
||||
isPlatform: true,
|
||||
unifiedLogs: true,
|
||||
})
|
||||
const logsRoute = routes.find((r) => r.key === 'logs')
|
||||
expect(logsRoute?.link).toBe(`/project/${REF}/logs`)
|
||||
})
|
||||
|
||||
it('links logs to explorer page by default', () => {
|
||||
const routes = generateOtherRoutes(REF, activeProject, { isPlatform: true })
|
||||
const logsRoute = routes.find((r) => r.key === 'logs')
|
||||
expect(logsRoute?.link).toBe(`/project/${REF}/logs/explorer`)
|
||||
})
|
||||
|
||||
it('points links to building URL when project is building', () => {
|
||||
const routes = generateOtherRoutes(REF, buildingProject, {
|
||||
isPlatform: true,
|
||||
showReports: true,
|
||||
})
|
||||
const observabilityRoute = routes.find((r) => r.key === 'observability')
|
||||
expect(observabilityRoute?.link).toBe(`/project/${REF}`)
|
||||
})
|
||||
|
||||
it('marks routes as disabled when project is not active', () => {
|
||||
const routes = generateOtherRoutes(REF, inactiveProject, { isPlatform: true })
|
||||
const advisorsRoute = routes.find((r) => r.key === 'advisors')
|
||||
expect(advisorsRoute?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateSettingsRoutes', () => {
|
||||
it('links to general settings on platform', () => {
|
||||
const routes = generateSettingsRoutes(REF, { isPlatform: true })
|
||||
const settingsRoute = routes.find((r) => r.key === 'settings')
|
||||
expect(settingsRoute?.link).toBe(`/project/${REF}/settings/general`)
|
||||
})
|
||||
|
||||
it('links to log-drains settings in self-hosted mode', () => {
|
||||
const routes = generateSettingsRoutes(REF, { isPlatform: false })
|
||||
const settingsRoute = routes.find((r) => r.key === 'settings')
|
||||
expect(settingsRoute?.link).toBe(`/project/${REF}/settings/log-drains`)
|
||||
})
|
||||
|
||||
it('returns a link as false when ref is undefined', () => {
|
||||
const routes = generateSettingsRoutes(undefined, { isPlatform: true })
|
||||
const settingsRoute = routes.find((r) => r.key === 'settings')
|
||||
expect(settingsRoute?.link).toBe(undefined)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar'
|
||||
import { useGenerateSettingsMenu } from 'components/layouts/ProjectSettingsLayout/SettingsMenu.utils'
|
||||
import type { Route } from 'components/ui/ui.types'
|
||||
import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor'
|
||||
import type { Project } from 'data/projects/project-detail-query'
|
||||
@@ -7,10 +6,43 @@ import { Auth, Database, EdgeFunctions, Realtime, SqlEditor, Storage, TableEdito
|
||||
import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
|
||||
import { Blocks, FileText, Lightbulb, List, Settings, Telescope } from 'lucide-react'
|
||||
|
||||
export const generateToolRoutes = (ref?: string, project?: Project, features?: {}): Route[] => {
|
||||
const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
|
||||
const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP
|
||||
const buildingUrl = `/project/${ref}`
|
||||
interface RouteContext {
|
||||
ref?: string
|
||||
isProjectActive: boolean
|
||||
isProjectBuilding: boolean
|
||||
buildingUrl: string
|
||||
}
|
||||
|
||||
interface ProductFeatures {
|
||||
auth?: boolean
|
||||
edgeFunctions?: boolean
|
||||
storage?: boolean
|
||||
realtime?: boolean
|
||||
authOverviewPage?: boolean
|
||||
}
|
||||
|
||||
interface OtherFeatures {
|
||||
isPlatform?: boolean
|
||||
unifiedLogs?: boolean
|
||||
showReports?: boolean
|
||||
apiDocsSidePanel?: boolean
|
||||
}
|
||||
|
||||
interface SettingsFeatures {
|
||||
isPlatform?: boolean
|
||||
}
|
||||
|
||||
function getRouteContext(ref?: string, project?: Project): RouteContext {
|
||||
return {
|
||||
ref,
|
||||
isProjectActive: project?.status === PROJECT_STATUS.ACTIVE_HEALTHY,
|
||||
isProjectBuilding: project?.status === PROJECT_STATUS.COMING_UP,
|
||||
buildingUrl: `/project/${ref}`,
|
||||
}
|
||||
}
|
||||
|
||||
export const generateToolRoutes = (ref?: string, project?: Project): Route[] => {
|
||||
const { isProjectActive, isProjectBuilding, buildingUrl } = getRouteContext(ref, project)
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -34,17 +66,9 @@ export const generateToolRoutes = (ref?: string, project?: Project, features?: {
|
||||
export const generateProductRoutes = (
|
||||
ref?: string,
|
||||
project?: Project,
|
||||
features?: {
|
||||
auth?: boolean
|
||||
edgeFunctions?: boolean
|
||||
storage?: boolean
|
||||
realtime?: boolean
|
||||
authOverviewPage?: boolean
|
||||
}
|
||||
features?: ProductFeatures
|
||||
): Route[] => {
|
||||
const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
|
||||
const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP
|
||||
const buildingUrl = `/project/${ref}`
|
||||
const { isProjectActive, isProjectBuilding, buildingUrl } = getRouteContext(ref, project)
|
||||
|
||||
const authEnabled = features?.auth ?? true
|
||||
const edgeFunctionsEnabled = features?.edgeFunctions ?? true
|
||||
@@ -122,16 +146,14 @@ export const generateProductRoutes = (
|
||||
export const generateOtherRoutes = (
|
||||
ref?: string,
|
||||
project?: Project,
|
||||
features?: { unifiedLogs?: boolean; showReports?: boolean; apiDocsSidePanel?: boolean }
|
||||
features?: OtherFeatures
|
||||
): Route[] => {
|
||||
const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
|
||||
const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP
|
||||
const buildingUrl = `/project/${ref}`
|
||||
const { isProjectActive, isProjectBuilding, buildingUrl } = getRouteContext(ref, project)
|
||||
|
||||
const { unifiedLogs, showReports, apiDocsSidePanel } = features ?? {}
|
||||
const unifiedLogsEnabled = unifiedLogs ?? false
|
||||
const reportsEnabled = showReports ?? true
|
||||
const apiDocsSidePanelEnabled = apiDocsSidePanel ?? false
|
||||
const isPlatform = features?.isPlatform ?? IS_PLATFORM
|
||||
const unifiedLogsEnabled = features?.unifiedLogs ?? false
|
||||
const reportsEnabled = features?.showReports ?? true
|
||||
const apiDocsSidePanelEnabled = features?.apiDocsSidePanel ?? false
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -142,18 +164,16 @@ export const generateOtherRoutes = (
|
||||
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/advisors/security`),
|
||||
},
|
||||
// Observability is only available on the platform, not for self-hosted/CLI
|
||||
...(IS_PLATFORM
|
||||
? reportsEnabled
|
||||
? [
|
||||
{
|
||||
key: 'observability',
|
||||
label: 'Observability',
|
||||
disabled: !isProjectActive,
|
||||
icon: <Telescope size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
|
||||
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/observability`),
|
||||
},
|
||||
]
|
||||
: []
|
||||
...(isPlatform && reportsEnabled
|
||||
? [
|
||||
{
|
||||
key: 'observability',
|
||||
label: 'Observability',
|
||||
disabled: !isProjectActive,
|
||||
icon: <Telescope size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
|
||||
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/observability`),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'logs',
|
||||
@@ -185,7 +205,9 @@ export const generateOtherRoutes = (
|
||||
]
|
||||
}
|
||||
|
||||
export const generateSettingsRoutes = (ref?: string): Route[] => {
|
||||
export const generateSettingsRoutes = (ref?: string, features?: SettingsFeatures): Route[] => {
|
||||
const isPlatform = features?.isPlatform ?? IS_PLATFORM
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'settings',
|
||||
@@ -193,7 +215,7 @@ export const generateSettingsRoutes = (ref?: string): Route[] => {
|
||||
icon: <Settings size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
|
||||
link:
|
||||
ref &&
|
||||
(IS_PLATFORM ? `/project/${ref}/settings/general` : `/project/${ref}/settings/log-drains`),
|
||||
(isPlatform ? `/project/${ref}/settings/general` : `/project/${ref}/settings/log-drains`),
|
||||
disabled: false,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -20,9 +20,10 @@ import { cn, Menu } from 'ui'
|
||||
import { InnerSideBarEmptyPanel } from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus'
|
||||
|
||||
import { generateObservabilityMenuItems } from './ObservabilityMenu.utils'
|
||||
import { ObservabilityMenuItem } from './ObservabilityMenuItem'
|
||||
import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus'
|
||||
|
||||
const ObservabilityMenu = () => {
|
||||
const router = useRouter()
|
||||
@@ -124,87 +125,14 @@ const ObservabilityMenu = () => {
|
||||
|
||||
const reportMenuItems = getReportMenuItems()
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: 'GENERAL',
|
||||
key: 'general-section',
|
||||
items: [
|
||||
...(showOverview
|
||||
? [
|
||||
{
|
||||
name: 'Overview',
|
||||
key: 'observability',
|
||||
url: `/project/${ref}/observability${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isSupamonitorEnabled
|
||||
? [
|
||||
{
|
||||
name: 'Query Insights',
|
||||
key: 'query-insights',
|
||||
url: `/project/${ref}/observability/query-insights${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Query Performance',
|
||||
key: 'query-performance',
|
||||
url: `/project/${ref}/observability/query-performance${preservedQueryParams}`,
|
||||
},
|
||||
]),
|
||||
...(IS_PLATFORM
|
||||
? [
|
||||
{
|
||||
name: 'API Gateway',
|
||||
key: 'api-overview',
|
||||
url: `/project/${ref}/observability/api-overview${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'PRODUCT',
|
||||
key: 'product-section',
|
||||
items: [
|
||||
{
|
||||
name: 'Database',
|
||||
key: 'database',
|
||||
url: `/project/${ref}/observability/database${preservedQueryParams}`,
|
||||
},
|
||||
{
|
||||
name: 'Data API',
|
||||
key: 'postgrest',
|
||||
url: `/project/${ref}/observability/postgrest${preservedQueryParams}`,
|
||||
},
|
||||
{
|
||||
name: 'Auth',
|
||||
key: 'auth',
|
||||
url: `/project/${ref}/observability/auth${preservedQueryParams}`,
|
||||
},
|
||||
{
|
||||
name: 'Edge Functions',
|
||||
key: 'edge-functions',
|
||||
url: `/project/${ref}/observability/edge-functions${preservedQueryParams}`,
|
||||
},
|
||||
...(storageSupported
|
||||
? [
|
||||
{
|
||||
name: 'Storage',
|
||||
key: 'storage',
|
||||
url: `/project/${ref}/observability/storage${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Realtime',
|
||||
key: 'realtime',
|
||||
url: `/project/${ref}/observability/realtime${preservedQueryParams}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const menuItems = generateObservabilityMenuItems({
|
||||
ref,
|
||||
preservedQueryParams,
|
||||
showOverview,
|
||||
isSupamonitorEnabled,
|
||||
storageSupported,
|
||||
isPlatform: IS_PLATFORM,
|
||||
})
|
||||
|
||||
return (
|
||||
<Menu type="pills" className="mt-6">
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
generateObservabilityMenuItems,
|
||||
type ObservabilityMenuSection,
|
||||
} from './ObservabilityMenu.utils'
|
||||
|
||||
const REF = 'test-project-ref'
|
||||
const QUERY_PARAMS = ''
|
||||
|
||||
const sectionTitles = (sections: ObservabilityMenuSection[]) => sections.map((s) => s.title)
|
||||
|
||||
const itemKeys = (section: ObservabilityMenuSection | undefined) =>
|
||||
section?.items.map((i) => i.key) ?? []
|
||||
|
||||
const findSection = (sections: ObservabilityMenuSection[], title: string) =>
|
||||
sections.find((s) => s.title === title)
|
||||
|
||||
describe('generateObservabilityMenuItems - PRODUCT section', () => {
|
||||
it('includes PRODUCT section on platform', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: true,
|
||||
})
|
||||
|
||||
expect(sectionTitles(menu)).toContain('PRODUCT')
|
||||
const productSection = findSection(menu, 'PRODUCT')
|
||||
expect(itemKeys(productSection)).toEqual([
|
||||
'database',
|
||||
'postgrest',
|
||||
'auth',
|
||||
'edge-functions',
|
||||
'storage',
|
||||
'realtime',
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes PRODUCT section in self-hosted mode', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: false,
|
||||
})
|
||||
|
||||
expect(sectionTitles(menu)).not.toContain('PRODUCT')
|
||||
expect(menu.length).toBe(1) // Only GENERAL section
|
||||
})
|
||||
|
||||
it('excludes Storage from PRODUCT when storageSupported is false', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: false,
|
||||
isPlatform: true,
|
||||
})
|
||||
|
||||
const productSection = findSection(menu, 'PRODUCT')
|
||||
expect(itemKeys(productSection)).not.toContain('storage')
|
||||
expect(itemKeys(productSection)).toEqual([
|
||||
'database',
|
||||
'postgrest',
|
||||
'auth',
|
||||
'edge-functions',
|
||||
'realtime',
|
||||
])
|
||||
})
|
||||
|
||||
it('includes Storage in PRODUCT when storageSupported is true', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: true,
|
||||
})
|
||||
|
||||
const productSection = findSection(menu, 'PRODUCT')
|
||||
expect(itemKeys(productSection)).toContain('storage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateObservabilityMenuItems - GENERAL section', () => {
|
||||
it('always includes GENERAL section', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: false,
|
||||
})
|
||||
|
||||
expect(sectionTitles(menu)).toContain('GENERAL')
|
||||
})
|
||||
|
||||
it('includes Query Performance when supamonitor is disabled', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: false,
|
||||
})
|
||||
|
||||
const generalSection = findSection(menu, 'GENERAL')
|
||||
expect(itemKeys(generalSection)).toContain('query-performance')
|
||||
expect(itemKeys(generalSection)).not.toContain('query-insights')
|
||||
})
|
||||
|
||||
it('includes Query Insights when supamonitor is enabled', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: true,
|
||||
storageSupported: true,
|
||||
isPlatform: false,
|
||||
})
|
||||
|
||||
const generalSection = findSection(menu, 'GENERAL')
|
||||
expect(itemKeys(generalSection)).toContain('query-insights')
|
||||
expect(itemKeys(generalSection)).not.toContain('query-performance')
|
||||
})
|
||||
|
||||
it('includes Overview when showOverview is true', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: true,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: false,
|
||||
})
|
||||
|
||||
const generalSection = findSection(menu, 'GENERAL')
|
||||
expect(itemKeys(generalSection)).toContain('observability')
|
||||
})
|
||||
|
||||
it('excludes Overview when showOverview is false', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: false,
|
||||
})
|
||||
|
||||
const generalSection = findSection(menu, 'GENERAL')
|
||||
expect(itemKeys(generalSection)).not.toContain('observability')
|
||||
})
|
||||
|
||||
it('includes API Gateway on platform', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: true,
|
||||
})
|
||||
|
||||
const generalSection = findSection(menu, 'GENERAL')
|
||||
expect(itemKeys(generalSection)).toContain('api-overview')
|
||||
})
|
||||
|
||||
it('excludes API Gateway in self-hosted mode', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: false,
|
||||
})
|
||||
|
||||
const generalSection = findSection(menu, 'GENERAL')
|
||||
expect(itemKeys(generalSection)).not.toContain('api-overview')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateObservabilityMenuItems - URL construction', () => {
|
||||
it('constructs correct URLs with preserved query params', () => {
|
||||
const params = '?its=2024-01-01&ite=2024-01-31'
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: params,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: true,
|
||||
})
|
||||
|
||||
const generalSection = findSection(menu, 'GENERAL')
|
||||
const queryPerfItem = generalSection?.items.find((i) => i.key === 'query-performance')
|
||||
expect(queryPerfItem?.url).toBe(`/project/${REF}/observability/query-performance${params}`)
|
||||
|
||||
const productSection = findSection(menu, 'PRODUCT')
|
||||
const databaseItem = productSection?.items.find((i) => i.key === 'database')
|
||||
expect(databaseItem?.url).toBe(`/project/${REF}/observability/database${params}`)
|
||||
})
|
||||
|
||||
it('handles undefined ref', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: undefined,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: false,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: true,
|
||||
})
|
||||
|
||||
const generalSection = findSection(menu, 'GENERAL')
|
||||
const queryPerfItem = generalSection?.items.find((i) => i.key === 'query-performance')
|
||||
expect(queryPerfItem?.url).toBe('/project/undefined/observability/query-performance')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateObservabilityMenuItems - complete structure', () => {
|
||||
it('returns only GENERAL in self-hosted with all standard items', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: true,
|
||||
isSupamonitorEnabled: false,
|
||||
storageSupported: true,
|
||||
isPlatform: false,
|
||||
})
|
||||
|
||||
expect(menu.length).toBe(1)
|
||||
expect(menu[0].title).toBe('GENERAL')
|
||||
expect(itemKeys(menu[0])).toEqual([
|
||||
'observability', // Overview
|
||||
'query-performance',
|
||||
// API Gateway excluded
|
||||
])
|
||||
})
|
||||
|
||||
it('returns GENERAL and PRODUCT on platform with all items', () => {
|
||||
const menu = generateObservabilityMenuItems({
|
||||
ref: REF,
|
||||
preservedQueryParams: QUERY_PARAMS,
|
||||
showOverview: true,
|
||||
isSupamonitorEnabled: true,
|
||||
storageSupported: true,
|
||||
isPlatform: true,
|
||||
})
|
||||
|
||||
expect(menu.length).toBe(2)
|
||||
expect(sectionTitles(menu)).toEqual(['GENERAL', 'PRODUCT'])
|
||||
|
||||
const generalSection = findSection(menu, 'GENERAL')
|
||||
expect(itemKeys(generalSection)).toEqual([
|
||||
'observability', // Overview
|
||||
'query-insights', // Supamonitor enabled
|
||||
'api-overview', // Platform only
|
||||
])
|
||||
|
||||
const productSection = findSection(menu, 'PRODUCT')
|
||||
expect(itemKeys(productSection)).toEqual([
|
||||
'database',
|
||||
'postgrest',
|
||||
'auth',
|
||||
'edge-functions',
|
||||
'storage',
|
||||
'realtime',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
|
||||
interface ObservabilityMenuItem {
|
||||
name: string
|
||||
key: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ObservabilityMenuSection {
|
||||
title: string
|
||||
key: string
|
||||
items: ObservabilityMenuItem[]
|
||||
}
|
||||
|
||||
interface GenerateObservabilityMenuOptions {
|
||||
ref: string | undefined
|
||||
preservedQueryParams: string
|
||||
showOverview: boolean
|
||||
isSupamonitorEnabled: boolean
|
||||
storageSupported: boolean
|
||||
isPlatform?: boolean
|
||||
}
|
||||
|
||||
export function generateObservabilityMenuItems(
|
||||
options: GenerateObservabilityMenuOptions
|
||||
): ObservabilityMenuSection[] {
|
||||
const {
|
||||
ref,
|
||||
preservedQueryParams,
|
||||
showOverview,
|
||||
isSupamonitorEnabled,
|
||||
storageSupported,
|
||||
isPlatform = IS_PLATFORM,
|
||||
} = options
|
||||
|
||||
const generalItems: ObservabilityMenuItem[] = [
|
||||
...(showOverview
|
||||
? [
|
||||
{
|
||||
name: 'Overview',
|
||||
key: 'observability',
|
||||
url: `/project/${ref}/observability${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isSupamonitorEnabled
|
||||
? [
|
||||
{
|
||||
name: 'Query Insights',
|
||||
key: 'query-insights',
|
||||
url: `/project/${ref}/observability/query-insights${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Query Performance',
|
||||
key: 'query-performance',
|
||||
url: `/project/${ref}/observability/query-performance${preservedQueryParams}`,
|
||||
},
|
||||
]),
|
||||
...(isPlatform
|
||||
? [
|
||||
{
|
||||
name: 'API Gateway',
|
||||
key: 'api-overview',
|
||||
url: `/project/${ref}/observability/api-overview${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
const productItems: ObservabilityMenuItem[] = [
|
||||
{
|
||||
name: 'Database',
|
||||
key: 'database',
|
||||
url: `/project/${ref}/observability/database${preservedQueryParams}`,
|
||||
},
|
||||
{
|
||||
name: 'Data API',
|
||||
key: 'postgrest',
|
||||
url: `/project/${ref}/observability/postgrest${preservedQueryParams}`,
|
||||
},
|
||||
{
|
||||
name: 'Auth',
|
||||
key: 'auth',
|
||||
url: `/project/${ref}/observability/auth${preservedQueryParams}`,
|
||||
},
|
||||
{
|
||||
name: 'Edge Functions',
|
||||
key: 'edge-functions',
|
||||
url: `/project/${ref}/observability/edge-functions${preservedQueryParams}`,
|
||||
},
|
||||
...(storageSupported
|
||||
? [
|
||||
{
|
||||
name: 'Storage',
|
||||
key: 'storage',
|
||||
url: `/project/${ref}/observability/storage${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Realtime',
|
||||
key: 'realtime',
|
||||
url: `/project/${ref}/observability/realtime${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
|
||||
const sections: ObservabilityMenuSection[] = [
|
||||
{
|
||||
title: 'GENERAL',
|
||||
key: 'general-section',
|
||||
items: generalItems,
|
||||
},
|
||||
]
|
||||
|
||||
if (isPlatform) {
|
||||
sections.push({
|
||||
title: 'PRODUCT',
|
||||
key: 'product-section',
|
||||
items: productItems,
|
||||
})
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import { CheckCircle, Download, Loader } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { SupportLink } from 'components/interfaces/Support/SupportLink'
|
||||
@@ -9,11 +6,24 @@ import { useBackupDownloadMutation } from 'data/database/backup-download-mutatio
|
||||
import { useDownloadableBackupQuery } from 'data/database/backup-query'
|
||||
import { useInvalidateProjectDetailsQuery } from 'data/projects/project-detail-query'
|
||||
import { useProjectStatusQuery } from 'data/projects/project-status-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { Button } from 'ui'
|
||||
import { CheckCircle, Download, Loader } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from 'ui'
|
||||
|
||||
const RestoringState = () => {
|
||||
export const RestoringState = () => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
@@ -37,12 +47,8 @@ const RestoringState = () => {
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProjectStatusSuccess) return
|
||||
if (projectStatusData.status === PROJECT_STATUS.ACTIVE_HEALTHY) {
|
||||
setIsCompleted(true)
|
||||
}
|
||||
}, [isProjectStatusSuccess, projectStatusData, ref, invalidateProjectDetailsQuery])
|
||||
const restoreInitiatedSinceMinutes = dayjs().diff(dayjs.utc(project?.updated_at), 'minute')
|
||||
const showSupportCta = restoreInitiatedSinceMinutes >= 30
|
||||
|
||||
const { mutate: downloadBackup, isPending: isDownloading } = useBackupDownloadMutation({
|
||||
onSuccess: (res) => {
|
||||
@@ -70,6 +76,12 @@ const RestoringState = () => {
|
||||
if (ref) await invalidateProjectDetailsQuery(ref)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isProjectStatusSuccess && projectStatusData.status === PROJECT_STATUS.ACTIVE_HEALTHY) {
|
||||
setIsCompleted(true)
|
||||
}
|
||||
}, [isProjectStatusSuccess, projectStatusData])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="bg-surface-100 border border-overlay rounded-md w-3/4 lg:w-1/2">
|
||||
@@ -110,17 +122,43 @@ const RestoringState = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-overlay flex items-center justify-end py-4 px-8 gap-x-2">
|
||||
<Button asChild type="default">
|
||||
<SupportLink
|
||||
queryParams={{
|
||||
category: SupportCategories.DATABASE_UNRESPONSIVE,
|
||||
projectRef: project?.ref,
|
||||
subject: 'Ongoing restoration for project',
|
||||
}}
|
||||
>
|
||||
Contact support
|
||||
</SupportLink>
|
||||
</Button>
|
||||
{showSupportCta && (
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button type="text" className="text-foreground-light">
|
||||
Taking longer than expected?
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restoration taking longer than expected?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection>
|
||||
<p className="text-sm">
|
||||
Restores may take from a minutes up to several hours depending on the size
|
||||
of your database. However, if the restoration process is taking far longer
|
||||
than expected for the size of your database, you may reach out to us via
|
||||
Support for help.
|
||||
</p>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button asChild type="default">
|
||||
<SupportLink
|
||||
queryParams={{
|
||||
category: SupportCategories.DATABASE_UNRESPONSIVE,
|
||||
projectRef: project?.ref,
|
||||
subject: 'Ongoing restoration for project',
|
||||
}}
|
||||
>
|
||||
Contact support
|
||||
</SupportLink>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
icon={<Download />}
|
||||
@@ -144,5 +182,3 @@ const RestoringState = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RestoringState
|
||||
@@ -36,7 +36,7 @@ export const UpgradingState = () => {
|
||||
{
|
||||
projectRef: ref,
|
||||
projectStatus: project?.status,
|
||||
trackingId: queryParams.get('trackingId'),
|
||||
trackingId: queryParams?.get('trackingId'),
|
||||
},
|
||||
{
|
||||
enabled: IS_PLATFORM,
|
||||
|
||||
@@ -34,7 +34,7 @@ import { PausingState } from './PausingState'
|
||||
import { ResizingState } from './ResizingState'
|
||||
import RestartingState from './RestartingState'
|
||||
import { RestoreFailedState } from './RestoreFailedState'
|
||||
import RestoringState from './RestoringState'
|
||||
import { RestoringState } from './RestoringState'
|
||||
import { UpgradingState } from './UpgradingState'
|
||||
import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal'
|
||||
import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
|
||||
|
||||
@@ -6,20 +6,18 @@ import { PropsWithChildren, useEffect } from 'react'
|
||||
|
||||
import { ProjectLayoutWithAuth } from '../ProjectLayout'
|
||||
import { SaveQueueActionBar } from '@/components/grid/components/footer/operations/SaveQueueActionBar'
|
||||
import { useIsTableFilterBarEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { BannerTableEditorFilter } from '@/components/ui/BannerStack/Banners/BannerTableEditorFilter'
|
||||
import { useIsQueueOperationsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { BannerTableEditorQueueOperations } from '@/components/ui/BannerStack/Banners/BannerTableEditorQueueOperations'
|
||||
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
|
||||
const TABLE_EDITOR_NEW_FILTER_BANNER_ID = 'table-editor-new-filter-banner'
|
||||
|
||||
export const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
const { ref } = useParams()
|
||||
const { addBanner, dismissBanner } = useBannerStack()
|
||||
const isTableFilterBarEnabled = useIsTableFilterBarEnabled()
|
||||
const isTableQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
|
||||
const [isTableEditorNewFilterBannerDismissed] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.TABLE_EDITOR_NEW_FILTER_BANNER_DISMISSED(ref ?? ''),
|
||||
const [isTableEditorQueueOperationsBannerDismissed] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.TABLE_EDITOR_QUEUE_OPERATIONS_BANNER_DISMISSED(ref ?? ''),
|
||||
false
|
||||
)
|
||||
|
||||
@@ -28,30 +26,39 @@ export const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
'tables'
|
||||
)
|
||||
|
||||
const { can: canWriteTables } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'tables'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPermissionsLoaded) return
|
||||
|
||||
if (canReadTables && !isTableEditorNewFilterBannerDismissed && !isTableFilterBarEnabled) {
|
||||
if (
|
||||
canWriteTables &&
|
||||
!isTableEditorQueueOperationsBannerDismissed &&
|
||||
!isTableQueueOperationsEnabled
|
||||
) {
|
||||
addBanner({
|
||||
id: TABLE_EDITOR_NEW_FILTER_BANNER_ID,
|
||||
id: 'table-editor-queue-operations-banner',
|
||||
priority: 2,
|
||||
isDismissed: false,
|
||||
content: <BannerTableEditorFilter />,
|
||||
content: <BannerTableEditorQueueOperations />,
|
||||
})
|
||||
} else {
|
||||
dismissBanner(TABLE_EDITOR_NEW_FILTER_BANNER_ID)
|
||||
dismissBanner('table-editor-queue-operations-banner')
|
||||
}
|
||||
|
||||
return () => {
|
||||
dismissBanner(TABLE_EDITOR_NEW_FILTER_BANNER_ID)
|
||||
dismissBanner('table-editor-queue-operations-banner')
|
||||
}
|
||||
}, [
|
||||
addBanner,
|
||||
dismissBanner,
|
||||
canReadTables,
|
||||
isPermissionsLoaded,
|
||||
isTableEditorNewFilterBannerDismissed,
|
||||
isTableFilterBarEnabled,
|
||||
canWriteTables,
|
||||
isTableEditorQueueOperationsBannerDismissed,
|
||||
isTableQueueOperationsEnabled,
|
||||
])
|
||||
|
||||
if (isPermissionsLoaded && !canReadTables) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Admonition } from 'ui-patterns/admonition'
|
||||
export interface AlertErrorProps {
|
||||
projectRef?: string
|
||||
subject?: string
|
||||
description?: string
|
||||
error?: { message: string } | null
|
||||
layout?: 'vertical' | 'horizontal' | 'responsive'
|
||||
className?: string
|
||||
@@ -46,6 +47,7 @@ export const ContactSupportButton = ({
|
||||
export const AlertError = ({
|
||||
projectRef,
|
||||
subject,
|
||||
description = 'Try refreshing your browser, but if the issue persists for more than a few minutes, please reach out to us via support.',
|
||||
error,
|
||||
className,
|
||||
showIcon = true,
|
||||
@@ -87,12 +89,7 @@ export const AlertError = ({
|
||||
{formattedErrorMessage}
|
||||
</p>
|
||||
)}
|
||||
{showInstructions && (
|
||||
<p>
|
||||
Try refreshing your browser, but if the issue persists for more than a few minutes,
|
||||
please reach out to us via support.
|
||||
</p>
|
||||
)}
|
||||
{showInstructions && <p>{description}</p>}
|
||||
{children}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { createContext, useContext, useState, useCallback } from 'react'
|
||||
import { createContext, useCallback, useContext, useState } from 'react'
|
||||
|
||||
const BANNER_ID = {
|
||||
METRICS_API: 'metrics-api-banner',
|
||||
INDEX_ADVISOR: 'index-advisor-banner',
|
||||
TABLE_EDITOR_QUEUE_OPERATIONS: 'table-editor-queue-operations-banner',
|
||||
RLS_EVENT_TRIGGER: 'rls-event-trigger-banner',
|
||||
} as const
|
||||
|
||||
export type BannerId = (typeof BANNER_ID)[keyof typeof BANNER_ID]
|
||||
|
||||
export interface Banner {
|
||||
id: string
|
||||
id: BannerId
|
||||
content: React.ReactNode
|
||||
isDismissed: boolean
|
||||
priority?: number
|
||||
@@ -11,7 +20,7 @@ export interface Banner {
|
||||
interface BannerStackContextType {
|
||||
banners: Banner[]
|
||||
addBanner: (banner: Banner) => void
|
||||
dismissBanner: (id: string) => void
|
||||
dismissBanner: (id: BannerId) => void
|
||||
}
|
||||
|
||||
const BannerStackContext = createContext<BannerStackContextType | undefined>(undefined)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { useParams } from 'common/hooks'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Badge, Button } from 'ui'
|
||||
|
||||
import { BannerCard } from '../BannerCard'
|
||||
import { useBannerStack } from '../BannerStackProvider'
|
||||
import {
|
||||
useFeaturePreviewModal,
|
||||
useIsTableFilterBarEnabled,
|
||||
} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
|
||||
export const BannerTableEditorFilter = () => {
|
||||
const { ref } = useParams()
|
||||
const { selectFeaturePreview } = useFeaturePreviewModal()
|
||||
const isTableFilterBarEnabled = useIsTableFilterBarEnabled()
|
||||
|
||||
const { dismissBanner } = useBannerStack()
|
||||
const [, setIsDismissed] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.TABLE_EDITOR_NEW_FILTER_BANNER_DISMISSED(ref ?? ''),
|
||||
false
|
||||
)
|
||||
|
||||
const text = "name = 'John Doe'"
|
||||
|
||||
return (
|
||||
<BannerCard
|
||||
onDismiss={() => {
|
||||
setIsDismissed(true)
|
||||
dismissBanner('table-editor-new-filter-banner')
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-2 items-start">
|
||||
<Badge variant="success" className="-ml-0.5 uppercase inline-flex items-center mb-2">
|
||||
Preview
|
||||
</Badge>
|
||||
<div className="flex items-center gap-3 bg-surface-100 w-full border rounded-md px-1.5 py-1">
|
||||
<Search size={14} />
|
||||
<div className="bg-surface-200 border px-1.5 py-0.5 rounded">
|
||||
<p
|
||||
className="text-xs font-mono overflow-hidden whitespace-nowrap border-r-2 border-overlay"
|
||||
style={{
|
||||
width: `${text.length}ch`,
|
||||
animation: `typewriter 2s steps(${text.length}) forwards, blink-caret 0.75s step-end infinite`,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 mb-2">
|
||||
<p className="text-sm font-medium">New Table Filter Bar</p>
|
||||
<p className="text-xs text-foreground-lighter text-balance">
|
||||
Build and modify complex filters visually
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
className="w-min"
|
||||
onClick={() => selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR)}
|
||||
>
|
||||
{isTableFilterBarEnabled ? 'View' : 'Enable'} feature preview
|
||||
</Button>
|
||||
</div>
|
||||
</BannerCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { useParams } from 'common/hooks'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { Badge, Button, Card, CardContent, CardHeader } from 'ui'
|
||||
|
||||
import { BannerCard } from '../BannerCard'
|
||||
import { useBannerStack } from '../BannerStackProvider'
|
||||
import {
|
||||
useFeaturePreviewModal,
|
||||
useIsQueueOperationsEnabled,
|
||||
} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
|
||||
export const BannerTableEditorQueueOperations = () => {
|
||||
const { ref } = useParams()
|
||||
const { selectFeaturePreview } = useFeaturePreviewModal()
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
|
||||
const { dismissBanner } = useBannerStack()
|
||||
const [, setIsDismissed] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.TABLE_EDITOR_QUEUE_OPERATIONS_BANNER_DISMISSED(ref ?? ''),
|
||||
false
|
||||
)
|
||||
|
||||
return (
|
||||
<BannerCard
|
||||
onDismiss={() => {
|
||||
setIsDismissed(true)
|
||||
dismissBanner('table-editor-queue-operations-banner')
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-2 items-start">
|
||||
<Badge variant="success" className="-ml-0.5 uppercase inline-flex items-center mb-2">
|
||||
Preview
|
||||
</Badge>
|
||||
<Card className="text-xs w-full">
|
||||
<CardHeader className="flex flex-row gap-2 px-2 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-foreground ml-0.5">
|
||||
<span>name</span>
|
||||
<span className="text-foreground-muted mx-1.5">·</span>
|
||||
<span>where id = 10</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="font-mono text-xs px-2 py-1">
|
||||
<div className="flex gap-2 py-0.5">
|
||||
<span className="text-destructive select-none font-medium">-</span>
|
||||
<span className="text-destructive truncate max-w-full">Red</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 py-0.5">
|
||||
<span className="text-brand-link select-none font-medium">+</span>
|
||||
<span className="text-brand-link truncate max-w-full">Blue</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 mb-2">
|
||||
<p className="text-sm font-medium">Queue row edits in Table Editor</p>
|
||||
<p className="text-xs text-foreground-lighter text-balance">
|
||||
Batch multiple row edits and review them before saving to your database
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
className="w-min"
|
||||
onClick={() => selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS)}
|
||||
>
|
||||
{isQueueOperationsEnabled ? 'View' : 'Enable'} feature preview
|
||||
</Button>
|
||||
</div>
|
||||
</BannerCard>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getAccessToken } from 'common'
|
||||
import { databaseKeys } from 'data/database/keys'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { toast } from 'sonner'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
import { stripeSyncKeys } from './keys'
|
||||
|
||||
export type StripeSyncInstallVariables = {
|
||||
projectRef: string
|
||||
stripeSecretKey: string
|
||||
startTime: number
|
||||
}
|
||||
|
||||
export type StripeSyncInstallResponse = {
|
||||
@@ -21,6 +22,7 @@ export type StripeSyncInstallResponse = {
|
||||
export async function installStripeSync({
|
||||
projectRef,
|
||||
stripeSecretKey,
|
||||
startTime,
|
||||
}: StripeSyncInstallVariables): Promise<StripeSyncInstallResponse> {
|
||||
const accessToken = await getAccessToken()
|
||||
|
||||
@@ -33,6 +35,7 @@ export async function installStripeSync({
|
||||
body: JSON.stringify({
|
||||
projectRef,
|
||||
stripeSecretKey,
|
||||
startTime,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getAccessToken } from 'common'
|
||||
import { databaseKeys } from 'data/database/keys'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { toast } from 'sonner'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
import { stripeSyncKeys } from './keys'
|
||||
|
||||
export type StripeSyncUninstallVariables = {
|
||||
projectRef: string
|
||||
startTime: number
|
||||
}
|
||||
|
||||
export type StripeSyncUninstallResponse = {
|
||||
@@ -19,6 +20,7 @@ export type StripeSyncUninstallResponse = {
|
||||
|
||||
export async function uninstallStripeSync({
|
||||
projectRef,
|
||||
startTime,
|
||||
}: StripeSyncUninstallVariables): Promise<StripeSyncUninstallResponse> {
|
||||
const accessToken = await getAccessToken()
|
||||
|
||||
@@ -30,6 +32,7 @@ export async function uninstallStripeSync({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectRef,
|
||||
startTime,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import type { ResponseError, UseCustomQueryOptions } from 'types'
|
||||
|
||||
import { projectKeys } from './keys'
|
||||
|
||||
export type ProjectStatusVariables = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import z from 'zod'
|
||||
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { InternalServerError } from 'lib/api/apiHelpers'
|
||||
import z from 'zod'
|
||||
|
||||
export type IncidentCache = {
|
||||
affected_regions: Array<string> | null
|
||||
@@ -81,7 +80,7 @@ export async function getActiveIncidents(): Promise<IncidentInfo[]> {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
next: { revalidate: 180 },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
const responseText = await response.text()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { httpEndpointUrlSchema, isValidHttpEndpointUrl } from './http-url'
|
||||
|
||||
const schema = httpEndpointUrlSchema({
|
||||
requiredMessage: 'required',
|
||||
invalidMessage: 'invalid',
|
||||
prefixMessage: 'prefix',
|
||||
})
|
||||
|
||||
describe('isValidHttpEndpointUrl', () => {
|
||||
it('accepts valid http and https endpoints', () => {
|
||||
expect(isValidHttpEndpointUrl('https://api.supabase.com/webhooks')).toBe(true)
|
||||
expect(isValidHttpEndpointUrl('http://localhost:3000/hooks')).toBe(true)
|
||||
expect(isValidHttpEndpointUrl('https://127.0.0.1:4318/v1/logs')).toBe(true)
|
||||
expect(isValidHttpEndpointUrl('https://[::1]:4318/v1/logs')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid endpoint URLs', () => {
|
||||
expect(isValidHttpEndpointUrl('https://webhook')).toBe(false)
|
||||
expect(isValidHttpEndpointUrl('ftp://api.supabase.com/webhooks')).toBe(false)
|
||||
expect(isValidHttpEndpointUrl('not a url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('httpEndpointUrlSchema', () => {
|
||||
it('rejects empty values', () => {
|
||||
const result = schema.safeParse('')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('required')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects URLs without an http or https prefix', () => {
|
||||
const result = schema.safeParse('api.supabase.com/webhooks')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('prefix')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects incomplete hostnames', () => {
|
||||
const result = schema.safeParse('https://webhook')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('invalid')
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid endpoints after trimming', () => {
|
||||
const result = schema.safeParse(' https://api.supabase.com/webhooks ')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data).toBe('https://api.supabase.com/webhooks')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const HTTP_URL_PROTOCOL_REGEX = /^https?:\/\//
|
||||
const IPV4_SEGMENT = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'
|
||||
const IPV4_REGEX = new RegExp(`^(?:${IPV4_SEGMENT}\\.){3}${IPV4_SEGMENT}$`)
|
||||
const BRACKETED_IPV6_REGEX = /^\[[0-9a-f:.]+\]$/i
|
||||
|
||||
export const hasHttpUrlProtocol = (value: string) => HTTP_URL_PROTOCOL_REGEX.test(value)
|
||||
|
||||
export const isValidHttpEndpointUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
||||
|
||||
const { hostname } = url
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname.includes('.') ||
|
||||
IPV4_REGEX.test(hostname) ||
|
||||
BRACKETED_IPV6_REGEX.test(hostname)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type HttpEndpointUrlSchemaOptions = {
|
||||
requiredMessage: string
|
||||
invalidMessage: string
|
||||
prefixMessage: string
|
||||
}
|
||||
|
||||
export const httpEndpointUrlSchema = ({
|
||||
requiredMessage,
|
||||
invalidMessage,
|
||||
prefixMessage,
|
||||
}: HttpEndpointUrlSchemaOptions) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, requiredMessage)
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value) return
|
||||
|
||||
if (!hasHttpUrlProtocol(value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: prefixMessage,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidHttpEndpointUrl(value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: invalidMessage,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -103,7 +103,7 @@
|
||||
"memoize-one": "^5.0.1",
|
||||
"mime-db": "^1.53.0",
|
||||
"monaco-editor": "0.52.2",
|
||||
"next": "~16.1.6",
|
||||
"next": "~16.1.7",
|
||||
"next-themes": "^0.3.0",
|
||||
"nuqs": "2.7.1",
|
||||
"openai": "^4.75.1",
|
||||
@@ -137,7 +137,7 @@
|
||||
"sql-formatter": "^15.0.0",
|
||||
"sqlstring": "^2.3.2",
|
||||
"streamdown": "^1.3.0",
|
||||
"stripe-experiment-sync": "^1.0.19",
|
||||
"stripe-experiment-sync": "1.0.27",
|
||||
"tus-js-client": "^4.1.0",
|
||||
"ui": "workspace:*",
|
||||
"ui-patterns": "workspace:*",
|
||||
|
||||
@@ -7,10 +7,12 @@ import { z } from 'zod'
|
||||
const InstallBodySchema = z.object({
|
||||
projectRef: z.string().min(1),
|
||||
stripeSecretKey: z.string().min(1),
|
||||
startTime: z.number().positive().optional(),
|
||||
})
|
||||
|
||||
const UninstallBodySchema = z.object({
|
||||
projectRef: z.string().min(1),
|
||||
startTime: z.number().positive().optional(),
|
||||
})
|
||||
|
||||
async function isStripeSyncEnabled() {
|
||||
@@ -26,6 +28,10 @@ function getBearerToken(req: NextApiRequest) {
|
||||
return match?.[1]?.trim() ?? null
|
||||
}
|
||||
|
||||
export const config = {
|
||||
maxDuration: 300, // 5 minutes, since the installation process can take a while even if happening in background
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Hide endpoint if the integration is disabled.
|
||||
if (!(await isStripeSyncEnabled())) {
|
||||
@@ -59,7 +65,7 @@ async function handleDeleteStripeSyncInstall(req: NextApiRequest, res: NextApiRe
|
||||
.status(400)
|
||||
.json({ data: null, error: { message: 'Bad Request: Invalid request body' } })
|
||||
}
|
||||
const { projectRef } = parsed.data
|
||||
const { projectRef, startTime } = parsed.data
|
||||
|
||||
waitUntil(
|
||||
uninstall({
|
||||
@@ -67,6 +73,7 @@ async function handleDeleteStripeSyncInstall(req: NextApiRequest, res: NextApiRe
|
||||
supabaseProjectRef: projectRef,
|
||||
baseProjectUrl: process.env.NEXT_PUBLIC_CUSTOMER_DOMAIN,
|
||||
supabaseManagementUrl: process.env.NEXT_PUBLIC_API_DOMAIN,
|
||||
startTime,
|
||||
}).catch((error) => {
|
||||
console.error('Stripe Sync Engine uninstallation failed.', error)
|
||||
throw error
|
||||
@@ -92,7 +99,7 @@ async function handleSetupStripeSyncInstall(req: NextApiRequest, res: NextApiRes
|
||||
.status(400)
|
||||
.json({ data: null, error: { message: 'Bad Request: Invalid request body' } })
|
||||
}
|
||||
const { projectRef, stripeSecretKey } = parsed.data
|
||||
const { projectRef, stripeSecretKey, startTime } = parsed.data
|
||||
|
||||
// Validate the Stripe API key before proceeding with installation
|
||||
try {
|
||||
@@ -113,10 +120,12 @@ async function handleSetupStripeSyncInstall(req: NextApiRequest, res: NextApiRes
|
||||
error: { message: errorMessage },
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
const normalizedErrorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return res.status(400).json({
|
||||
data: null,
|
||||
error: { message: `Failed to validate Stripe API key: ${error.message}` },
|
||||
error: { message: `Failed to validate Stripe API key: ${normalizedErrorMessage}` },
|
||||
})
|
||||
}
|
||||
waitUntil(
|
||||
@@ -127,6 +136,7 @@ async function handleSetupStripeSyncInstall(req: NextApiRequest, res: NextApiRes
|
||||
baseProjectUrl: process.env.NEXT_PUBLIC_CUSTOMER_DOMAIN,
|
||||
supabaseManagementUrl: process.env.NEXT_PUBLIC_API_DOMAIN,
|
||||
packageVersion: VERSION,
|
||||
startTime,
|
||||
}).catch((error) => {
|
||||
console.error('Stripe Sync Engine installation failed.', error)
|
||||
throw error
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useCustomContent } from 'hooks/custom-content/useCustomContent'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useDataApiGrantTogglesEnabled } from 'hooks/misc/useDataApiGrantTogglesEnabled'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { usePHFlag } from 'hooks/ui/useFlag'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
@@ -96,6 +97,10 @@ const Wizard: NextPageWithLayout = () => {
|
||||
const showPostgresVersionSelector = useFlag('showPostgresVersionSelector')
|
||||
const cloudProviderEnabled = useFlag('enableFlyCloudProvider')
|
||||
const isDataApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled()
|
||||
// Read the raw flag for telemetry — useDataApiGrantTogglesEnabled coerces undefined→false,
|
||||
// which would record false for users whose flags haven't loaded yet. The raw value preserves
|
||||
// undefined (omitted from PostHog) so we only record true/false when the flag is resolved.
|
||||
const tableEditorApiAccessToggleFlag = usePHFlag<boolean>('tableEditorApiAccessToggle')
|
||||
|
||||
const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'
|
||||
const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '')
|
||||
@@ -259,6 +264,9 @@ const Wizard: NextPageWithLayout = () => {
|
||||
enableRlsEventTrigger: form.getValues('enableRlsEventTrigger'),
|
||||
dataApiEnabled: form.getValues('dataApi'),
|
||||
useOrioleDb: form.getValues('useOrioleDb'),
|
||||
...(tableEditorApiAccessToggleFlag !== undefined && {
|
||||
tableEditorApiAccessToggleEnabled: tableEditorApiAccessToggleFlag,
|
||||
}),
|
||||
},
|
||||
{
|
||||
project: res.ref,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { parseAsArrayOf, parseAsInteger, parseAsJson, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus'
|
||||
import { useQueryPerformanceSort } from 'components/interfaces/QueryPerformance/hooks/useQueryPerformanceSort'
|
||||
import { QueryPerformance } from 'components/interfaces/QueryPerformance/QueryPerformance'
|
||||
import { type QuerySource } from 'components/interfaces/QueryPerformance/QueryPerformance.types'
|
||||
import { useQueryPerformanceQuery } from 'components/interfaces/QueryPerformance/useQueryPerformanceQuery'
|
||||
import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants'
|
||||
import { useQueryPerformanceQuery } from 'components/interfaces/Reports/Reports.queries'
|
||||
import { Presets } from 'components/interfaces/Reports/Reports.types'
|
||||
import { queriesFactory } from 'components/interfaces/Reports/Reports.utils'
|
||||
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
|
||||
import { DefaultLayout } from 'components/layouts/DefaultLayout'
|
||||
import ObservabilityLayout from 'components/layouts/ObservabilityLayout/ObservabilityLayout'
|
||||
import { DatabaseSelector } from 'components/ui/DatabaseSelector'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { parseAsArrayOf, parseAsInteger, parseAsJson, parseAsString, useQueryStates } from 'nuqs'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
@@ -25,12 +25,20 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
const { sort: sortConfig } = useQueryPerformanceSort()
|
||||
|
||||
const [
|
||||
{ search: searchQuery, roles, minCalls, totalTimeFilter: totalTimeFilterRaw, indexAdvisor },
|
||||
{
|
||||
search: searchQuery,
|
||||
roles,
|
||||
sources,
|
||||
minCalls,
|
||||
totalTimeFilter: totalTimeFilterRaw,
|
||||
indexAdvisor,
|
||||
},
|
||||
] = useQueryStates({
|
||||
sort: parseAsString,
|
||||
order: parseAsString,
|
||||
search: parseAsString.withDefault(''),
|
||||
roles: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
sources: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
minCalls: parseAsInteger,
|
||||
totalTimeFilter: parseAsJson<NumericFilter | null>((value) =>
|
||||
value === null || value === undefined ? null : (value as NumericFilter)
|
||||
@@ -57,6 +65,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
orderBy: sortConfig || undefined,
|
||||
preset: 'unified',
|
||||
roles,
|
||||
sources: sources as QuerySource[],
|
||||
runIndexAdvisor: isIndexAdvisorEnabled,
|
||||
minCalls: minCalls ?? undefined,
|
||||
minTotalTime,
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const SqlQuickstarts: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams<{ ref: string }>()
|
||||
const ref = useParams<{ ref: string }>()?.ref
|
||||
const tabs = useTabsStateSnapshot()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const SqlTemplates: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams<{ ref: string }>()
|
||||
const ref = useParams<{ ref: string }>()?.ref
|
||||
const tabs = useTabsStateSnapshot()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
".next/types/**/*.ts",
|
||||
"./../../packages/ui/src/**/*.d.ts"
|
||||
"./../../packages/ui/src/**/*.d.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "public/deno/*.ts"]
|
||||
}
|
||||
+20
-20
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"company": [
|
||||
{
|
||||
"number": "180+",
|
||||
"text": "team members\n\nin 35+ countries"
|
||||
"number": "280+",
|
||||
"text": "team members\n\nin 55+ countries"
|
||||
},
|
||||
{
|
||||
"number": "15+",
|
||||
"number": "20+",
|
||||
"text": "languages spoken"
|
||||
},
|
||||
{
|
||||
"number": "$496M",
|
||||
"text": "in funding"
|
||||
"number": "$500M",
|
||||
"text": "raised"
|
||||
},
|
||||
{
|
||||
"number": "430,000+",
|
||||
"number": "540,000+",
|
||||
"text": "community members"
|
||||
},
|
||||
{
|
||||
@@ -25,27 +25,27 @@
|
||||
{
|
||||
"icon": "egoless",
|
||||
"title": "Egoless",
|
||||
"text": "Willing to do the schlep, the team goals come first."
|
||||
"text": "Mission driven, work for the good of colleagues."
|
||||
},
|
||||
{
|
||||
"icon": "curious",
|
||||
"title": "Polyglot",
|
||||
"text": "Broad skillsets. Growth mindset."
|
||||
},
|
||||
{
|
||||
"icon": "process",
|
||||
"title": "Process Driven",
|
||||
"text": "Engineer yourself out of the job."
|
||||
"icon": "kaizen",
|
||||
"title": "Kaizen Mindset",
|
||||
"text": "Iterative approach, continuously improve all functions."
|
||||
},
|
||||
{
|
||||
"icon": "honesty",
|
||||
"title": "Intellectual Honesty",
|
||||
"text": "Put our own biases aside."
|
||||
"title": "Truth Seeking",
|
||||
"text": "Intellectually honest, act with integrity, recognize biases."
|
||||
},
|
||||
{
|
||||
"icon": "flexibility",
|
||||
"title": "Kaizen mindset",
|
||||
"text": "Continuously improve all functions."
|
||||
"icon": "batteries",
|
||||
"title": "Batteries Included",
|
||||
"text": "High ownership, intrinsically motivated, care about quality."
|
||||
},
|
||||
{
|
||||
"icon": "undeniable",
|
||||
"title": "Undeniable",
|
||||
"text": "So good you earn respect, reliable, consistently deliver."
|
||||
}
|
||||
],
|
||||
"contributors": [
|
||||
|
||||
+1
-1
@@ -299,7 +299,7 @@ SERVICE_ROLE_KEY_ASYMMETRIC=
|
||||
############
|
||||
|
||||
# Enable webp support
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION=true
|
||||
IMGPROXY_AUTO_WEBP=true
|
||||
|
||||
|
||||
############
|
||||
|
||||
@@ -394,7 +394,7 @@ services:
|
||||
IMGPROXY_BIND: ":5001"
|
||||
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION}
|
||||
IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP}
|
||||
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
|
||||
|
||||
meta:
|
||||
@@ -600,7 +600,8 @@ services:
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -22,7 +22,10 @@ export const test = base.extend<TestOptions>({
|
||||
page: async ({ page }, use) => {
|
||||
const ref = env.PROJECT_REF ?? 'default'
|
||||
await page.addInitScript((ref) => {
|
||||
localStorage.setItem(`table-editor-new-filter-banner-dismissed-${ref}`, JSON.stringify(true))
|
||||
localStorage.setItem(
|
||||
`table-editor-queue-operations-banner-dismissed-${ref}`,
|
||||
JSON.stringify(true)
|
||||
)
|
||||
}, ref)
|
||||
await use(page)
|
||||
},
|
||||
|
||||
@@ -1,90 +1,82 @@
|
||||
// AWS SDK issue: https://github.com/aws/aws-sdk-js-v3/issues/6134
|
||||
// We need to mock the file system for the AWS SDK to work.
|
||||
import {
|
||||
prepareVirtualFile,
|
||||
} from "https://deno.land/x/mock_file@v1.1.2/mod.ts";
|
||||
import { prepareVirtualFile } from 'https://deno.land/x/mock_file@v1.1.2/mod.ts'
|
||||
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
InvokeModelCommand,
|
||||
} from "npm:@aws-sdk/client-bedrock-runtime";
|
||||
import { createClient } from "npm:@supabase/supabase-js";
|
||||
import { decode } from "npm:base64-arraybuffer";
|
||||
import { BedrockRuntimeClient, InvokeModelCommand } from 'npm:@aws-sdk/client-bedrock-runtime'
|
||||
import { createClient } from 'npm:@supabase/supabase-js'
|
||||
import { decode } from 'npm:base64-arraybuffer'
|
||||
|
||||
console.log("Hello from Amazon Bedrock!");
|
||||
console.log('Hello from Amazon Bedrock!')
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
prepareVirtualFile("./aws/config");
|
||||
prepareVirtualFile("./aws/credentials");
|
||||
prepareVirtualFile('./aws/config')
|
||||
prepareVirtualFile('./aws/credentials')
|
||||
|
||||
const client = new BedrockRuntimeClient({
|
||||
region: "us-west-2",
|
||||
region: 'us-west-2',
|
||||
credentials: {
|
||||
accessKeyId: Deno.env.get("AWS_ACCESS_KEY_ID") ?? "",
|
||||
secretAccessKey: Deno.env.get("AWS_SECRET_ACCESS_KEY") ?? "",
|
||||
sessionToken: Deno.env.get("AWS_SESSION_TOKEN") ?? "",
|
||||
accessKeyId: Deno.env.get('AWS_ACCESS_KEY_ID') ?? '',
|
||||
secretAccessKey: Deno.env.get('AWS_SECRET_ACCESS_KEY') ?? '',
|
||||
sessionToken: Deno.env.get('AWS_SESSION_TOKEN') ?? '',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const { prompt, seed } = await req.json();
|
||||
console.log(prompt);
|
||||
const { prompt, seed } = await req.json()
|
||||
console.log(prompt)
|
||||
const input = {
|
||||
contentType: "application/json",
|
||||
accept: "*/*",
|
||||
modelId: "amazon.titan-image-generator-v1",
|
||||
contentType: 'application/json',
|
||||
accept: '*/*',
|
||||
modelId: 'amazon.titan-image-generator-v1',
|
||||
body: JSON.stringify({
|
||||
"taskType": "TEXT_IMAGE",
|
||||
"textToImageParams": { "text": prompt },
|
||||
"imageGenerationConfig": {
|
||||
"numberOfImages": 1,
|
||||
"quality": "standard",
|
||||
"cfgScale": 8.0,
|
||||
"height": 512,
|
||||
"width": 512,
|
||||
"seed": seed ?? 0,
|
||||
taskType: 'TEXT_IMAGE',
|
||||
textToImageParams: { text: prompt },
|
||||
imageGenerationConfig: {
|
||||
numberOfImages: 1,
|
||||
quality: 'standard',
|
||||
cfgScale: 8.0,
|
||||
height: 512,
|
||||
width: 512,
|
||||
seed: seed ?? 0,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const command = new InvokeModelCommand(input);
|
||||
const response = await client.send(command);
|
||||
console.log(response);
|
||||
const command = new InvokeModelCommand(input)
|
||||
const response = await client.send(command)
|
||||
console.log(response)
|
||||
|
||||
if (response.$metadata.httpStatusCode === 200) {
|
||||
const { body, $metadata } = response;
|
||||
const { body, $metadata } = response
|
||||
|
||||
const textDecoder = new TextDecoder("utf-8");
|
||||
const jsonString = textDecoder.decode(body.buffer);
|
||||
const parsedData = JSON.parse(jsonString);
|
||||
console.log(parsedData);
|
||||
const image = parsedData.images[0];
|
||||
const textDecoder = new TextDecoder('utf-8')
|
||||
const jsonString = textDecoder.decode(body.buffer)
|
||||
const parsedData = JSON.parse(jsonString)
|
||||
console.log(parsedData)
|
||||
const image = parsedData.images[0]
|
||||
|
||||
const supabaseClient = createClient(
|
||||
// Supabase API URL - env var exported by default.
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
// Supabase API ANON KEY - env var exported by default.
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
const { data: upload, error: uploadError } = await supabaseClient.storage
|
||||
.from("images")
|
||||
.upload(`${$metadata.requestId ?? ""}.png`, decode(image), {
|
||||
contentType: "image/png",
|
||||
cacheControl: "3600",
|
||||
.from('images')
|
||||
.upload(`${$metadata.requestId ?? ''}.png`, decode(image), {
|
||||
contentType: 'image/png',
|
||||
cacheControl: '3600',
|
||||
upsert: false,
|
||||
});
|
||||
})
|
||||
if (!upload) {
|
||||
return Response.json(uploadError);
|
||||
return Response.json(uploadError)
|
||||
}
|
||||
const { data } = supabaseClient
|
||||
.storage
|
||||
.from("images")
|
||||
.getPublicUrl(upload.path!);
|
||||
return Response.json(data);
|
||||
const { data } = supabaseClient.storage.from('images').getPublicUrl(upload.path!)
|
||||
return Response.json(data)
|
||||
}
|
||||
|
||||
return Response.json(response);
|
||||
});
|
||||
return Response.json(response)
|
||||
})
|
||||
|
||||
/* To invoke locally:
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[]
|
||||
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]
|
||||
|
||||
export type Database = {
|
||||
graphql_public: {
|
||||
@@ -180,11 +174,11 @@ export type Database = {
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "objects_bucketId_fkey"
|
||||
columns: ["bucket_id"]
|
||||
foreignKeyName: 'objects_bucketId_fkey'
|
||||
columns: ['bucket_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: "buckets"
|
||||
referencedColumns: ["id"]
|
||||
referencedRelation: 'buckets'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -257,27 +251,25 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
|
||||
type PublicSchema = Database[Extract<keyof Database, "public">]
|
||||
type PublicSchema = Database[Extract<keyof Database, 'public'>]
|
||||
|
||||
export type Tables<
|
||||
PublicTableNameOrOptions extends
|
||||
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
|
||||
| keyof (PublicSchema['Tables'] & PublicSchema['Views'])
|
||||
| { schema: keyof Database },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[PublicTableNameOrOptions["schema"]]["Views"])
|
||||
? keyof (Database[PublicTableNameOrOptions['schema']]['Tables'] &
|
||||
Database[PublicTableNameOrOptions['schema']]['Views'])
|
||||
: never = never,
|
||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||
? (Database[PublicTableNameOrOptions['schema']]['Tables'] &
|
||||
Database[PublicTableNameOrOptions['schema']]['Views'])[TableName] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
|
||||
PublicSchema["Views"])
|
||||
? (PublicSchema["Tables"] &
|
||||
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
|
||||
: PublicTableNameOrOptions extends keyof (PublicSchema['Tables'] & PublicSchema['Views'])
|
||||
? (PublicSchema['Tables'] & PublicSchema['Views'])[PublicTableNameOrOptions] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
@@ -285,20 +277,18 @@ export type Tables<
|
||||
: never
|
||||
|
||||
export type TablesInsert<
|
||||
PublicTableNameOrOptions extends
|
||||
| keyof PublicSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
PublicTableNameOrOptions extends keyof PublicSchema['Tables'] | { schema: keyof Database },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||
? keyof Database[PublicTableNameOrOptions['schema']]['Tables']
|
||||
: never = never,
|
||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
|
||||
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
|
||||
: PublicTableNameOrOptions extends keyof PublicSchema['Tables']
|
||||
? PublicSchema['Tables'][PublicTableNameOrOptions] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
@@ -306,20 +296,18 @@ export type TablesInsert<
|
||||
: never
|
||||
|
||||
export type TablesUpdate<
|
||||
PublicTableNameOrOptions extends
|
||||
| keyof PublicSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
PublicTableNameOrOptions extends keyof PublicSchema['Tables'] | { schema: keyof Database },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||
? keyof Database[PublicTableNameOrOptions['schema']]['Tables']
|
||||
: never = never,
|
||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
|
||||
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
|
||||
: PublicTableNameOrOptions extends keyof PublicSchema['Tables']
|
||||
? PublicSchema['Tables'][PublicTableNameOrOptions] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
@@ -327,14 +315,12 @@ export type TablesUpdate<
|
||||
: never
|
||||
|
||||
export type Enums<
|
||||
PublicEnumNameOrOptions extends
|
||||
| keyof PublicSchema["Enums"]
|
||||
| { schema: keyof Database },
|
||||
PublicEnumNameOrOptions extends keyof PublicSchema['Enums'] | { schema: keyof Database },
|
||||
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
|
||||
? keyof Database[PublicEnumNameOrOptions['schema']]['Enums']
|
||||
: never = never,
|
||||
> = PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
|
||||
? PublicSchema["Enums"][PublicEnumNameOrOptions]
|
||||
? Database[PublicEnumNameOrOptions['schema']]['Enums'][EnumName]
|
||||
: PublicEnumNameOrOptions extends keyof PublicSchema['Enums']
|
||||
? PublicSchema['Enums'][PublicEnumNameOrOptions]
|
||||
: never
|
||||
@@ -1,47 +1,47 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
|
||||
|
||||
import { createClient } from "jsr:@supabase/supabase-js@2";
|
||||
import { Database, Tables } from "../_shared/database.types.ts";
|
||||
import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
import { Database, Tables } from '../_shared/database.types.ts'
|
||||
|
||||
type EmbeddingsRecord = Tables<"embeddings">;
|
||||
type EmbeddingsRecord = Tables<'embeddings'>
|
||||
interface WebhookPayload {
|
||||
type: "INSERT" | "UPDATE" | "DELETE";
|
||||
table: string;
|
||||
record: EmbeddingsRecord;
|
||||
schema: "public";
|
||||
old_record: null | EmbeddingsRecord;
|
||||
type: 'INSERT' | 'UPDATE' | 'DELETE'
|
||||
table: string
|
||||
record: EmbeddingsRecord
|
||||
schema: 'public'
|
||||
old_record: null | EmbeddingsRecord
|
||||
}
|
||||
|
||||
const supabase = createClient<Database>(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
const model = new Supabase.ai.Session("gte-small");
|
||||
const model = new Supabase.ai.Session('gte-small')
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const payload: WebhookPayload = await req.json();
|
||||
const { content, id } = payload.record;
|
||||
const payload: WebhookPayload = await req.json()
|
||||
const { content, id } = payload.record
|
||||
|
||||
// Check if content has changed.
|
||||
if (content === payload?.old_record?.content) {
|
||||
return new Response("ok - no change");
|
||||
return new Response('ok - no change')
|
||||
}
|
||||
|
||||
// Generate embedding
|
||||
const embedding = await model.run(content, {
|
||||
mean_pool: true,
|
||||
normalize: true,
|
||||
});
|
||||
})
|
||||
|
||||
// Store in DB
|
||||
const { error } = await supabase.from("embeddings").update({
|
||||
embedding: JSON.stringify(embedding),
|
||||
}).eq(
|
||||
"id",
|
||||
id,
|
||||
);
|
||||
if (error) console.warn(error.message);
|
||||
const { error } = await supabase
|
||||
.from('embeddings')
|
||||
.update({
|
||||
embedding: JSON.stringify(embedding),
|
||||
})
|
||||
.eq('id', id)
|
||||
if (error) console.warn(error.message)
|
||||
|
||||
return new Response("ok - updated");
|
||||
});
|
||||
return new Response('ok - updated')
|
||||
})
|
||||
@@ -1,38 +1,38 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
|
||||
|
||||
import { createClient } from "jsr:@supabase/supabase-js@2";
|
||||
import { Database } from "../_shared/database.types.ts";
|
||||
import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
import { Database } from '../_shared/database.types.ts'
|
||||
|
||||
const supabase = createClient<Database>(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
const model = new Supabase.ai.Session("gte-small");
|
||||
const model = new Supabase.ai.Session('gte-small')
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const { search } = await req.json();
|
||||
if (!search) return new Response("Please provide a search param!");
|
||||
const { search } = await req.json()
|
||||
if (!search) return new Response('Please provide a search param!')
|
||||
// Generate embedding for search term.
|
||||
const embedding = await model.run(search, {
|
||||
mean_pool: true,
|
||||
normalize: true,
|
||||
});
|
||||
})
|
||||
|
||||
// Query embeddings.
|
||||
const { data: result, error } = await supabase
|
||||
.rpc("query_embeddings", {
|
||||
.rpc('query_embeddings', {
|
||||
embedding: JSON.stringify(embedding),
|
||||
match_threshold: 0.8,
|
||||
})
|
||||
.select("content")
|
||||
.limit(3);
|
||||
.select('content')
|
||||
.limit(3)
|
||||
if (error) {
|
||||
return Response.json(error);
|
||||
return Response.json(error)
|
||||
}
|
||||
|
||||
return Response.json({ search, result });
|
||||
});
|
||||
return Response.json({ search, result })
|
||||
})
|
||||
|
||||
/* To invoke locally:
|
||||
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
// https://github.com/Mozilla-Ocho/llamafile?tab=readme-ov-file#quickstart
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
const session = new Supabase.ai.Session("LLaMA_CPP");
|
||||
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
|
||||
const session = new Supabase.ai.Session('LLaMA_CPP')
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const params = new URL(req.url).searchParams;
|
||||
const prompt = params.get("prompt") ?? "";
|
||||
const params = new URL(req.url).searchParams
|
||||
const prompt = params.get('prompt') ?? ''
|
||||
|
||||
// Get the output as a stream
|
||||
const output = await session.run({
|
||||
messages: [
|
||||
{
|
||||
"role": "system",
|
||||
"content":
|
||||
"You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
],
|
||||
}, {
|
||||
mode: "openaicompatible", // Mode for the inference API host. (default: 'ollama')
|
||||
stream: true,
|
||||
}) as AsyncGenerator<any>;
|
||||
const output = (await session.run(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
mode: 'openaicompatible', // Mode for the inference API host. (default: 'ollama')
|
||||
stream: true,
|
||||
}
|
||||
)) as AsyncGenerator<any>
|
||||
|
||||
const body = new ReadableStream({
|
||||
async pull(ctrl) {
|
||||
try {
|
||||
const item = await output.next();
|
||||
const item = await output.next()
|
||||
|
||||
if (item.done) {
|
||||
console.log("done");
|
||||
ctrl.close();
|
||||
return;
|
||||
console.log('done')
|
||||
ctrl.close()
|
||||
return
|
||||
}
|
||||
|
||||
ctrl.enqueue("data: ");
|
||||
ctrl.enqueue(JSON.stringify(item.value));
|
||||
ctrl.enqueue("\r\n\r\n");
|
||||
ctrl.enqueue('data: ')
|
||||
ctrl.enqueue(JSON.stringify(item.value))
|
||||
ctrl.enqueue('\r\n\r\n')
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
ctrl.close();
|
||||
console.error(err)
|
||||
ctrl.close()
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return new Response(
|
||||
body.pipeThrough(new TextEncoderStream()),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
},
|
||||
return new Response(body.pipeThrough(new TextEncoderStream()), {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
},
|
||||
);
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
Run locally:
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
// https://github.com/Mozilla-Ocho/llamafile?tab=readme-ov-file#quickstart
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
const session = new Supabase.ai.Session("LLaMA_CPP");
|
||||
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
|
||||
const session = new Supabase.ai.Session('LLaMA_CPP')
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const params = new URL(req.url).searchParams;
|
||||
const prompt = params.get("prompt") ?? "";
|
||||
const params = new URL(req.url).searchParams
|
||||
const prompt = params.get('prompt') ?? ''
|
||||
|
||||
// Get the output as a stream
|
||||
const output = await session.run({
|
||||
messages: [
|
||||
{
|
||||
"role": "system",
|
||||
"content":
|
||||
"You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
],
|
||||
}, {
|
||||
mode: "openaicompatible", // Mode for the inference API host. (default: 'ollama')
|
||||
stream: false,
|
||||
});
|
||||
const output = await session.run(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
mode: 'openaicompatible', // Mode for the inference API host. (default: 'ollama')
|
||||
stream: false,
|
||||
}
|
||||
)
|
||||
|
||||
console.log("done");
|
||||
return Response.json(output);
|
||||
});
|
||||
console.log('done')
|
||||
return Response.json(output)
|
||||
})
|
||||
|
||||
/**
|
||||
Run locally:
|
||||
|
||||
@@ -3,66 +3,64 @@
|
||||
// This enables autocomplete, go to definition, etc.
|
||||
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import OpenAI from "https://deno.land/x/openai@v4.53.2/mod.ts";
|
||||
import OpenAI from 'https://deno.land/x/openai@v4.53.2/mod.ts'
|
||||
|
||||
console.log("Hello from openai-sdk compatible!");
|
||||
console.log('Hello from openai-sdk compatible!')
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const client = new OpenAI();
|
||||
const { prompt } = await req.json();
|
||||
const stream = true;
|
||||
const client = new OpenAI()
|
||||
const { prompt } = await req.json()
|
||||
const stream = true
|
||||
|
||||
const chatCompletion = await client.chat.completions.create({
|
||||
model: "LLaMA_CPP",
|
||||
model: 'LLaMA_CPP',
|
||||
stream,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
role: 'system',
|
||||
content:
|
||||
"You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.",
|
||||
'You are LLAMAfile, an AI assistant. Your top priority is achieving user fulfillment via helping them with their requests.',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
if (stream) {
|
||||
const headers = new Headers({
|
||||
"Content-Type": "text/event-stream",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
'Content-Type': 'text/event-stream',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
// Create a stream
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
try {
|
||||
for await (const part of chatCompletion) {
|
||||
controller.enqueue(
|
||||
encoder.encode(part.choices[0]?.delta?.content || ""),
|
||||
);
|
||||
controller.enqueue(encoder.encode(part.choices[0]?.delta?.content || ''))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Stream error:", err);
|
||||
console.error('Stream error:', err)
|
||||
} finally {
|
||||
controller.close();
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Return the stream to the user
|
||||
return new Response(stream, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
console.log(chatCompletion);
|
||||
console.log(chatCompletion)
|
||||
|
||||
return Response.json(chatCompletion);
|
||||
});
|
||||
return Response.json(chatCompletion)
|
||||
})
|
||||
|
||||
/* To invoke locally:
|
||||
|
||||
|
||||
@@ -51,4 +51,4 @@
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Tabs } from 'expo-router'
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { Colors } from '@/constants/theme'
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme'
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const colorScheme = useColorScheme()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
@@ -19,5 +20,5 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Link, Stack } from 'expo-router'
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text'
|
||||
import { ThemedView } from '@/components/themed-view'
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
@@ -15,7 +15,7 @@ export default function NotFoundScreen() {
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -29,4 +29,4 @@ const styles = StyleSheet.create({
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -1,34 +1,34 @@
|
||||
import { useEffect } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { useEffect } from 'react'
|
||||
import { StyleSheet } from 'react-native'
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
} from 'react-native-reanimated';
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedText } from '@/components/themed-text'
|
||||
|
||||
export function HelloWave() {
|
||||
const rotationAnimation = useSharedValue(0);
|
||||
const rotationAnimation = useSharedValue(0)
|
||||
|
||||
useEffect(() => {
|
||||
rotationAnimation.value = withRepeat(
|
||||
withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
|
||||
4
|
||||
);
|
||||
}, [rotationAnimation]);
|
||||
)
|
||||
}, [rotationAnimation])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotationAnimation.value}deg` }],
|
||||
}));
|
||||
}))
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedStyle}>
|
||||
<ThemedText style={styles.text}>👋</ThemedText>
|
||||
</Animated.View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -37,4 +37,4 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -1,30 +1,30 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import type { PropsWithChildren, ReactElement } from 'react'
|
||||
import { StyleSheet } from 'react-native'
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollViewOffset,
|
||||
} from 'react-native-reanimated';
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { ThemedView } from '@/components/themed-view'
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme'
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
const HEADER_HEIGHT = 250
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
headerImage: ReactElement
|
||||
headerBackgroundColor: { dark: string; light: string }
|
||||
}>
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(scrollRef);
|
||||
const colorScheme = useColorScheme() ?? 'light'
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>()
|
||||
const scrollOffset = useScrollViewOffset(scrollRef)
|
||||
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -40,8 +40,8 @@ export default function ParallaxScrollView({
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
@@ -51,13 +51,14 @@ export default function ParallaxScrollView({
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
]}
|
||||
>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -74,4 +75,4 @@ const styles = StyleSheet.create({
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
})
|
||||
+25
-21
@@ -1,14 +1,14 @@
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { appleAuthAndroid, AppleButton } from '@invertase/react-native-apple-authentication';
|
||||
import { SignInWithIdTokenCredentials } from '@supabase/supabase-js';
|
||||
import { Platform } from 'react-native';
|
||||
import 'react-native-get-random-values';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { appleAuthAndroid, AppleButton } from '@invertase/react-native-apple-authentication'
|
||||
import { SignInWithIdTokenCredentials } from '@supabase/supabase-js'
|
||||
import { Platform } from 'react-native'
|
||||
import 'react-native-get-random-values'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
async function onAppleButtonPress() {
|
||||
// Generate secure, random values for state and nonce
|
||||
const rawNonce = uuid();
|
||||
const state = uuid();
|
||||
const rawNonce = uuid()
|
||||
const state = uuid()
|
||||
|
||||
// Configure the request
|
||||
appleAuthAndroid.configure({
|
||||
@@ -30,11 +30,11 @@ async function onAppleButtonPress() {
|
||||
|
||||
// Unique state value used to prevent CSRF attacks. A UUID will be generated if nothing is provided.
|
||||
state,
|
||||
});
|
||||
})
|
||||
|
||||
// Open the browser window for user sign in
|
||||
const credentialState = await appleAuthAndroid.signIn();
|
||||
console.log('Apple sign in successful:', credentialState);
|
||||
const credentialState = await appleAuthAndroid.signIn()
|
||||
console.log('Apple sign in successful:', credentialState)
|
||||
|
||||
if (credentialState.id_token && credentialState.code && credentialState.nonce) {
|
||||
const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = {
|
||||
@@ -42,26 +42,30 @@ async function onAppleButtonPress() {
|
||||
token: credentialState.id_token,
|
||||
nonce: credentialState.nonce,
|
||||
access_token: credentialState.code,
|
||||
};
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials);
|
||||
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials)
|
||||
|
||||
if (error) {
|
||||
console.error('Error signing in with Apple:', error);
|
||||
console.error('Error signing in with Apple:', error)
|
||||
}
|
||||
|
||||
if (data) {
|
||||
console.log('Apple sign in successful:', data);
|
||||
console.log('Apple sign in successful:', data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppleSignInButton() {
|
||||
if (Platform.OS !== 'android' || appleAuthAndroid.isSupported !== true) { return <></> }
|
||||
if (Platform.OS !== 'android' || appleAuthAndroid.isSupported !== true) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <AppleButton
|
||||
buttonStyle={AppleButton.Style.BLACK}
|
||||
buttonType={AppleButton.Type.SIGN_IN}
|
||||
onPress={() => onAppleButtonPress()}
|
||||
/>;
|
||||
return (
|
||||
<AppleButton
|
||||
buttonStyle={AppleButton.Style.BLACK}
|
||||
buttonType={AppleButton.Type.SIGN_IN}
|
||||
onPress={() => onAppleButtonPress()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+29
-21
@@ -1,8 +1,8 @@
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { AppleButton, appleAuth } from '@invertase/react-native-apple-authentication';
|
||||
import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js';
|
||||
import { router } from 'expo-router';
|
||||
import { Platform } from 'react-native';
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { AppleButton, appleAuth } from '@invertase/react-native-apple-authentication'
|
||||
import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js'
|
||||
import { router } from 'expo-router'
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
async function onAppleButtonPress() {
|
||||
// Performs login request
|
||||
@@ -10,42 +10,50 @@ async function onAppleButtonPress() {
|
||||
requestedOperation: appleAuth.Operation.LOGIN,
|
||||
// Note: it appears putting FULL_NAME first is important, see issue #293
|
||||
requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL],
|
||||
});
|
||||
})
|
||||
|
||||
// Get the current authentication state for user
|
||||
// Note: This method must be tested on a real device. On the iOS simulator it always throws an error.
|
||||
const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user);
|
||||
const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user)
|
||||
|
||||
console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse });
|
||||
console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse })
|
||||
|
||||
if (credentialState === appleAuth.State.AUTHORIZED && appleAuthRequestResponse.identityToken && appleAuthRequestResponse.authorizationCode) {
|
||||
if (
|
||||
credentialState === appleAuth.State.AUTHORIZED &&
|
||||
appleAuthRequestResponse.identityToken &&
|
||||
appleAuthRequestResponse.authorizationCode
|
||||
) {
|
||||
const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = {
|
||||
provider: 'apple',
|
||||
token: appleAuthRequestResponse.identityToken,
|
||||
nonce: appleAuthRequestResponse.nonce,
|
||||
access_token: appleAuthRequestResponse.authorizationCode,
|
||||
};
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials);
|
||||
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials)
|
||||
|
||||
if (error) {
|
||||
console.error('Error signing in with Apple:', error);
|
||||
console.error('Error signing in with Apple:', error)
|
||||
}
|
||||
|
||||
if (data) {
|
||||
console.log('Apple sign in successful:', data);
|
||||
router.navigate('/(tabs)');
|
||||
console.log('Apple sign in successful:', data)
|
||||
router.navigate('/(tabs)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppleSignInButton() {
|
||||
if (Platform.OS !== 'ios') { return <></>; }
|
||||
if (Platform.OS !== 'ios') {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <AppleButton
|
||||
buttonStyle={AppleButton.Style.BLACK}
|
||||
buttonType={AppleButton.Type.SIGN_IN}
|
||||
style={{ width: 160, height: 45 }}
|
||||
onPress={() => onAppleButtonPress()}
|
||||
/>;
|
||||
return (
|
||||
<AppleButton
|
||||
buttonStyle={AppleButton.Style.BLACK}
|
||||
buttonType={AppleButton.Type.SIGN_IN}
|
||||
style={{ width: 160, height: 45 }}
|
||||
onPress={() => onAppleButtonPress()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+29
-21
@@ -1,8 +1,8 @@
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { AppleButton, appleAuth } from '@invertase/react-native-apple-authentication';
|
||||
import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js';
|
||||
import { router } from 'expo-router';
|
||||
import { Platform } from 'react-native';
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { AppleButton, appleAuth } from '@invertase/react-native-apple-authentication'
|
||||
import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js'
|
||||
import { router } from 'expo-router'
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
async function onAppleButtonPress() {
|
||||
// Performs login request
|
||||
@@ -10,42 +10,50 @@ async function onAppleButtonPress() {
|
||||
requestedOperation: appleAuth.Operation.LOGIN,
|
||||
// Note: it appears putting FULL_NAME first is important, see issue #293
|
||||
requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL],
|
||||
});
|
||||
})
|
||||
|
||||
// Get the current authentication state for user
|
||||
// Note: This method must be tested on a real device. On the iOS simulator it always throws an error.
|
||||
const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user);
|
||||
const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user)
|
||||
|
||||
console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse });
|
||||
console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse })
|
||||
|
||||
if (credentialState === appleAuth.State.AUTHORIZED && appleAuthRequestResponse.identityToken && appleAuthRequestResponse.authorizationCode) {
|
||||
if (
|
||||
credentialState === appleAuth.State.AUTHORIZED &&
|
||||
appleAuthRequestResponse.identityToken &&
|
||||
appleAuthRequestResponse.authorizationCode
|
||||
) {
|
||||
const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = {
|
||||
provider: 'apple',
|
||||
token: appleAuthRequestResponse.identityToken,
|
||||
nonce: appleAuthRequestResponse.nonce,
|
||||
access_token: appleAuthRequestResponse.authorizationCode,
|
||||
};
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials);
|
||||
const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials)
|
||||
|
||||
if (error) {
|
||||
console.error('Error signing in with Apple:', error);
|
||||
console.error('Error signing in with Apple:', error)
|
||||
}
|
||||
|
||||
if (data) {
|
||||
console.log('Apple sign in successful:', data);
|
||||
router.navigate('/(tabs)');
|
||||
console.log('Apple sign in successful:', data)
|
||||
router.navigate('/(tabs)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppleSignInButton() {
|
||||
if (Platform.OS !== 'ios') { return <></>; }
|
||||
if (Platform.OS !== 'ios') {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <AppleButton
|
||||
buttonStyle={AppleButton.Style.BLACK}
|
||||
buttonType={AppleButton.Type.SIGN_IN}
|
||||
style={{ width: 160, height: 45 }}
|
||||
onPress={() => onAppleButtonPress()}
|
||||
/>;
|
||||
return (
|
||||
<AppleButton
|
||||
buttonStyle={AppleButton.Style.BLACK}
|
||||
buttonType={AppleButton.Type.SIGN_IN}
|
||||
style={{ width: 160, height: 45 }}
|
||||
onPress={() => onAppleButtonPress()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 441 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user