mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 07:39:07 +03:00
Merge branch 'master' into self-hosted/fix-connect
This commit is contained in:
540 files changed
+5791
-3547
No files matched your search
@@ -58,6 +58,7 @@ const FunctionLink = memo(function FunctionLink({
|
||||
*/
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
menuState.setMenuActiveRefId(id)
|
||||
history.pushState({}, '', url)
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
document.getElementById(slug)?.scrollIntoView({
|
||||
|
||||
@@ -18,6 +18,8 @@ Supabase provides client libraries for the REST and Realtime APIs. Some librarie
|
||||
|
||||
## Community libraries
|
||||
|
||||
{/* supa-mdx-lint-disable Rule003Spelling */}
|
||||
|
||||
| `Language` | `Source Code` | `Documentation` |
|
||||
| ----------------------- | -------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| C# | [supabase-csharp](https://github.com/supabase-community/supabase-csharp) | [Docs](/docs/reference/csharp/introduction) |
|
||||
@@ -25,3 +27,4 @@ Supabase provides client libraries for the REST and Realtime APIs. Some librarie
|
||||
| Kotlin | [supabase-kt](https://github.com/supabase-community/supabase-kt) | [Docs](/docs/reference/kotlin/introduction) |
|
||||
| Ruby | [supabase-rb](https://github.com/supabase-community/supabase-rb) | |
|
||||
| Godot Engine (GDScript) | [supabase-gdscript](https://github.com/supabase-community/godot-engine.supabase) | |
|
||||
| Elixir | [supabase-elixir](https://github.com/supabase-community/supabase-ex) | |
|
||||
@@ -66,7 +66,7 @@ hideToc: true
|
||||
|
||||
Create a helper file `lib/supabase.ts` that exports a Supabase client using your Project URL and key.
|
||||
|
||||
Create a `.env` file and populate with your Supabase connection variables:
|
||||
Rename `.env.example` to `.env` and populate with your Supabase connection variables:
|
||||
|
||||
<ProjectConfigVariables variable="url" />
|
||||
<ProjectConfigVariables variable="publishable" />
|
||||
@@ -90,7 +90,7 @@ hideToc: true
|
||||
<StepHikeCompact.Step step={5}>
|
||||
<StepHikeCompact.Details title="Create a login component">
|
||||
|
||||
Let's set up a React Native component to manage logins and sign ups.
|
||||
Create a React Native component to manage logins and sign ups. The app later uses the [`getClaims`](/docs/reference/javascript/auth-getclaims) method in `App.tsx` to validate the local JWT before showing the signed-in user.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
|
||||
@@ -74,10 +74,11 @@ hideToc: true
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```text name=.env.local
|
||||
VITE_SUPABASE_URL=your-project-url
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=sb_publishable_... or anon key
|
||||
```
|
||||
<$CodeSample
|
||||
path="/auth/quickstarts/react/.env.example"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=.env.local"
|
||||
/>
|
||||
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "react", "tab": "frameworks" }} />
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
@@ -90,166 +91,16 @@ hideToc: true
|
||||
|
||||
In `App.jsx`, create a Supabase client using your Project URL and key.
|
||||
|
||||
You can configure the Auth component to display whenever there is no session inside `supabase.auth.getSession()`
|
||||
The code uses the [`getClaims`](/docs/reference/javascript/auth-getclaims) method in `App.jsx` to validate the local JWT before showing the signed-in user.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```jsx name=src/App.jsx
|
||||
import "./index.css";
|
||||
import { useState, useEffect } from "react";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = createClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY);
|
||||
|
||||
export default function App() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [session, setSession] = useState(null);
|
||||
|
||||
// Check URL params on initial render
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const hasTokenHash = params.get("token_hash");
|
||||
|
||||
const [verifying, setVerifying] = useState(!!hasTokenHash);
|
||||
const [authError, setAuthError] = useState(null);
|
||||
const [authSuccess, setAuthSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we have token_hash in URL (magic link callback)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token_hash = params.get("token_hash");
|
||||
const type = params.get("type");
|
||||
|
||||
if (token_hash) {
|
||||
// Verify the OTP token
|
||||
supabase.auth.verifyOtp({
|
||||
token_hash,
|
||||
type: type || "email",
|
||||
}).then(({ error }) => {
|
||||
if (error) {
|
||||
setAuthError(error.message);
|
||||
} else {
|
||||
setAuthSuccess(true);
|
||||
// Clear URL params
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
}
|
||||
setVerifying(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Check for existing session
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
});
|
||||
|
||||
// Listen for auth changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (event) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: window.location.origin,
|
||||
}
|
||||
});
|
||||
if (error) {
|
||||
alert(error.error_description || error.message);
|
||||
} else {
|
||||
alert("Check your email for the login link!");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
setSession(null);
|
||||
};
|
||||
|
||||
// Show verification state
|
||||
if (verifying) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Authentication</h1>
|
||||
<p>Confirming your magic link...</p>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show auth error
|
||||
if (authError) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Authentication</h1>
|
||||
<p>✗ Authentication failed</p>
|
||||
<p>{authError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAuthError(null);
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
}}
|
||||
>
|
||||
Return to login
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show auth success (briefly before session loads)
|
||||
if (authSuccess && !session) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Authentication</h1>
|
||||
<p>✓ Authentication successful!</p>
|
||||
<p>Loading your account...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If user is logged in, show welcome screen
|
||||
if (session) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome!</h1>
|
||||
<p>You are logged in as: {session.user.email}</p>
|
||||
<button onClick={handleLogout}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login form
|
||||
return (
|
||||
<div>
|
||||
<h1>Supabase + React</h1>
|
||||
<p>Sign in via magic link with your email below</p>
|
||||
<form onSubmit={handleLogin}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
required={true}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button disabled={loading}>
|
||||
{loading ? <span>Loading</span> : <span>Send magic link</span>}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
<$CodeSample
|
||||
path="/auth/quickstarts/react/src/App.jsx"
|
||||
lines={[[1, -1]]}
|
||||
meta="name=src/App.jsx"
|
||||
/>
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ Supabase branches create separate environments that spin off from your main proj
|
||||
## How branching works
|
||||
|
||||
- **Separate Environments**: Each branch is a separate environment with its own Supabase instance and API credentials.
|
||||
- **Preview Branches**: You can create multiple Preview Branches for testing.
|
||||
- **Persistent Branches**: Persistent branches are long-lived branches. They aren't automatically paused or deleted due to non-inactivity or merging.
|
||||
- **Preview Branches**: Preview branches are ephemeral and best suited for focused testing. They are automatically paused after inactivity or deleted when a PR is merged or closed.
|
||||
- **Persistent Branches**: Persistent branches are long-lived and recommended for environments like staging, QA, or development. Unlike preview branches, they aren't automatically paused or deleted due to inactivity or when a PR is merged or closed.
|
||||
- **Managing Branches**: You can create, review, and merge branches either automatically via our [GitHub integration](/docs/guides/deployment/branching/github-integration) or directly [through the dashboard](/docs/guides/deployment/branching/dashboard) (currently in beta). All branches show up in the branches page in the dashboard, regardless of how they were created.
|
||||
- **Data-less**: New branches do not start with any data from your main project. This is meant to better protect your sensitive production data. To start your branches with data, you can use a [seed file](/docs/guides/deployment/branching/github-integration#seeding) if using the GitHub integration.
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ lines={[[1, -1]]}
|
||||
meta="name=src/environments/environment.ts"
|
||||
/>
|
||||
|
||||
Now you have the API credentials in place, create a `SupabaseService` with `ng g s supabase` and add the following code to initialize the Supabase client and implement functions to communicate with the Supabase API.
|
||||
With the API credentials in place, create a `SupabaseService` with `ng g s supabase` and add the following code to initialize the Supabase client and implement functions to communicate with the Supabase API.
|
||||
|
||||
This uses the [`getUser`](/docs/reference/javascript/auth-getuser) method to get the current user details if there is an existing session. This method performs a network request to the Supabase Auth server.
|
||||
|
||||
<$CodeSample
|
||||
path="/user-management/angular-user-management/src/app/supabase.service.ts"
|
||||
|
||||
@@ -173,5 +173,5 @@ As mentioned in the Postgres [documentation](https://postgresqlco.nf/doc/en/para
|
||||
|
||||
### Constraints
|
||||
|
||||
- You can modify disk attributes up to **four times** within a rolling 24-hour window. A new modification can be initiated as soon as the previous one completes. If you reach this limit, you will encounter throttling and must wait for the rolling 24-hour window to permit further adjustments.
|
||||
- After **any** disk attribute change, there is a cooldown period of approximately six hours before you can make further adjustments. During this time, no changes are allowed. If you encounter throttling, you’ll need to wait until the cooldown period concludes before making additional modifications.
|
||||
- You can increase disk size but cannot decrease it.
|
||||
@@ -71,7 +71,6 @@ Vacuum operations can temporarily increase resource utilization, which may adver
|
||||
</Admonition>
|
||||
|
||||
Supabase projects have automatic vacuuming enabled, which ensures that these operations are performed regularly to keep the database healthy and performant.
|
||||
|
||||
It is possible to [fine-tune](https://www.percona.com/blog/2018/08/10/tuning-autovacuum-in-postgresql-and-autovacuum-internals/) the [autovacuum parameters](https://www.enterprisedb.com/blog/postgresql-vacuum-and-analyze-best-practice-tips), or [manually initiate](https://www.postgresql.org/docs/current/sql-vacuum.html) vacuum operations.
|
||||
Running a manual vacuum after deleting large amounts of data from your DB could help reduce the database size reported by Postgres.
|
||||
|
||||
@@ -87,9 +86,7 @@ Supabase uses network-attached storage to balance performance with scalability.
|
||||
|
||||
Projects on the Pro Plan and higher have auto-scaling disks.
|
||||
|
||||
Disk size expands automatically when the database reaches 90% of the allocated disk size. The disk is expanded to be 50% larger (for example, 8 GB -> 12 GB).
|
||||
|
||||
Auto-scaling is limited to four modifications within a rolling 24-hour window. While a new modification can be initiated immediately after the previous one completes, reaching the daily quota of four resizes will prevent further scaling until the rolling window allows it. If you reach 95% disk utilization and have exhausted your modification quota, your project will enter read-only mode.
|
||||
Disk size expands automatically when the database reaches 90% of the allocated disk size. The disk is expanded to be 50% larger (for example, 8 GB -> 12 GB). Auto-scaling can only take place once every 6 hours. If within those 6 hours you reach 95% of the disk space, your project will enter read-only mode.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
@@ -103,7 +100,7 @@ Disk size can also be manually expanded on the [Database Settings page](/dashboa
|
||||
|
||||
You may want to import a lot of data into your database which requires multiple disk expansions. for example, uploading more than 1.5x the current size of your database storage will put your database into [read-only mode](#read-only-mode). If so, it is highly recommended you increase the disk size manually on the [Database Settings page](/dashboard/project/_/database/settings).
|
||||
|
||||
Due to restrictions on the underlying cloud provider, disk modifications are limited to four operations within a rolling 24-hour window. While a new modification can be initiated as soon as the previous one completes, you will be unable to make further adjustments if you reach this daily quota until the rolling 24-hour window permits it.
|
||||
Due to restrictions on the underlying cloud provider, disk expansions can occur only once every six hours. During the six hour cool down window, the disk cannot be resized again.
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ Your Supabase project supports connecting to the Postgres DB without SSL enabled
|
||||
|
||||
Disabling SSL enforcement only applies to connections to Postgres and Supavisor ("Connection Pooler"); all HTTP APIs offered by Supabase (e.g., PostgREST, Storage, Auth) automatically enforce SSL on all incoming connections.
|
||||
|
||||
<Admonition type="note">
|
||||
<Admonition type="caution">
|
||||
|
||||
Projects need to be at least on Postgres 13.3.0 to enable SSL enforcement. You can find the Postgres version of your project in the [Infrastructure Settings page](/dashboard/project/_/settings/infrastructure). If your project is on an older version, you will need to [upgrade](/docs/guides/platform/migrating-and-upgrading-projects#upgrade-your-project) to use this feature.
|
||||
Applying or updating SSL enforcement triggers a fast database reboot. On small projects this usually completes in a few seconds, but larger databases may see a longer interruption.
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ Since the wraparound prevention autovacuum cannot be stopped, the best approach
|
||||
2. **Increase Disk Throughput/IOPS:**
|
||||
- **Action:** If disk I/O utilization is also consistently high (e.g., near 100%), consider temporarily increasing your disk's provisioned IOPS and throughput.
|
||||
- **Why it helps:** Autovacuum is an I/O-intensive operation, involving a lot of reading and writing. Higher disk performance can significantly speed up the process.
|
||||
- **Considerations:** Cloud providers may limit the number of disk modifications (e.g., up to four in 24 hours). You can start a new modification immediately after the previous one finishes, provided you have not exceeded the daily quota.
|
||||
- **Considerations:** Cloud providers often have limitations, such as a cooldown period (e.g., 6 hours) between disk modification operations.
|
||||
|
||||
### **Monitoring progress and future prevention**
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
---
|
||||
title = "How to bypass modification limits"
|
||||
title = "How to bypass cooldown period"
|
||||
topics = [
|
||||
"platform"
|
||||
]
|
||||
keywords = ["modification limits", "disk resize"]
|
||||
keywords = ["cooldown", "disk resize"]
|
||||
---
|
||||
|
||||
This limit is not a Supabase limitation. It is rooted in how Amazon EBS (the underlying storage for our databases) manages volume modifications. AWS allows up to four modifications per volume within a rolling 24-hour window. While you can start a new modification immediately after a previous one completes, AWS enforces a quota that prevents a fifth modification within that 24-hour period to ensure data integrity and volume stability.
|
||||
This cooldown period isn't a Supabase limitation. It's rooted in how Amazon EBS (the underlying storage instance for our databases) manages volume modifications. After modifying a volume (e.g. increasing size, changing type, or IOPS), AWS enforces a mandatory 6-hour cooldown before allowing another modification on the same volume. This is to ensure data integrity and stability of the volume under load.
|
||||
|
||||
From the [**AWS docs**](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_ModifyVolume.html):
|
||||
|
||||
> After you initiate a volume modification, you must wait for that modification to reach the completed state before you can initiate another modification for the same volume. You can modify a volume up to four times within a rolling 24-hour period, as long as the volume is in the in-use or available state, and all previous modifications for that volume are completed. If you exceed this limit, you get an error message that indicates when you can perform your next modification.
|
||||
> “After modifying a volume, you must wait at least six hours and ensure that the volume is in the in-use or available state before you can modify the same volume. This is sometimes referred to as a cooldown period.”
|
||||
|
||||
There are a few options to work around this limit if you have already used your four modifications and need to make further adjustments:
|
||||
There are a few options to work around the cooldown, depending on the state of your database:
|
||||
|
||||
1. **Restore to a new project**: This spins up a new instance with a new disk, bypassing the the 24-hour quota entirely. It’s a great option if you're okay with a new project and project refactoring. [**Docs: restoring to a new project**](/docs/guides/platform/backups#restore-to-a-new-project).
|
||||
2. **pg_upgrade**: Our [**pg_upgrade**](/docs/guides/platform/upgrading) implementation migrates your data to a new disk, which resets the modification count. The main requirement here is that the database must be operational - it can't run it if your DB is in a degraded or inaccessible state.
|
||||
1. **Restore to a new project**: This spins up a new instance with a new disk, bypassing the cooldown entirely. It’s a great option if you're okay with a new project and project refactoring. [**Docs: restoring to a new project**](/docs/guides/platform/backups#restore-to-a-new-project).
|
||||
2. **pg_upgrade**: Our [**pg_upgrade**](/docs/guides/platform/upgrading) implementation migrates your data to a new disk, which skips the cooldown. The main requirement here is that the database must be operational - it can't run it if your DB is in a degraded or inaccessible state.
|
||||
3. **Pause and Restore**: This also migrates to a new disk but is only available for projects on the Free plan. If you're not on the Free plan, you'd need to [**transfer your project to an organization on the Free plan**](/docs/guides/platform/project-transfer) first.
|
||||
|
||||
If the database is down or locked in a bad state (e.g corrupted or stuck during resize), and you have hit the modification limit, the only path forward is to wait until the rolling 24-hour window allows for another modification.
|
||||
If the database is down or locked in a bad state (e.g corrupted or stuck during resize), the only path forward is to wait until the cooldown expires and the disk resize job completes in the queue.
|
||||
|
||||
More on this in our doc here: [**https://supabase.com/docs/guides/platform/database-size#disk-size**](/docs/guides/platform/database-size#disk-size).
|
||||
@@ -7,7 +7,16 @@ import { ChevronUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import type { HTMLAttributes, MouseEvent, PropsWithChildren } from 'react'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
|
||||
import { cn } from 'ui'
|
||||
|
||||
@@ -17,6 +26,63 @@ import { BASE_PATH } from '~/lib/constants'
|
||||
|
||||
export const ReferenceContentInitiallyScrolledContext = createContext<boolean>(false)
|
||||
|
||||
let patchCount = 0
|
||||
let originalPushState: typeof history.pushState | null = null
|
||||
let originalReplaceState: typeof history.replaceState | null = null
|
||||
const pathnameListeners = new Set<() => void>()
|
||||
|
||||
function notifyPathnameListeners() {
|
||||
pathnameListeners.forEach((callback) => callback())
|
||||
}
|
||||
|
||||
function subscribeToPathname(callback: () => void) {
|
||||
pathnameListeners.add(callback)
|
||||
|
||||
if (patchCount === 0) {
|
||||
window.addEventListener('popstate', notifyPathnameListeners)
|
||||
|
||||
originalPushState = history.pushState.bind(history)
|
||||
history.pushState = (...args) => {
|
||||
originalPushState!(...args)
|
||||
notifyPathnameListeners()
|
||||
}
|
||||
|
||||
originalReplaceState = history.replaceState.bind(history)
|
||||
history.replaceState = (...args) => {
|
||||
originalReplaceState!(...args)
|
||||
notifyPathnameListeners()
|
||||
}
|
||||
}
|
||||
patchCount++
|
||||
|
||||
return () => {
|
||||
pathnameListeners.delete(callback)
|
||||
patchCount--
|
||||
|
||||
if (patchCount === 0) {
|
||||
window.removeEventListener('popstate', notifyPathnameListeners)
|
||||
history.pushState = originalPushState!
|
||||
history.replaceState = originalReplaceState!
|
||||
originalPushState = null
|
||||
originalReplaceState = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPathname() {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const pathname = window.location.pathname
|
||||
return pathname.startsWith(BASE_PATH) ? pathname.slice(BASE_PATH.length) : pathname
|
||||
}
|
||||
|
||||
function getServerPathname() {
|
||||
return ''
|
||||
}
|
||||
|
||||
function useCurrentPathname() {
|
||||
return useSyncExternalStore(subscribeToPathname, getPathname, getServerPathname)
|
||||
}
|
||||
|
||||
export function ReferenceContentScrollHandler({
|
||||
libPath,
|
||||
version,
|
||||
@@ -184,7 +250,7 @@ export function RefLink({
|
||||
}) {
|
||||
const ref = useRef<HTMLAnchorElement>(null)
|
||||
|
||||
const pathname = usePathname()
|
||||
const pathname = useCurrentPathname()
|
||||
const href = deriveHref(basePath, section)
|
||||
const isActive =
|
||||
pathname === href || (pathname === basePath && href.replace(basePath, '') === '/introduction')
|
||||
@@ -230,7 +296,7 @@ export function RefLink({
|
||||
function useCompoundRefLinkActive(basePath: string, section: AbbrevApiReferenceSection) {
|
||||
const [open, _setOpen] = useState(false)
|
||||
|
||||
const pathname = usePathname()
|
||||
const pathname = useCurrentPathname()
|
||||
const parentHref = deriveHref(basePath, section)
|
||||
const isParentActive = pathname === parentHref
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Supabase is 100% remote.
|
||||
Aaron Byrne
|
||||
Ada Wong
|
||||
Adam Mokan
|
||||
AJ Matias
|
||||
Akash Manimaran
|
||||
Alaister Young
|
||||
Alan De Los Santos
|
||||
@@ -66,6 +67,7 @@ Eliot Whalan
|
||||
Emmett Folger
|
||||
Eric Kharitonashvili
|
||||
Etienne Stalmans
|
||||
Eyal Ehrlich
|
||||
Fabrizio Cataldo
|
||||
Fabrizio Fenoglio
|
||||
Fady A
|
||||
@@ -84,6 +86,7 @@ Hardik Maheshwari
|
||||
Haritabh Gupta
|
||||
Haydn Maley
|
||||
Hieu Pham
|
||||
Hunter Luckow
|
||||
Ignacio Dobronich
|
||||
Illia Basalaiev
|
||||
Inian P
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
@@ -1917,9 +1917,9 @@ commands:
|
||||
summary: Show statistics related to vacuum operations per table
|
||||
description: |2
|
||||
|
||||
This shows you stats about the vacuum activities for each table. Due to Postgres' [MVCC](https://www.postgresql.org/docs/current/mvcc.html) when data is updated or deleted new rows are created and old rows are made invisible and marked as "dead tuples". Usually the [autovaccum](https://supabase.com/docs/guides/platform/database-size#vacuum-operations) process will aysnchronously clean the dead tuples.
|
||||
This shows you stats about the vacuum activities for each table. Due to Postgres' [MVCC](https://www.postgresql.org/docs/current/mvcc.html) when data is updated or deleted new rows are created and old rows are made invisible and marked as "dead tuples". Usually the [autovacuum](https://supabase.com/docs/guides/platform/database-size#vacuum-operations) process will asynchronously clean the dead tuples.
|
||||
|
||||
The command lists when the last vacuum and last auto vacuum took place, the row count on the table as well as the count of dead rows and whether autovacuum is expected to run or not. If the number of dead rows is much higher than the row count, or if an autovacuum is expected but has not been performed for some time, this can indicate that autovacuum is not able to keep up and that your vacuum settings need to be tweaked or that you require more compute or disk IOPS to allow autovaccum to complete.
|
||||
The command lists when the last vacuum and last auto vacuum took place, the row count on the table as well as the count of dead rows and whether autovacuum is expected to run or not. If the number of dead rows is much higher than the row count, or if an autovacuum is expected but has not been performed for some time, this can indicate that autovacuum is not able to keep up and that your vacuum settings need to be tweaked or that you require more compute or disk IOPS to allow autovacuum to complete.
|
||||
|
||||
|
||||
```
|
||||
@@ -2296,9 +2296,9 @@ commands:
|
||||
Estimates space allocated to a relation that is full of dead tuples
|
||||
description: |2
|
||||
|
||||
This command displays an estimation of table "bloat" - Due to Postgres' [MVCC](https://www.postgresql.org/docs/current/mvcc.html) when data is updated or deleted new rows are created and old rows are made invisible and marked as "dead tuples". Usually the [autovaccum](https://supabase.com/docs/guides/platform/database-size#vacuum-operations) process will asynchronously clean the dead tuples. Sometimes the autovaccum is unable to work fast enough to reduce or prevent tables from becoming bloated. High bloat can slow down queries, cause excessive IOPS and waste space in your database.
|
||||
This command displays an estimation of table "bloat" - Due to Postgres' [MVCC](https://www.postgresql.org/docs/current/mvcc.html) when data is updated or deleted new rows are created and old rows are made invisible and marked as "dead tuples". Usually the [autovacuum](https://supabase.com/docs/guides/platform/database-size#vacuum-operations) process will asynchronously clean the dead tuples. Sometimes the autovacuum is unable to work fast enough to reduce or prevent tables from becoming bloated. High bloat can slow down queries, cause excessive IOPS and waste space in your database.
|
||||
|
||||
Tables with a high bloat ratio should be investigated to see if there are vacuuming is not quick enough or there are other issues.
|
||||
Tables with a high bloat ratio should be investigated to see if vacuuming is not quick enough or if there are other issues.
|
||||
|
||||
```
|
||||
TYPE │ SCHEMA NAME │ OBJECT NAME │ BLOAT │ WASTE
|
||||
@@ -3258,7 +3258,7 @@ commands:
|
||||
|
||||
By default, all schemas in the target database are diffed. Use the `--schema public,extensions` flag to restrict diffing to a subset of schemas.
|
||||
|
||||
While the diff command is able to capture most schema changes, there are cases where it is known to fail. Currently, this could happen if you schema contains:
|
||||
While the diff command is able to capture most schema changes, there are cases where it is known to fail. Currently, this could happen if your schema contains:
|
||||
|
||||
- Changes to publication
|
||||
- Changes to storage buckets
|
||||
|
||||
@@ -26,3 +26,5 @@ NEXT_PUBLIC_SUPABASE_URL=https://xguihxuzqibwxjnimxev.supabase.co
|
||||
|
||||
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
|
||||
|
||||
# Required to manage edge-functions via dashboard on Self-Hosting
|
||||
# EDGE_FUNCTIONS_MANAGEMENT_FOLDER=/path-where-edge-functions-are-mounted
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Eye } from 'lucide-react'
|
||||
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'
|
||||
import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import {
|
||||
useOperationQueueShortcuts,
|
||||
getModKey,
|
||||
} from 'components/grid/hooks/useOperationQueueShortcuts'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'
|
||||
import { getModKeyLabel } from '@/lib/helpers'
|
||||
|
||||
export const SaveQueueActionBar = () => {
|
||||
const modKey = getModKeyLabel()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
const { handleSave } = useOperationQueueActions()
|
||||
|
||||
useOperationQueueShortcuts()
|
||||
|
||||
const operationCount = snap.operationQueue.operations.length
|
||||
const isSaving = snap.operationQueue.status === 'saving'
|
||||
const isOperationQueuePanelOpen = snap.sidePanel?.type === 'operation-queue'
|
||||
@@ -23,16 +24,6 @@ export const SaveQueueActionBar = () => {
|
||||
const isVisible =
|
||||
isQueueOperationsEnabled && snap.hasPendingOperations && !isOperationQueuePanelOpen
|
||||
|
||||
useOperationQueueShortcuts({
|
||||
enabled: isQueueOperationsEnabled && snap.hasPendingOperations,
|
||||
onSave: handleSave,
|
||||
onTogglePanel: () => snap.onViewOperationQueue(),
|
||||
isSaving,
|
||||
hasOperations: operationCount > 0,
|
||||
})
|
||||
|
||||
const modKey = getModKey()
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
@@ -49,7 +40,7 @@ export const SaveQueueActionBar = () => {
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => snap.onViewOperationQueue()}
|
||||
onClick={() => snap.toggleViewOperationQueue()}
|
||||
className="text-foreground-light hover:text-foreground transition-colors flex items-center"
|
||||
aria-label="View Details"
|
||||
>
|
||||
@@ -73,6 +64,6 @@ export const SaveQueueActionBar = () => {
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
||||
if (typeof document === 'undefined') return null
|
||||
if (typeof document === 'undefined' || !document.body) return null
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/query/table-row-query'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { isEqual } from 'lodash'
|
||||
import { ChevronDown, List } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
|
||||
import type { Sort } from 'components/grid/types'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { isEqual } from 'lodash'
|
||||
import { ChevronDown, List } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
type RoleImpersonationState,
|
||||
useRoleImpersonationStateSnapshot,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
Popover_Shadcn_,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { DropdownControl } from '../../common/DropdownControl'
|
||||
import SortRow from './SortRow'
|
||||
|
||||
@@ -278,8 +278,10 @@ export const SortPopoverPrimitive = ({
|
||||
const hasSortNotPK = localSorts.some(
|
||||
(x) => !snap.table.columns.find((y) => x.column === y.name)?.isPrimaryKey
|
||||
)
|
||||
if (hasSortNotPK) setShowWarning(true)
|
||||
} else onSelectApplySorts()
|
||||
if (hasSortNotPK) return setShowWarning(true)
|
||||
}
|
||||
|
||||
onSelectApplySorts()
|
||||
}}
|
||||
>
|
||||
Apply sorting
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useOperationQueueSaveMutation } from 'data/table-rows/operation-queue-save-mutation'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { QueuedOperation } from 'state/table-editor-operation-queue.types'
|
||||
@@ -30,6 +29,7 @@ export function useOperationQueueActions(options: UseOperationQueueActionsOption
|
||||
useOperationQueueSaveMutation({
|
||||
onSuccess: () => {
|
||||
snap.clearQueue()
|
||||
snap.closeSidePanel()
|
||||
toast.success('Changes saved successfully')
|
||||
onSaveSuccess?.()
|
||||
},
|
||||
@@ -40,12 +40,10 @@ export function useOperationQueueActions(options: UseOperationQueueActionsOption
|
||||
})
|
||||
|
||||
const isSaving = snap.operationQueue.status === 'saving' || isMutationPending
|
||||
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!project) return
|
||||
|
||||
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
|
||||
if (operations.length === 0) return
|
||||
if (!project || operations.length === 0) return
|
||||
|
||||
snap.setQueueStatus('saving')
|
||||
|
||||
@@ -55,7 +53,7 @@ export function useOperationQueueActions(options: UseOperationQueueActionsOption
|
||||
operations,
|
||||
roleImpersonationState: getImpersonatedRoleState(),
|
||||
})
|
||||
}, [snap, project, saveOperationQueue, getImpersonatedRoleState])
|
||||
}, [snap, project, operations, saveOperationQueue, getImpersonatedRoleState])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
// Get unique table IDs from the queue before clearing
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
import { detectOS } from 'lib/helpers'
|
||||
|
||||
export function getModKey() {
|
||||
const os = detectOS()
|
||||
return os === 'macos' ? '⌘' : 'Ctrl+'
|
||||
}
|
||||
|
||||
interface UseOperationQueueShortcutsOptions {
|
||||
enabled: boolean
|
||||
onSave: () => void
|
||||
onTogglePanel: () => void
|
||||
isSaving?: boolean
|
||||
hasOperations?: boolean
|
||||
}
|
||||
import { useOperationQueueActions } from './useOperationQueueActions'
|
||||
import { useIsQueueOperationsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useHotKey } from '@/hooks/ui/useHotKey'
|
||||
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
|
||||
/**
|
||||
* Hook that provides keyboard shortcuts for the operation queue.
|
||||
@@ -25,44 +13,34 @@ interface UseOperationQueueShortcutsOptions {
|
||||
* These shortcuts are registered on the capture phase to ensure they fire
|
||||
* before the data grid handles the keyboard event.
|
||||
*/
|
||||
export function useOperationQueueShortcuts({
|
||||
enabled,
|
||||
onSave,
|
||||
onTogglePanel,
|
||||
isSaving = false,
|
||||
hasOperations = true,
|
||||
}: UseOperationQueueShortcutsOptions) {
|
||||
const os = detectOS()
|
||||
const modKey = os === 'macos' ? '⌘' : 'Ctrl+'
|
||||
export function useOperationQueueShortcuts() {
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const { handleSave } = useOperationQueueActions()
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const isMod = os === 'macos' ? event.metaKey : event.ctrlKey
|
||||
const isSaving = snap.operationQueue.status === 'saving'
|
||||
const hasOperations = snap.hasPendingOperations
|
||||
const isEnabled = isQueueOperationsEnabled && hasOperations
|
||||
|
||||
if (isMod && event.key === 's') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!isSaving && hasOperations) {
|
||||
onSave()
|
||||
}
|
||||
} else if (isMod && event.key === '.') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onTogglePanel()
|
||||
useHotKey(
|
||||
(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!isSaving && hasOperations) {
|
||||
handleSave()
|
||||
}
|
||||
},
|
||||
[os, isSaving, hasOperations, onSave, onTogglePanel]
|
||||
's',
|
||||
{ enabled: isEnabled }
|
||||
)
|
||||
|
||||
// Use capture phase to intercept events before the grid handles them
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
}
|
||||
}, [enabled, handleKeyDown])
|
||||
|
||||
return { modKey }
|
||||
useHotKey(
|
||||
(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
snap.toggleViewOperationQueue()
|
||||
},
|
||||
'.',
|
||||
{ enabled: isEnabled }
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsMFAEnabled, useParams } from 'common'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
|
||||
@@ -10,6 +6,9 @@ import useLatest from 'hooks/misc/useLatest'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Ideally these could all be within a _middleware when we use Next 12
|
||||
export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
@@ -70,7 +69,7 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
const isValidOrg = organizations.some((org) => org.slug === slug)
|
||||
|
||||
if (!isValidOrg) {
|
||||
toast.error("We couldn't find that organization")
|
||||
toast.error('You do not have access to this organization')
|
||||
router.push(`${DEFAULT_HOME}?error=org_not_found&org=${slug}`)
|
||||
return
|
||||
}
|
||||
@@ -83,7 +82,7 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
|
||||
// A successful request to project details will validate access to both project and branches
|
||||
if (!!ref && isErrorProject) {
|
||||
toast.error('This project does not exist')
|
||||
toast.error('You do not have access to this project')
|
||||
router.push(DEFAULT_HOME)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,9 +116,17 @@ export const MfaAuthSettingsForm = () => {
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
const { hasAccess: hasAccessToMFA, isLoading: isLoadingEntitlement } =
|
||||
const { hasAccess: hasAccessToMFAEntitlement, isLoading: isLoadingEntitlement } =
|
||||
useCheckEntitlements('auth.mfa_phone')
|
||||
const promptProPlanUpgrade = IS_PLATFORM && !hasAccessToMFA
|
||||
const hasAccessToMFA = !IS_PLATFORM || hasAccessToMFAEntitlement
|
||||
const promptProPlanUpgrade = IS_PLATFORM && !hasAccessToMFAEntitlement
|
||||
|
||||
const {
|
||||
hasAccess: hasAccessToEnhanceSecurityEntitlement,
|
||||
isLoading: isLoadingEntitlementEnhanceSecurity,
|
||||
} = useCheckEntitlements('auth.mfa_enhanced_security')
|
||||
const hasAccessToEnhanceSecurity = !IS_PLATFORM || hasAccessToEnhanceSecurityEntitlement
|
||||
const promptEnhancedSecurityUpgrade = IS_PLATFORM && !hasAccessToEnhanceSecurityEntitlement
|
||||
|
||||
// For now, we support Twilio and Vonage. Twilio Verify is not supported and the remaining providers are community maintained.
|
||||
const sendSMSHookIsEnabled =
|
||||
@@ -282,7 +290,7 @@ export const MfaAuthSettingsForm = () => {
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading || isLoadingEntitlement) {
|
||||
if (isLoading || isLoadingEntitlement || isLoadingEntitlementEnhanceSecurity) {
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
@@ -580,13 +588,24 @@ export const MfaAuthSettingsForm = () => {
|
||||
<Switch
|
||||
checked={!field.value}
|
||||
onCheckedChange={(value) => field.onChange(!value)}
|
||||
disabled={!canUpdateConfig}
|
||||
disabled={!canUpdateConfig || !hasAccessToEnhanceSecurity}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
{promptEnhancedSecurityUpgrade && (
|
||||
<UpgradeToPro
|
||||
fullWidth
|
||||
source="authEnhancedSecurity"
|
||||
featureProposition="configure settings for Enhanced MFA Security"
|
||||
primaryText="Enhanced MFA Security is not available on your plan"
|
||||
secondaryText="Upgrade your plan to configure settings for Enhanced MFA Security"
|
||||
buttonText="Upgrade"
|
||||
/>
|
||||
)}
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{securityForm.formState.isDirty && (
|
||||
<Button type="default" onClick={() => securityForm.reset()}>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { UpgradeToPro } from 'components/ui/UpgradeToPro'
|
||||
import { useBackupRestoreMutation } from 'data/database/backup-restore-mutation'
|
||||
import { DatabaseBackup, useBackupsQuery } from 'data/database/backups-query'
|
||||
import { useSetProjectStatus } from 'data/projects/project-detail-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import { BackupItem } from './BackupItem'
|
||||
import { BackupsEmpty } from './BackupsEmpty'
|
||||
import { BackupsStorageAlert } from './BackupsStorageAlert'
|
||||
@@ -111,11 +112,17 @@ export const BackupsList = () => {
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">
|
||||
This will restore your database to the backup made on{' '}
|
||||
{dayjs(selectedBackup?.inserted_at).format('DD MMM YYYY')} at{' '}
|
||||
{dayjs(selectedBackup?.inserted_at).format('HH:mm:ss')} UTC.
|
||||
</p>
|
||||
{!!selectedBackup && (
|
||||
<p className="text-sm">
|
||||
This will restore your database to the backup made on{' '}
|
||||
<TimestampInfo
|
||||
displayAs="utc"
|
||||
utcTimestamp={selectedBackup.inserted_at}
|
||||
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
|
||||
className="!text-sm"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
|
||||
+6
-1
@@ -35,7 +35,12 @@ export const BackupsList = ({ onSelectRestore, disabled }: BackupsListProps) =>
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 items-center p-4" key={backup.id}>
|
||||
<div>
|
||||
<TimestampInfo utcTimestamp={backup.inserted_at} />
|
||||
<TimestampInfo
|
||||
displayAs="utc"
|
||||
utcTimestamp={backup.inserted_at}
|
||||
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
|
||||
className="text-left !text-sm font-mono tracking-tight"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Badge>{JSON.stringify(backup.status).replaceAll('"', '')}</Badge>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { PostgresExtension } from '@supabase/postgres-meta'
|
||||
import { Database, ExternalLinkIcon, Plus } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
@@ -10,6 +6,9 @@ import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useProtectedSchemas } from 'hooks/useProtectedSchemas'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { Database, ExternalLinkIcon, Plus } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
@@ -108,6 +107,8 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isLoading = fetchingSchemaInfo || isSchemasLoading
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {}
|
||||
if (values.schema === 'custom' && !values.name) errors.name = 'Required field'
|
||||
@@ -170,7 +171,7 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
{fetchingSchemaInfo || isSchemasLoading ? (
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<div className="w-3/4">
|
||||
@@ -269,7 +270,7 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM
|
||||
<Button type="default" disabled={isEnabling} onClick={() => onCancel()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" disabled={isEnabling} loading={isEnabling}>
|
||||
<Button htmlType="submit" disabled={isEnabling || isLoading} loading={isEnabling}>
|
||||
Enable extension
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
|
||||
+6
-2
@@ -15,8 +15,10 @@ export const PreviousRestoreItem = ({ clone }: { clone: CloneStatus['clones'][nu
|
||||
</div>
|
||||
<div>
|
||||
<TimestampInfo
|
||||
className="font-mono text-xs text-foreground-lighter"
|
||||
className="text-left !text-sm font-mono tracking-tight text-foreground-lighter"
|
||||
displayAs="utc"
|
||||
utcTimestamp={clone.inserted_at ?? ''}
|
||||
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,8 +35,10 @@ export const PreviousRestoreItem = ({ clone }: { clone: CloneStatus['clones'][nu
|
||||
</div>
|
||||
<div>
|
||||
<TimestampInfo
|
||||
className="font-mono text-xs text-foreground-lighter"
|
||||
className="text-left !text-sm font-mono tracking-tight text-foreground-lighter"
|
||||
displayAs="utc"
|
||||
utcTimestamp={clone.inserted_at ?? ''}
|
||||
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end text-foreground-lighter group-hover:text-foreground">
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Check, Copy } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { useParams } from 'common/hooks'
|
||||
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
|
||||
import type { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query'
|
||||
import { copyToClipboard, TableCell, TableRow } from 'ui'
|
||||
import { cn, copyToClipboard, TableCell, TableRow } from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { createNavigationHandler } from 'lib/navigation'
|
||||
|
||||
@@ -30,7 +31,9 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
|
||||
? `https://${customDomainData.customDomain.hostname}/functions/v1/${item.slug}`
|
||||
: `${protocol}://${endpoint}/functions/v1/${item.slug}`
|
||||
|
||||
const handleNavigation = createNavigationHandler(`/project/${ref}/functions/${item.slug}`, router)
|
||||
const handleNavigation = IS_PLATFORM
|
||||
? createNavigationHandler(`/project/${ref}/functions/${item.slug}`, router)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -39,7 +42,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
|
||||
onAuxClick={handleNavigation}
|
||||
onKeyDown={handleNavigation}
|
||||
tabIndex={0}
|
||||
className="cursor-pointer inset-focus"
|
||||
className={cn({ 'cursor-pointer inset-focus': IS_PLATFORM })}
|
||||
>
|
||||
<TableCell>
|
||||
<p className="text-sm text-foreground whitespace-nowrap py-2">{item.name}</p>
|
||||
|
||||
@@ -189,7 +189,7 @@ export const FunctionsEmptyState = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const FunctionsEmptyStateLocal = () => {
|
||||
export const FunctionsInstructionsLocal = () => {
|
||||
const showStripeExample = useIsFeatureEnabled('edge_functions:show_stripe_example')
|
||||
const templates = useMemo(() => {
|
||||
if (showStripeExample) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Github } from 'lucide-react'
|
||||
import { Github, MoreVertical, Trash, Copy } from 'lucide-react'
|
||||
import InlineSVG from 'react-inlinesvg'
|
||||
import { useState } from 'react'
|
||||
|
||||
import CardButton from 'components/ui/CardButton'
|
||||
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
|
||||
import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal'
|
||||
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
|
||||
import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref'
|
||||
import { getComputeSize, OrgProject } from 'data/projects/org-projects-infinite-query'
|
||||
@@ -12,10 +14,21 @@ import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { inferProjectStatus } from './ProjectCard.utils'
|
||||
import { ProjectCardStatus } from './ProjectCardStatus'
|
||||
import type { Organization } from 'types'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
copyToClipboard,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Button,
|
||||
} from 'ui'
|
||||
|
||||
export interface ProjectCardProps {
|
||||
slug?: string
|
||||
project: OrgProject
|
||||
organization?: Organization
|
||||
rewriteHref?: string
|
||||
githubIntegration?: IntegrationProjectConnection
|
||||
vercelIntegration?: IntegrationProjectConnection
|
||||
@@ -25,12 +38,14 @@ export interface ProjectCardProps {
|
||||
export const ProjectCard = ({
|
||||
slug,
|
||||
project,
|
||||
organization,
|
||||
rewriteHref,
|
||||
githubIntegration,
|
||||
vercelIntegration,
|
||||
resourceWarnings,
|
||||
}: ProjectCardProps) => {
|
||||
const { name, ref: projectRef } = project
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
|
||||
const { infraAwsNimbusLabel } = useCustomContent(['infra:aws_nimbus_label'])
|
||||
const providerLabel =
|
||||
@@ -48,52 +63,105 @@ export const ProjectCard = ({
|
||||
const projectStatus = inferProjectStatus(project.status)
|
||||
|
||||
return (
|
||||
<li className="list-none h-min">
|
||||
<CardButton
|
||||
linkHref={rewriteHref ? rewriteHref : `/project/${projectRef}`}
|
||||
className="h-44 !px-0 group pt-5 pb-0"
|
||||
title={
|
||||
<div className="w-full flex flex-col gap-y-4 justify-between px-5">
|
||||
{/* Text */}
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<h5 className="text-sm flex-shrink truncate pr-5">{name}</h5>
|
||||
<p className="text-sm text-foreground-lighter">{desc}</p>
|
||||
</div>
|
||||
{/* Compute and integrations */}
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{project.status !== 'INACTIVE' && projectHomepageShowInstanceSize && (
|
||||
<ComputeBadgeWrapper
|
||||
slug={slug}
|
||||
projectRef={project.ref}
|
||||
cloudProvider={project.cloud_provider}
|
||||
computeSize={getComputeSize(project)}
|
||||
/>
|
||||
)}
|
||||
{isVercelIntegrated && (
|
||||
<div className="bg-surface-100 w-5 h-5 p-1 border border-strong rounded-md flex items-center justify-center text-black dark:text-white">
|
||||
<InlineSVG
|
||||
src={`${BASE_PATH}/img/icons/vercel-icon.svg`}
|
||||
title="Vercel Icon"
|
||||
className="w-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isGithubIntegrated && (
|
||||
<div className="bg-surface-100 flex items-center gap-x-0.5 h-5 pr-1 border border-strong rounded-md">
|
||||
<div className="w-5 h-5 p-1 flex items-center justify-center">
|
||||
<Github size={12} strokeWidth={1.5} />
|
||||
<>
|
||||
<li className="list-none h-min">
|
||||
<CardButton
|
||||
linkHref={rewriteHref ? rewriteHref : `/project/${projectRef}`}
|
||||
className="h-44 !px-0 group pt-5 pb-0 overflow-hidden relative"
|
||||
hideChevron
|
||||
title={
|
||||
<div className="w-full flex flex-col gap-y-4 justify-between px-5 z-10">
|
||||
<div className="flex flex-col gap-y-0.5 relative z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm flex-shrink truncate pr-5">{name}</h5>
|
||||
<div onClick={(e) => e.preventDefault()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MoreVertical size={14} />}
|
||||
className="w-6 h-6 px-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyToClipboard(projectRef)
|
||||
toast.success('Copied project ID to clipboard')
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
<span>Copy project ID</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsDeleteModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
<span>Delete project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-light truncate">{githubRepository}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-foreground-lighter">{desc}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1.5 relative z-10">
|
||||
<ProjectCardStatus
|
||||
projectStatus={projectStatus}
|
||||
resourceWarnings={resourceWarnings}
|
||||
renderMode="badge"
|
||||
/>
|
||||
{project.status !== 'INACTIVE' && projectHomepageShowInstanceSize && (
|
||||
<ComputeBadgeWrapper
|
||||
slug={slug}
|
||||
projectRef={project.ref}
|
||||
cloudProvider={project.cloud_provider}
|
||||
computeSize={getComputeSize(project)}
|
||||
/>
|
||||
)}
|
||||
{isVercelIntegrated && (
|
||||
<div className="bg-surface-100 w-5 h-5 p-1 border border-strong rounded-md flex items-center justify-center text-black dark:text-white">
|
||||
<InlineSVG
|
||||
src={`${BASE_PATH}/img/icons/vercel-icon.svg`}
|
||||
title="Vercel Icon"
|
||||
className="w-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isGithubIntegrated && (
|
||||
<div className="bg-surface-100 flex items-center gap-x-0.5 h-5 pr-1 border border-strong rounded-md">
|
||||
<div className="w-5 h-5 p-1 flex items-center justify-center">
|
||||
<Github size={12} strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-xs text-foreground-light truncate">{githubRepository}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<ProjectCardStatus projectStatus={projectStatus} resourceWarnings={resourceWarnings} />
|
||||
}
|
||||
containerElement={<ProjectIndexPageLink projectRef={projectRef} />}
|
||||
}
|
||||
footer={
|
||||
<ProjectCardStatus projectStatus={projectStatus} resourceWarnings={resourceWarnings} />
|
||||
}
|
||||
containerElement={<ProjectIndexPageLink projectRef={projectRef} />}
|
||||
/>
|
||||
</li>
|
||||
<DeleteProjectModal
|
||||
visible={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
project={project}
|
||||
organization={organization}
|
||||
/>
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -148,6 +148,7 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec
|
||||
<TableHead className={cn(noResults && 'text-foreground-muted')}>Compute</TableHead>
|
||||
<TableHead className={cn(noResults && 'text-foreground-muted')}>Region</TableHead>
|
||||
<TableHead className={cn(noResults && 'text-foreground-muted')}>Created</TableHead>
|
||||
<TableHead className={cn(noResults && 'text-foreground-muted')} />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Github } from 'lucide-react'
|
||||
import { Github, MoreVertical, Trash, Copy, Check } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import InlineSVG from 'react-inlinesvg'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
|
||||
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
|
||||
@@ -9,10 +10,21 @@ import type { ResourceWarning } from 'data/usage/resource-warnings-query'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { createNavigationHandler } from 'lib/navigation'
|
||||
import type { Organization } from 'types'
|
||||
import { TableCell, TableRow } from 'ui'
|
||||
import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenu,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Button,
|
||||
} from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { inferProjectStatus } from './ProjectCard.utils'
|
||||
import { ProjectCardStatus } from './ProjectCardStatus'
|
||||
import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal'
|
||||
import { toast } from 'sonner'
|
||||
import { copyToClipboard } from 'ui'
|
||||
|
||||
export interface ProjectTableRowProps {
|
||||
project: OrgProject
|
||||
@@ -34,93 +46,154 @@ export const ProjectTableRow = ({
|
||||
const router = useRouter()
|
||||
const { name, ref: projectRef } = project
|
||||
const projectStatus = inferProjectStatus(project.status)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const url = rewriteHref ?? `/project/${project.ref}`
|
||||
const isGithubIntegrated = githubIntegration !== undefined
|
||||
const isVercelIntegrated = vercelIntegration !== undefined
|
||||
const githubRepository = githubIntegration?.metadata.name ?? undefined
|
||||
|
||||
const infraInformation = project.databases.find((x) => x.identifier === project.ref)
|
||||
|
||||
const handleNavigation = createNavigationHandler(url, router)
|
||||
|
||||
const handleCopyProjectRef = (e: React.SyntheticEvent) => {
|
||||
e.stopPropagation()
|
||||
copyToClipboard(projectRef)
|
||||
setIsCopied(true)
|
||||
toast.success('Copied project ID to clipboard')
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-surface-200 inset-focus"
|
||||
onClick={handleNavigation}
|
||||
onAuxClick={handleNavigation}
|
||||
onKeyDown={handleNavigation}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{/* Text */}
|
||||
<div>
|
||||
<h5 className="text-sm">{name}</h5>
|
||||
<p className="text-sm text-foreground-lighter">ID: {projectRef}</p>
|
||||
</div>
|
||||
{/* Integrations */}
|
||||
{(isGithubIntegrated || isVercelIntegrated) && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{isVercelIntegrated && (
|
||||
<div className="bg-surface-100 w-5 h-5 p-1 border border-strong rounded-md flex items-center text-black dark:text-white">
|
||||
<InlineSVG
|
||||
src={`${BASE_PATH}/img/icons/vercel-icon.svg`}
|
||||
title="Vercel Icon"
|
||||
className="w-3"
|
||||
<>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-surface-200 inset-focus"
|
||||
onClick={handleNavigation}
|
||||
onAuxClick={handleNavigation}
|
||||
onKeyDown={handleNavigation}
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div>
|
||||
<h5 className="text-sm">{name}</h5>
|
||||
<button
|
||||
tabIndex={0}
|
||||
onClick={handleCopyProjectRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleCopyProjectRef(e)
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-x-1 cursor-pointer border border-transparent border-dashed rounded transition-colors hover:bg-surface-100 hover:border hover:border-strong group font-mono text-xs text-foreground-lighter hover:text-foreground-light px-1 -ml-1"
|
||||
>
|
||||
{projectRef}
|
||||
{isCopied ? (
|
||||
<Check size={12} strokeWidth={1.25} className="text-brand" />
|
||||
) : (
|
||||
<Copy
|
||||
size={12}
|
||||
strokeWidth={1.25}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isGithubIntegrated && (
|
||||
<div className="bg-surface-100 flex items-center gap-x-0.5 h-5 pr-1 border border-strong rounded-md">
|
||||
<div className="w-5 h-5 p-1 flex items-center">
|
||||
<Github size={12} strokeWidth={1.5} />
|
||||
</div>
|
||||
{githubRepository && (
|
||||
<p className="text-xs text-foreground-light truncate">{githubRepository}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ProjectCardStatus
|
||||
projectStatus={projectStatus}
|
||||
resourceWarnings={resourceWarnings}
|
||||
renderMode="badge"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="w-fit">
|
||||
{project.status !== 'INACTIVE' ? (
|
||||
<ComputeBadgeWrapper
|
||||
slug={organization?.slug}
|
||||
projectRef={project.ref}
|
||||
cloudProvider={project.cloud_provider}
|
||||
computeSize={getComputeSize(project)}
|
||||
{(isGithubIntegrated || isVercelIntegrated) && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{isVercelIntegrated && (
|
||||
<div className="bg-surface-100 w-5 h-5 p-1 border border-strong rounded-md flex items-center text-black dark:text-white">
|
||||
<InlineSVG
|
||||
src={`${BASE_PATH}/img/icons/vercel-icon.svg`}
|
||||
title="Vercel Icon"
|
||||
className="w-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isGithubIntegrated && (
|
||||
<div className="bg-surface-100 flex items-center gap-x-0.5 h-5 pr-1 border border-strong rounded-md">
|
||||
<div className="w-5 h-5 p-1 flex items-center">
|
||||
<Github size={12} strokeWidth={1.5} />
|
||||
</div>
|
||||
{githubRepository && (
|
||||
<p className="text-xs text-foreground-light truncate">{githubRepository}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ProjectCardStatus
|
||||
projectStatus={projectStatus}
|
||||
resourceWarnings={resourceWarnings}
|
||||
renderMode="badge"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="w-fit">
|
||||
{project.status !== 'INACTIVE' ? (
|
||||
<ComputeBadgeWrapper
|
||||
slug={organization?.slug}
|
||||
projectRef={project.ref}
|
||||
cloudProvider={project.cloud_provider}
|
||||
computeSize={getComputeSize(project)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-foreground-muted">–</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="lowercase text-sm text-foreground-light">
|
||||
{project.cloud_provider} | {project.region || 'N/A'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.inserted_at ? (
|
||||
<TimestampInfo
|
||||
className="text-sm text-foreground-light"
|
||||
utcTimestamp={project.inserted_at}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-foreground-muted">–</span>
|
||||
<span className="text-sm text-foreground-light">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="lowercase text-sm text-foreground-light">
|
||||
{project.cloud_provider} | {project.region || 'N/A'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.inserted_at ? (
|
||||
<TimestampInfo
|
||||
className="text-sm text-foreground-light"
|
||||
utcTimestamp={project.inserted_at}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-foreground-light">N/A</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<MoreVertical />}
|
||||
aria-label="More actions"
|
||||
className="w-7"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsDeleteModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
<span>Delete project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<DeleteProjectModal
|
||||
visible={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
project={project}
|
||||
organization={organization}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,3 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Auth, Database, Realtime, Storage } from 'icons'
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import BarChart from 'components/ui/Charts/BarChart'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
@@ -15,10 +7,17 @@ import {
|
||||
UsageApiCounts,
|
||||
useProjectLogStatsQuery,
|
||||
} from 'data/analytics/project-log-stats-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { Auth, Database, Realtime, Storage } from 'icons'
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import type { ChartIntervals } from 'types'
|
||||
import {
|
||||
Button,
|
||||
@@ -88,20 +87,20 @@ const ProjectUsage = () => {
|
||||
selectedInterval.startUnit as dayjs.ManipulateType
|
||||
)
|
||||
const endDateLocal = dayjs()
|
||||
const { data: charts } = useFillTimeseriesSorted(
|
||||
data?.result || [],
|
||||
'timestamp',
|
||||
[
|
||||
const { data: charts } = useFillTimeseriesSorted({
|
||||
data: data?.result ?? [],
|
||||
timestampKey: 'timestamp',
|
||||
valueKey: [
|
||||
'total_auth_requests',
|
||||
'total_rest_requests',
|
||||
'total_storage_requests',
|
||||
'total_realtime_requests',
|
||||
],
|
||||
0,
|
||||
startDateLocal.toISOString(),
|
||||
endDateLocal.toISOString(),
|
||||
5
|
||||
)
|
||||
defaultValue: 0,
|
||||
startDate: startDateLocal.toISOString(),
|
||||
endDate: endDateLocal.toISOString(),
|
||||
minPointsToFill: 5,
|
||||
})
|
||||
const datetimeFormat = selectedInterval.format || 'MMM D, ha'
|
||||
|
||||
const handleBarClick = (
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import { LogsTableName } from '../Settings/Logs/Logs.constants'
|
||||
import { genChartQuery } from '../Settings/Logs/Logs.utils'
|
||||
import {
|
||||
type RawChartData,
|
||||
calculateAggregatedMetrics,
|
||||
calculateDateRange,
|
||||
calculateHealthMetrics,
|
||||
@@ -81,11 +82,11 @@ const fetchServiceHealthMetrics = async (
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error || data?.error) {
|
||||
throw error || data?.error
|
||||
if (error ?? data?.error) {
|
||||
throw error ?? data?.error
|
||||
}
|
||||
|
||||
return (data?.result || []) as ChartQueryResult[]
|
||||
return (data?.result ?? []) as ChartQueryResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,18 +120,18 @@ const useServiceHealthQuery = ({
|
||||
const normalizedData = useTimeseriesUnixToIso(queryResult.data ?? [], 'timestamp')
|
||||
|
||||
// Fill gaps in timeseries
|
||||
const { data: filledData } = useFillTimeseriesSorted(
|
||||
normalizedData,
|
||||
'timestamp',
|
||||
'ok_count',
|
||||
0,
|
||||
const { data: filledData } = useFillTimeseriesSorted({
|
||||
data: normalizedData,
|
||||
timestampKey: 'timestamp',
|
||||
valueKey: 'ok_count',
|
||||
defaultValue: 0,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
endDate,
|
||||
})
|
||||
|
||||
// Transform to LogsBarChartDatum format
|
||||
const eventChartData: LogsBarChartDatum[] = useMemo(
|
||||
() => transformToBarChartData(filledData),
|
||||
() => transformToBarChartData(filledData as RawChartData[]),
|
||||
[filledData]
|
||||
)
|
||||
|
||||
|
||||
@@ -36,11 +36,12 @@ export const calculateDateRange = (
|
||||
return { startDate: start, endDate: end }
|
||||
}
|
||||
|
||||
type RawChartData = {
|
||||
export type RawChartData = {
|
||||
timestamp: string | number
|
||||
ok_count?: number | null
|
||||
warning_count?: number | null
|
||||
error_count?: number | null
|
||||
[key: string]: string | number | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,9 +50,9 @@ type RawChartData = {
|
||||
export const transformToBarChartData = (data: RawChartData[]): LogsBarChartDatum[] => {
|
||||
return data.map((row) => ({
|
||||
timestamp: typeof row.timestamp === 'string' ? row.timestamp : String(row.timestamp),
|
||||
ok_count: row.ok_count || 0,
|
||||
warning_count: row.warning_count || 0,
|
||||
error_count: row.error_count || 0,
|
||||
ok_count: row.ok_count ?? 0,
|
||||
warning_count: row.warning_count ?? 0,
|
||||
error_count: row.error_count ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Fragment, useRef, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { COUNTRY_LAT_LON } from 'components/interfaces/ProjectCreation/ProjectCreation.constants'
|
||||
import {
|
||||
MAP_CHART_THEME,
|
||||
buildCountsByIso2,
|
||||
computeMarkerRadius,
|
||||
extractIso2FromFeatureProps,
|
||||
getFillColor,
|
||||
getFillOpacity,
|
||||
isKnownCountryCode,
|
||||
isMicroCountry,
|
||||
iso2ToCountryName,
|
||||
} from 'components/interfaces/Reports/utils/geo'
|
||||
import {
|
||||
jsonSyntaxHighlight,
|
||||
TextFormatter,
|
||||
jsonSyntaxHighlight,
|
||||
} from 'components/interfaces/Settings/Logs/LogsFormatters'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import BarChart from 'components/ui/Charts/BarChart'
|
||||
import { geoCentroid } from 'd3-geo'
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Fragment, useRef, useState } from 'react'
|
||||
import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps'
|
||||
import type { ResponseError } from 'types'
|
||||
import {
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Collapsible,
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
Collapsible_Shadcn_,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { queryParamsToObject } from '../Reports.utils'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget'
|
||||
import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps'
|
||||
import { COUNTRY_LAT_LON } from 'components/interfaces/ProjectCreation/ProjectCreation.constants'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { geoCentroid } from 'd3-geo'
|
||||
import {
|
||||
buildCountsByIso2,
|
||||
getFillColor,
|
||||
getFillOpacity,
|
||||
isMicroCountry,
|
||||
isKnownCountryCode,
|
||||
computeMarkerRadius,
|
||||
MAP_CHART_THEME,
|
||||
extractIso2FromFeatureProps,
|
||||
iso2ToCountryName,
|
||||
} from 'components/interfaces/Reports/utils/geo'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { queryParamsToObject } from '../Reports.utils'
|
||||
|
||||
export const NetworkTrafficRenderer = (
|
||||
props: ReportWidgetProps<{
|
||||
@@ -50,14 +50,14 @@ export const NetworkTrafficRenderer = (
|
||||
egress: number
|
||||
}>
|
||||
) => {
|
||||
const { data, error, isError } = useFillTimeseriesSorted(
|
||||
props.data,
|
||||
'timestamp',
|
||||
['ingress_mb', 'egress_mb'],
|
||||
0,
|
||||
props.params?.iso_timestamp_start,
|
||||
props.params?.iso_timestamp_end
|
||||
)
|
||||
const { data, error, isError } = useFillTimeseriesSorted({
|
||||
data: props.data,
|
||||
timestampKey: 'timestamp',
|
||||
valueKey: ['ingress_mb', 'egress_mb'],
|
||||
defaultValue: 0,
|
||||
startDate: props.params?.iso_timestamp_start,
|
||||
endDate: props.params?.iso_timestamp_end,
|
||||
})
|
||||
|
||||
const totalIngress = sumBy(props.data, 'ingress_mb')
|
||||
const totalEgress = sumBy(props.data, 'egress_mb')
|
||||
@@ -76,7 +76,7 @@ export const NetworkTrafficRenderer = (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve network traffic</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{error.message}</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{error?.message ?? 'Unknown error'}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -121,14 +121,14 @@ export const TotalRequestsChartRenderer = (
|
||||
const total = props.data.reduce((acc, datum) => {
|
||||
return acc + datum.count
|
||||
}, 0)
|
||||
const { data, error, isError } = useFillTimeseriesSorted(
|
||||
props.data,
|
||||
'timestamp',
|
||||
'count',
|
||||
0,
|
||||
props.params?.iso_timestamp_start,
|
||||
props.params?.iso_timestamp_end
|
||||
)
|
||||
const { data, error, isError } = useFillTimeseriesSorted({
|
||||
data: props.data,
|
||||
timestampKey: 'timestamp',
|
||||
valueKey: 'count',
|
||||
defaultValue: 0,
|
||||
startDate: props.params?.iso_timestamp_start,
|
||||
endDate: props.params?.iso_timestamp_end,
|
||||
})
|
||||
|
||||
if (!!props.error) {
|
||||
const error = (
|
||||
@@ -140,7 +140,7 @@ export const TotalRequestsChartRenderer = (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve total requests</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{error.message}</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{error?.message ?? 'Unknown error'}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -253,14 +253,14 @@ export const ErrorCountsChartRenderer = (
|
||||
return acc + datum.count
|
||||
}, 0)
|
||||
|
||||
const { data, error, isError } = useFillTimeseriesSorted(
|
||||
props.data,
|
||||
'timestamp',
|
||||
'count',
|
||||
0,
|
||||
props.params?.iso_timestamp_start,
|
||||
props.params?.iso_timestamp_end
|
||||
)
|
||||
const { data, error, isError } = useFillTimeseriesSorted({
|
||||
data: props.data,
|
||||
timestampKey: 'timestamp',
|
||||
valueKey: 'count',
|
||||
defaultValue: 0,
|
||||
startDate: props.params?.iso_timestamp_start,
|
||||
endDate: props.params?.iso_timestamp_end,
|
||||
})
|
||||
|
||||
if (!!props.error) {
|
||||
const error = (
|
||||
@@ -272,7 +272,7 @@ export const ErrorCountsChartRenderer = (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve request errors</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{error.message}</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{error?.message ?? 'Unknown error'}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -302,14 +302,14 @@ export const ResponseSpeedChartRenderer = (
|
||||
avg: datum.avg,
|
||||
}))
|
||||
|
||||
const { data, error, isError } = useFillTimeseriesSorted(
|
||||
transformedData,
|
||||
'timestamp',
|
||||
'avg',
|
||||
0,
|
||||
props.params?.iso_timestamp_start,
|
||||
props.params?.iso_timestamp_end
|
||||
)
|
||||
const { data, error, isError } = useFillTimeseriesSorted({
|
||||
data: transformedData,
|
||||
timestampKey: 'timestamp',
|
||||
valueKey: 'avg',
|
||||
defaultValue: 0,
|
||||
startDate: props.params?.iso_timestamp_start,
|
||||
endDate: props.params?.iso_timestamp_end,
|
||||
})
|
||||
|
||||
const lastAvg = props.data[props.data.length - 1]?.avg
|
||||
|
||||
@@ -323,7 +323,7 @@ export const ResponseSpeedChartRenderer = (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve response speeds</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{error.message}</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{error?.message ?? 'Unknown error'}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { ChartHighlightAction } from 'components/ui/Charts/ChartHighlightActions'
|
||||
import { ComposedChart } from 'components/ui/Charts/ComposedChart'
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
import { useChartHighlight } from 'components/ui/Charts/useChartHighlight'
|
||||
import type { AnalyticsInterval } from 'data/analytics/constants'
|
||||
import type { ReportConfig } from 'data/reports/v2/reports.types'
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, cn } from 'ui'
|
||||
|
||||
import { ReportChartUpsell } from './ReportChartUpsell'
|
||||
|
||||
export interface ReportChartV2Props {
|
||||
@@ -32,10 +33,13 @@ export interface ReportChartV2Props {
|
||||
|
||||
// Compute total across entire period over unique attribute keys.
|
||||
// Excludes attributes that are disabled, reference lines, max values, or marked omitFromTotal.
|
||||
export function computePeriodTotal(chartData: any[], dynamicAttributes: any[]): number {
|
||||
export function computePeriodTotal(
|
||||
chartData: Record<string, unknown>[],
|
||||
dynamicAttributes: MultiAttribute[]
|
||||
): number {
|
||||
const attributeKeys = Array.from(
|
||||
new Set(
|
||||
(dynamicAttributes as any[])
|
||||
dynamicAttributes
|
||||
.filter(
|
||||
(a) =>
|
||||
a?.enabled !== false &&
|
||||
@@ -43,11 +47,11 @@ export function computePeriodTotal(chartData: any[], dynamicAttributes: any[]):
|
||||
!a?.isMaxValue &&
|
||||
!a?.omitFromTotal
|
||||
)
|
||||
.map((a: any) => a.attribute)
|
||||
.map((a) => a.attribute)
|
||||
)
|
||||
)
|
||||
|
||||
return chartData.reduce((sum: number, row: any) => {
|
||||
return chartData.reduce((sum: number, row: Record<string, unknown>) => {
|
||||
const rowTotal = attributeKeys.reduce((acc: number, key: string) => {
|
||||
const value = row?.[key]
|
||||
return acc + (typeof value === 'number' ? value : 0)
|
||||
@@ -111,16 +115,16 @@ export const ReportChartV2 = ({
|
||||
const firstItem = chartData[0]
|
||||
const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start'
|
||||
|
||||
const { data: filledChartData, isError: isFillError } = useFillTimeseriesSorted(
|
||||
chartData,
|
||||
const { data: filledChartData, isError: isFillError } = useFillTimeseriesSorted({
|
||||
data: chartData,
|
||||
timestampKey,
|
||||
(dynamicAttributes as any[]).map((attr: any) => attr.attribute),
|
||||
0,
|
||||
valueKey: dynamicAttributes.map((attr) => attr.attribute),
|
||||
defaultValue: 0,
|
||||
startDate,
|
||||
endDate,
|
||||
undefined,
|
||||
interval
|
||||
)
|
||||
minPointsToFill: undefined,
|
||||
interval,
|
||||
})
|
||||
|
||||
const [chartStyle, setChartStyle] = useState<string>(report.defaultChartStyle)
|
||||
const chartHighlight = useChartHighlight()
|
||||
|
||||
+12
-2
@@ -7,22 +7,32 @@ import { CANCELLATION_REASONS } from 'components/interfaces/Billing/Billing.cons
|
||||
import { TextConfirmModal } from 'components/ui/TextConfirmModalWrapper'
|
||||
import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send'
|
||||
import { useProjectDeleteMutation } from 'data/projects/project-delete-mutation'
|
||||
import type { OrgProject } from 'data/projects/org-projects-infinite-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import type { Organization } from 'types'
|
||||
import { Input } from 'ui'
|
||||
|
||||
export const DeleteProjectModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
project: projectProp,
|
||||
organization: organizationProp,
|
||||
}: {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
project?: OrgProject
|
||||
organization?: Organization
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
const { data: projectFromQuery } = useSelectedProjectQuery()
|
||||
const { data: organizationFromQuery } = useSelectedOrganizationQuery()
|
||||
|
||||
// Use props if provided, otherwise fall back to hooks
|
||||
const project = projectProp || projectFromQuery
|
||||
const organization = organizationProp || organizationFromQuery
|
||||
|
||||
const [lastVisitedOrganization] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
|
||||
|
||||
+15
-9
@@ -1,10 +1,4 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import z from 'zod'
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { formatWrapperTables } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
|
||||
@@ -16,6 +10,10 @@ import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
|
||||
import { fdwKeys } from 'data/fdw/keys'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -26,13 +24,17 @@ import {
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import z from 'zod'
|
||||
|
||||
import { getDecryptedParameters } from '../../Storage.utils'
|
||||
import { useS3VectorsWrapperExtension } from '../useS3VectorsWrapper'
|
||||
import { useS3VectorsWrapperInstance } from '../useS3VectorsWrapperInstance'
|
||||
import { isGreaterThanOrEqual } from '@/lib/semver'
|
||||
|
||||
// Create foreign tables for vector bucket
|
||||
export const InitializeForeignSchemaDialog = () => {
|
||||
@@ -45,6 +47,10 @@ export const InitializeForeignSchemaDialog = () => {
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const { data: wrapperInstance, meta: wrapperMeta } = useS3VectorsWrapperInstance({ bucketId })
|
||||
const { extension: wrappersExtension } = useS3VectorsWrapperExtension()
|
||||
const updatedImportForeignSchemaSyntax = !!wrappersExtension?.installed_version
|
||||
? isGreaterThanOrEqual(wrappersExtension.installed_version, '0.5.7')
|
||||
: false
|
||||
|
||||
const FormSchema = z.object({
|
||||
schema: z
|
||||
@@ -107,9 +113,9 @@ export const InitializeForeignSchemaDialog = () => {
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
serverName: wrapperInstance.server_name,
|
||||
sourceSchema: values.schema,
|
||||
sourceSchema: updatedImportForeignSchemaSyntax ? bucketId : values.schema,
|
||||
targetSchema: values.schema,
|
||||
schemaOptions: [`bucket_name '${bucketId}'`],
|
||||
schemaOptions: updatedImportForeignSchemaSyntax ? undefined : [`bucket_name '${bucketId}'`],
|
||||
})
|
||||
|
||||
toast.success(
|
||||
|
||||
+17
-10
@@ -1,8 +1,3 @@
|
||||
import { ChevronDown, ListPlus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
@@ -11,26 +6,33 @@ import { VectorBucketIndex } from 'data/storage/vector-buckets-indexes-query'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { SqlEditor } from 'icons'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { ChevronDown, ListPlus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
CodeBlock,
|
||||
Command_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
Command_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
import { useS3VectorsWrapperExtension } from '../useS3VectorsWrapper'
|
||||
import { useS3VectorsWrapperInstance } from '../useS3VectorsWrapperInstance'
|
||||
import { isGreaterThanOrEqual } from '@/lib/semver'
|
||||
|
||||
interface VectorBucketTableExamplesSheetProps {
|
||||
index: VectorBucketIndex
|
||||
@@ -147,6 +149,11 @@ function VectorBucketIndexExamples({
|
||||
const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId })
|
||||
const foreignTable = wrapperInstance?.tables?.find((x) => x.name === indexName)
|
||||
|
||||
const { extension: wrappersExtension } = useS3VectorsWrapperExtension()
|
||||
const updatedImportForeignSchemaSyntax = !!wrappersExtension?.installed_version
|
||||
? isGreaterThanOrEqual(wrappersExtension.installed_version, '0.5.7')
|
||||
: false
|
||||
|
||||
const dimensionLabel = `Data should match ${dimension} dimension${dimension > 1 ? 's' : ''}`
|
||||
const startValue = 0.1
|
||||
const dimensionComment = generateDimensionComment(dimension)
|
||||
@@ -159,12 +166,12 @@ insert into
|
||||
values
|
||||
(
|
||||
'doc-1',
|
||||
'[${dimensionExample}]'::embd${sqlComment},
|
||||
'[${dimensionExample}]'${updatedImportForeignSchemaSyntax ? '::s3vec' : '::embd'}${sqlComment},
|
||||
'{${metadataKeys.map((key) => `"${key}": "${key} value"`).join(', ')}}'::jsonb
|
||||
),
|
||||
(
|
||||
'doc-2',
|
||||
'[${dimensionExample}]'::embd${sqlComment},
|
||||
'[${dimensionExample}]'${updatedImportForeignSchemaSyntax ? '::s3vec' : '::embd'}${sqlComment},
|
||||
'{${metadataKeys.map((key) => `"${key}": "${key} value"`).join(', ')}}'::jsonb
|
||||
);`
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
// End of third-party imports
|
||||
|
||||
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import {
|
||||
cn,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectGroup_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
type ExtendedSupportCategories,
|
||||
@@ -91,7 +91,7 @@ function CategorySelector({ form }: CategorySelectorProps) {
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
{CATEGORY_OPTIONS.filter((option) => !option.hidden).map((option) => (
|
||||
{CATEGORY_OPTIONS.map((option) => (
|
||||
<SelectItem_Shadcn_ key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
<span className="block text-xs text-foreground-lighter">
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useLinkSupportTicketMutation } from 'data/feedback/link-support-ticket-mutation'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { Link2 } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import type { SubmitHandler } from 'react-hook-form'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useLinkSupportTicketMutation } from 'data/feedback/link-support-ticket-mutation'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import {
|
||||
Button,
|
||||
DialogSectionSeparator,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo'
|
||||
import {
|
||||
LinkSupportTicketFormSchema,
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
} from './LinkSupportTicketForm.schema'
|
||||
import { OrganizationSelector } from './OrganizationSelector'
|
||||
import { ProjectAndPlanInfo } from './ProjectAndPlanInfo'
|
||||
import { SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle'
|
||||
import { getOrgSubscriptionPlan, NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils'
|
||||
import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle'
|
||||
import { NO_ORG_MARKER, NO_PROJECT_MARKER, getOrgSubscriptionPlan } from './SupportForm.utils'
|
||||
|
||||
interface LinkSupportTicketFormProps {
|
||||
conversationId: string
|
||||
@@ -88,9 +88,10 @@ export const LinkSupportTicketForm = ({
|
||||
? values.projectRef
|
||||
: undefined,
|
||||
category: values.category,
|
||||
allow_support_access: SUPPORT_ACCESS_CATEGORIES.includes(values.category)
|
||||
? values.allowSupportAccess
|
||||
: false,
|
||||
allow_support_access:
|
||||
values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category)
|
||||
? values.allowSupportAccess
|
||||
: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -153,7 +154,7 @@ export const LinkSupportTicketForm = ({
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
{SUPPORT_ACCESS_CATEGORIES.includes(category) && (
|
||||
{!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && (
|
||||
<>
|
||||
<div className="py-4">
|
||||
<SupportAccessToggle form={form as any} />
|
||||
|
||||
@@ -10,7 +10,6 @@ export const CATEGORY_OPTIONS: {
|
||||
label: string
|
||||
description: string
|
||||
query?: string
|
||||
hidden?: boolean
|
||||
}[] = [
|
||||
{
|
||||
value: SupportCategories.PROBLEM,
|
||||
@@ -78,13 +77,6 @@ export const CATEGORY_OPTIONS: {
|
||||
query: undefined,
|
||||
},
|
||||
]),
|
||||
{
|
||||
value: 'Others' as const,
|
||||
label: 'Others',
|
||||
description: 'Issues that are not related to any of the other categories',
|
||||
query: undefined,
|
||||
hidden: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const SEVERITY_OPTIONS = [
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
// End of third-party imports
|
||||
|
||||
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import {
|
||||
Badge,
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
Collapsible_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import type { ExtendedSupportCategories } from './Support.constants'
|
||||
import type { SupportFormValues } from './SupportForm.schema'
|
||||
|
||||
export const SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [
|
||||
SupportCategories.DATABASE_UNRESPONSIVE,
|
||||
SupportCategories.PERFORMANCE_ISSUES,
|
||||
SupportCategories.PROBLEM,
|
||||
SupportCategories.DASHBOARD_BUG,
|
||||
export const DISABLE_SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [
|
||||
SupportCategories.ACCOUNT_DELETION,
|
||||
SupportCategories.SALES_ENQUIRY,
|
||||
SupportCategories.REFUND,
|
||||
]
|
||||
|
||||
interface SupportAccessToggleProps {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useEffect, type Dispatch, type MouseEventHandler } from 'react'
|
||||
import type { SubmitHandler, UseFormReturn } from 'react-hook-form'
|
||||
// End of third-party imports
|
||||
|
||||
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
||||
import { useConstant, useFlag } from 'common'
|
||||
import { CLIENT_LIBRARIES } from 'common/constants'
|
||||
@@ -13,7 +10,10 @@ import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachm
|
||||
import { useDeploymentCommitQuery } from 'data/utils/deployment-commit-query'
|
||||
import { detectBrowser } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { type Dispatch, type MouseEventHandler } from 'react'
|
||||
import type { SubmitHandler, UseFormReturn } from 'react-hook-form'
|
||||
import { DialogSectionSeparator, Form_Shadcn_ } from 'ui'
|
||||
|
||||
import {
|
||||
AffectedServicesSelector,
|
||||
CATEGORIES_WITHOUT_AFFECTED_SERVICES,
|
||||
@@ -27,15 +27,15 @@ import { OrganizationSelector } from './OrganizationSelector'
|
||||
import { ProjectAndPlanInfo } from './ProjectAndPlanInfo'
|
||||
import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo'
|
||||
import { SubmitButton } from './SubmitButton'
|
||||
import { SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle'
|
||||
import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle'
|
||||
import type { SupportFormValues } from './SupportForm.schema'
|
||||
import type { SupportFormActions, SupportFormState } from './SupportForm.state'
|
||||
import {
|
||||
NO_ORG_MARKER,
|
||||
NO_PROJECT_MARKER,
|
||||
formatMessage,
|
||||
formatStudioVersion,
|
||||
getOrgSubscriptionPlan,
|
||||
NO_ORG_MARKER,
|
||||
NO_PROJECT_MARKER,
|
||||
} from './SupportForm.utils'
|
||||
import {
|
||||
DASHBOARD_LOG_CATEGORIES,
|
||||
@@ -128,12 +128,12 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
category,
|
||||
organizationSlug: values.organizationSlug ?? NO_ORG_MARKER,
|
||||
projectRef: values.projectRef ?? NO_PROJECT_MARKER,
|
||||
allowSupportAccess: SUPPORT_ACCESS_CATEGORIES.includes(values.category)
|
||||
? values.allowSupportAccess
|
||||
: false,
|
||||
allowSupportAccess:
|
||||
values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category)
|
||||
? values.allowSupportAccess
|
||||
: false,
|
||||
library:
|
||||
values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined
|
||||
? selectedLibrary.key
|
||||
@@ -179,15 +179,6 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo
|
||||
handleFormSubmit(event)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (simplifiedSupportForm) {
|
||||
form.setValue('category', 'Others')
|
||||
} else {
|
||||
form.setValue('category', '' as any)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [simplifiedSupportForm])
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id="support-form" className="flex flex-col gap-y-6">
|
||||
@@ -202,14 +193,12 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo
|
||||
subscriptionPlanId={subscriptionPlanId}
|
||||
category={category}
|
||||
/>
|
||||
{!simplifiedSupportForm && (
|
||||
<CategoryAndSeverityInfo
|
||||
form={form}
|
||||
category={category}
|
||||
severity={severity}
|
||||
projectRef={projectRef}
|
||||
/>
|
||||
)}
|
||||
<CategoryAndSeverityInfo
|
||||
form={form}
|
||||
category={category}
|
||||
severity={severity}
|
||||
projectRef={projectRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
@@ -235,7 +224,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo
|
||||
</>
|
||||
)}
|
||||
|
||||
{SUPPORT_ACCESS_CATEGORIES.includes(category) && (
|
||||
{!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && (
|
||||
<>
|
||||
<SupportAccessToggle form={form} />
|
||||
<DialogSectionSeparator />
|
||||
|
||||
@@ -891,10 +891,6 @@ describe('SupportFormPage', () => {
|
||||
await userEvent.clear(messageField)
|
||||
await userEvent.type(messageField, 'MFA challenge fails with an unknown error code')
|
||||
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: /allow support access to your project/i })
|
||||
).toBeNull()
|
||||
|
||||
await userEvent.click(getSubmitButton(screen))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -910,7 +906,7 @@ describe('SupportFormPage', () => {
|
||||
organizationSlug: 'org-2',
|
||||
library: '',
|
||||
affectedServices: '',
|
||||
allowSupportAccess: false,
|
||||
allowSupportAccess: true,
|
||||
verified: true,
|
||||
tags: ['dashboard-support-form'],
|
||||
siteUrl: 'https://project-2.supabase.dev',
|
||||
|
||||
+21
-19
@@ -1,3 +1,4 @@
|
||||
import type { EnumeratedType } from 'data/enumerated-types/enumerated-types-query'
|
||||
import { noop } from 'lodash'
|
||||
import {
|
||||
Calendar,
|
||||
@@ -11,8 +12,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import type { EnumeratedType } from 'data/enumerated-types/enumerated-types-query'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
CommandSeparator_Shadcn_,
|
||||
Command_Shadcn_,
|
||||
CriticalIcon,
|
||||
Input,
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
TooltipTrigger,
|
||||
cn,
|
||||
} from 'ui'
|
||||
|
||||
import {
|
||||
POSTGRES_DATA_TYPES,
|
||||
POSTGRES_DATA_TYPE_OPTIONS,
|
||||
@@ -165,7 +166,7 @@ const ColumnType = ({
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)}>
|
||||
{showLabel && <Label_Shadcn_ className="text-foreground-light">Type</Label_Shadcn_>}
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
|
||||
<Popover_Shadcn_ modal open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type={error ? 'danger' : 'default'}
|
||||
@@ -186,18 +187,18 @@ const ColumnType = ({
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="w-[460px] p-0" side="bottom" align="center">
|
||||
<ScrollArea className="h-[335px]">
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_
|
||||
placeholder="Search types..."
|
||||
// [Joshen] Addresses style issues when this component is being used in the old Form component
|
||||
// Specifically in WrapperDynamicColumns - can be cleaned up once we're no longer using that
|
||||
className="!bg-transparent focus:!shadow-none focus:!ring-0"
|
||||
/>
|
||||
<CommandEmpty_Shadcn_>Type not found.</CommandEmpty_Shadcn_>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_
|
||||
placeholder="Search types..."
|
||||
// [Joshen] Addresses style issues when this component is being used in the old Form component
|
||||
// Specifically in WrapperDynamicColumns - can be cleaned up once we're no longer using that
|
||||
className="!bg-transparent focus:!shadow-none focus:!ring-0 text-xs"
|
||||
/>
|
||||
<CommandEmpty_Shadcn_>Type not found.</CommandEmpty_Shadcn_>
|
||||
|
||||
<CommandList_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<CommandList_Shadcn_>
|
||||
<ScrollArea className="h-[240px]">
|
||||
<CommandGroup_Shadcn_ heading="Postgres data types">
|
||||
{POSTGRES_DATA_TYPE_OPTIONS.map((option: PostgresDataTypeOption) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={option.name}
|
||||
@@ -219,10 +220,11 @@ const ColumnType = ({
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
</CommandGroup_Shadcn_>
|
||||
|
||||
{enumTypes.length > 0 && (
|
||||
<>
|
||||
<CommandItem_Shadcn_>Other types</CommandItem_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<CommandSeparator_Shadcn_ />
|
||||
<CommandGroup_Shadcn_ heading="Other types">
|
||||
{enumTypes.map((option) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={option.id}
|
||||
@@ -266,9 +268,9 @@ const ColumnType = ({
|
||||
</CommandGroup_Shadcn_>
|
||||
</>
|
||||
)}
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
|
||||
|
||||
+8
-7
@@ -1,12 +1,12 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { EditCellContentPayload } from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
import { formatOperationItemValue } from './OperationQueueSidePanel.utils'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { EditCellContentPayload } from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
interface OperationItemProps {
|
||||
operationId: string
|
||||
@@ -50,17 +50,18 @@ export const OperationItem = ({ operationId, tableId, content }: OperationItemPr
|
||||
<div className="text-xs text-foreground font-mono">{fullTableName}</div>
|
||||
<div className="text-sm text-foreground-muted mt-0.5">
|
||||
<span className="font-medium text-foreground">{columnName}</span>
|
||||
<span className="text-foreground-muted mx-2">·</span>
|
||||
<span className="text-foreground-muted mx-2">•</span>
|
||||
<span className="text-foreground text-xs">where {whereClause}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<X size={14} />}
|
||||
onClick={handleDelete}
|
||||
className="shrink-0"
|
||||
className="shrink-0 w-7"
|
||||
aria-label="Remove operation"
|
||||
tooltip={{ content: { side: 'bottom', text: 'Remove operation' } }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+3
-10
@@ -1,9 +1,9 @@
|
||||
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'
|
||||
import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { Button, SidePanel } from 'ui'
|
||||
|
||||
import { OperationList } from './OperationList'
|
||||
import { getModKeyLabel } from '@/lib/helpers'
|
||||
import { QueuedOperation } from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
interface OperationQueueSidePanelProps {
|
||||
@@ -12,6 +12,7 @@ interface OperationQueueSidePanelProps {
|
||||
}
|
||||
|
||||
export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueSidePanelProps) => {
|
||||
const modKey = getModKeyLabel()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
|
||||
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
|
||||
@@ -21,14 +22,6 @@ export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueS
|
||||
onCancelSuccess: closePanel,
|
||||
})
|
||||
|
||||
const { modKey } = useOperationQueueShortcuts({
|
||||
enabled: visible,
|
||||
onSave: handleSave,
|
||||
onTogglePanel: closePanel,
|
||||
isSaving,
|
||||
hasOperations: operations.length > 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
size="large"
|
||||
@@ -37,7 +30,7 @@ export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueS
|
||||
header={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>Pending Changes</span>
|
||||
<span>Pending changes</span>
|
||||
<span className="text-xs text-foreground-light">
|
||||
{operations.length} operation{operations.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { Book, Maximize2, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { isExplainQuery } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.utils'
|
||||
import { generateSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.constants'
|
||||
@@ -18,29 +14,33 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { Book, Maximize2, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { useEditorPanelStateSnapshot } from 'state/editor-panel-state'
|
||||
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
|
||||
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
CodeBlock,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
HoverCard_Shadcn_,
|
||||
Command_Shadcn_,
|
||||
HoverCardContent_Shadcn_,
|
||||
HoverCardTrigger_Shadcn_,
|
||||
HoverCard_Shadcn_,
|
||||
KeyboardShortcut,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
SQL_ICON,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils'
|
||||
import { AIEditor } from '../AIEditor'
|
||||
import { ButtonTooltip } from '../ButtonTooltip'
|
||||
@@ -124,6 +124,7 @@ export const EditorPanel = () => {
|
||||
sql: suffixWithLimit(currentValue, 100),
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
isStatementTimeoutDisabled: true,
|
||||
handleError: (executeError) => {
|
||||
throw executeError
|
||||
},
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { QueryClient, useQuery } from '@tanstack/react-query'
|
||||
import { operations } from 'api-types'
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { analyticsKeys } from './keys'
|
||||
import { UseCustomQueryOptions } from 'types'
|
||||
|
||||
import { analyticsKeys } from './keys'
|
||||
|
||||
export type ProjectLogStatsVariables = {
|
||||
projectRef?: string
|
||||
interval?: NonNullable<
|
||||
@@ -20,6 +21,7 @@ export interface UsageApiCounts {
|
||||
total_rest_requests: number
|
||||
total_realtime_requests: number
|
||||
timestamp: string
|
||||
[key: string]: string | number
|
||||
}
|
||||
|
||||
export async function getProjectLogStats(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { components } from 'data/api'
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { useIsOrioleDbInAws } from 'hooks/misc/useSelectedProject'
|
||||
import type { ResponseError, UseCustomQueryOptions } from 'types'
|
||||
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
export type BackupsVariables = {
|
||||
|
||||
@@ -37,6 +37,6 @@ export const useEdgeFunctionsQuery = <TData = EdgeFunctionsData>(
|
||||
useQuery<EdgeFunctionsData, EdgeFunctionsError, TData>({
|
||||
queryKey: edgeFunctionsKeys.list(projectRef),
|
||||
queryFn: ({ signal }) => getEdgeFunctions({ projectRef }, signal),
|
||||
enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined',
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { entityTypeKeys } from 'data/entity-types/keys'
|
||||
import { foreignTableKeys } from 'data/foreign-tables/keys'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { wrapWithTransaction } from 'data/sql/utils/transaction'
|
||||
import { vaultSecretsKeys } from 'data/vault/keys'
|
||||
import { toast } from 'sonner'
|
||||
import type { ResponseError, UseCustomMutationOptions } from 'types'
|
||||
|
||||
import { fdwKeys } from './keys'
|
||||
|
||||
export type FDWImportForeignSchemaVariables = {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasValidTimestamp, sortByTimestamp } from './useFillTimeseriesSorted'
|
||||
|
||||
describe('hasValidTimestamp', () => {
|
||||
it('should return true when data has valid timestamp key', () => {
|
||||
const data = [{ timestamp: '2024-01-01T00:00:00Z', value: 1 }]
|
||||
expect(hasValidTimestamp(data, 'timestamp')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when data is empty', () => {
|
||||
expect(hasValidTimestamp([], 'timestamp')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when timestamp key does not exist', () => {
|
||||
const data = [{ value: 1 }]
|
||||
expect(hasValidTimestamp(data, 'timestamp')).toBe(false)
|
||||
})
|
||||
|
||||
it('should work with custom timestamp key', () => {
|
||||
const data = [{ period_start: '2024-01-01T00:00:00Z', value: 1 }]
|
||||
expect(hasValidTimestamp(data, 'period_start')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortByTimestamp', () => {
|
||||
it('should sort data in ascending order by timestamp', () => {
|
||||
const data = [
|
||||
{ timestamp: '2024-01-03T00:00:00Z', value: 3 },
|
||||
{ timestamp: '2024-01-01T00:00:00Z', value: 1 },
|
||||
{ timestamp: '2024-01-02T00:00:00Z', value: 2 },
|
||||
]
|
||||
|
||||
const sorted = sortByTimestamp(data, 'timestamp')
|
||||
|
||||
expect(sorted[0].value).toBe(1)
|
||||
expect(sorted[1].value).toBe(2)
|
||||
expect(sorted[2].value).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle already sorted data', () => {
|
||||
const data = [
|
||||
{ timestamp: '2024-01-01T00:00:00Z', value: 1 },
|
||||
{ timestamp: '2024-01-02T00:00:00Z', value: 2 },
|
||||
]
|
||||
|
||||
const sorted = sortByTimestamp(data, 'timestamp')
|
||||
|
||||
expect(sorted[0].value).toBe(1)
|
||||
expect(sorted[1].value).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const sorted = sortByTimestamp([], 'timestamp')
|
||||
expect(sorted).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle single item', () => {
|
||||
const data = [{ timestamp: '2024-01-01T00:00:00Z', value: 1 }]
|
||||
const sorted = sortByTimestamp(data, 'timestamp')
|
||||
expect(sorted).toEqual(data)
|
||||
})
|
||||
|
||||
it('should work with custom timestamp key', () => {
|
||||
const data = [
|
||||
{ period_start: '2024-01-03T00:00:00Z', value: 3 },
|
||||
{ period_start: '2024-01-01T00:00:00Z', value: 1 },
|
||||
{ period_start: '2024-01-02T00:00:00Z', value: 2 },
|
||||
]
|
||||
|
||||
const sorted = sortByTimestamp(data, 'period_start')
|
||||
|
||||
expect(sorted[0].value).toBe(1)
|
||||
expect(sorted[1].value).toBe(2)
|
||||
expect(sorted[2].value).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle timestamps with different time zones', () => {
|
||||
const data = [
|
||||
{ timestamp: '2024-01-01T12:00:00Z', value: 2 },
|
||||
{ timestamp: '2024-01-01T00:00:00Z', value: 1 },
|
||||
{ timestamp: '2024-01-01T18:00:00Z', value: 3 },
|
||||
]
|
||||
|
||||
const sorted = sortByTimestamp(data, 'timestamp')
|
||||
|
||||
expect(sorted[0].value).toBe(1)
|
||||
expect(sorted[1].value).toBe(2)
|
||||
expect(sorted[2].value).toBe(3)
|
||||
})
|
||||
|
||||
it('should maintain stable sort for equal timestamps', () => {
|
||||
const data = [
|
||||
{ timestamp: '2024-01-01T00:00:00Z', value: 1, id: 'a' },
|
||||
{ timestamp: '2024-01-01T00:00:00Z', value: 2, id: 'b' },
|
||||
{ timestamp: '2024-01-01T00:00:00Z', value: 3, id: 'c' },
|
||||
]
|
||||
|
||||
const sorted = sortByTimestamp(data, 'timestamp')
|
||||
|
||||
// Should maintain original order for equal timestamps
|
||||
expect(sorted[0].id).toBe('a')
|
||||
expect(sorted[1].id).toBe('b')
|
||||
expect(sorted[2].id).toBe('c')
|
||||
})
|
||||
})
|
||||
@@ -1,35 +1,126 @@
|
||||
import { fillTimeseries } from 'components/interfaces/Settings/Logs/Logs.utils'
|
||||
import type { Datum } from 'components/ui/Charts/Charts.types'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export type FillTimeseriesOptions<T extends Datum = Datum> = {
|
||||
/** The timeseries data to fill gaps in */
|
||||
data: T[]
|
||||
/** The key in each data object that contains the timestamp */
|
||||
timestampKey: string
|
||||
/** The key(s) to fill with default values when gaps exist */
|
||||
valueKey: string | string[]
|
||||
/** Default value to use for gaps */
|
||||
defaultValue: number
|
||||
/** Start of the time range (ISO string) */
|
||||
startDate?: string
|
||||
/** End of the time range (ISO string) */
|
||||
endDate?: string
|
||||
/** Minimum number of points before filling is applied */
|
||||
minPointsToFill?: number
|
||||
/** Optional interval specification (e.g., '5m', '1h') */
|
||||
interval?: string
|
||||
}
|
||||
|
||||
export type FillTimeseriesResult<T extends Datum = Datum> = {
|
||||
data: T[]
|
||||
error: Error | null
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts timeseries data by timestamp in ascending order
|
||||
* Returns a new sorted array without mutating the input
|
||||
*/
|
||||
export function sortByTimestamp<T extends Datum>(data: T[], timestampKey: string): T[] {
|
||||
return [...data].sort((a, b) => {
|
||||
return (
|
||||
new Date(a[timestampKey] as string).getTime() - new Date(b[timestampKey] as string).getTime()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the data has a valid timestamp key
|
||||
*/
|
||||
export function hasValidTimestamp<T extends Datum>(data: T[], timestampKey: string): boolean {
|
||||
return Boolean(data[0]?.[timestampKey])
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience hook for memoized filling of timeseries data.
|
||||
*
|
||||
* Fills gaps in timeseries data and sorts results by timestamp.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { data, error, isError } = useFillTimeseriesSorted({
|
||||
* data: chartData,
|
||||
* timestampKey: 'timestamp',
|
||||
* valueKey: 'count',
|
||||
* defaultValue: 0,
|
||||
* startDate: startIso,
|
||||
* endDate: endIso
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const useFillTimeseriesSorted = (...args: Parameters<typeof fillTimeseries>) => {
|
||||
export const useFillTimeseriesSorted = <T extends Datum = Datum>(
|
||||
options: FillTimeseriesOptions<T>
|
||||
): FillTimeseriesResult<T> => {
|
||||
const {
|
||||
data,
|
||||
timestampKey,
|
||||
valueKey,
|
||||
defaultValue,
|
||||
startDate,
|
||||
endDate,
|
||||
minPointsToFill = 20,
|
||||
interval,
|
||||
} = options
|
||||
|
||||
return useMemo(() => {
|
||||
const [data, timestampKey] = args
|
||||
if (!data[0]?.[timestampKey])
|
||||
// Early return if no valid timestamp
|
||||
if (!hasValidTimestamp(data, timestampKey)) {
|
||||
return {
|
||||
data,
|
||||
error: undefined,
|
||||
error: null,
|
||||
isError: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const filled = fillTimeseries(...args)
|
||||
const filled = fillTimeseries(
|
||||
data,
|
||||
timestampKey,
|
||||
valueKey,
|
||||
defaultValue,
|
||||
startDate,
|
||||
endDate,
|
||||
minPointsToFill,
|
||||
interval
|
||||
) as T[]
|
||||
|
||||
const sorted = sortByTimestamp(filled, timestampKey)
|
||||
|
||||
return {
|
||||
data: filled.sort((a, b) => {
|
||||
return (new Date(a[args[1]]) as any) - (new Date(b[args[1]]) as any)
|
||||
}),
|
||||
error: undefined,
|
||||
data: sorted,
|
||||
error: null,
|
||||
isError: false,
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
data: [],
|
||||
error,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}, [JSON.stringify(args[0]), ...args])
|
||||
}, [
|
||||
JSON.stringify(data),
|
||||
timestampKey,
|
||||
JSON.stringify(valueKey),
|
||||
defaultValue,
|
||||
startDate,
|
||||
endDate,
|
||||
minPointsToFill,
|
||||
interval,
|
||||
])
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
LogsTableName,
|
||||
PREVIEWER_DATEPICKER_HELPERS,
|
||||
@@ -22,6 +19,9 @@ import {
|
||||
genDefaultQuery,
|
||||
} from 'components/interfaces/Settings/Logs/Logs.utils'
|
||||
import { get } from 'data/fetchers'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { useFillTimeseriesSorted } from './useFillTimeseriesSorted'
|
||||
import { useLogsUrlState } from './useLogsUrlState'
|
||||
import useTimeseriesUnixToIso from './useTimeseriesUnixToIso'
|
||||
@@ -248,14 +248,14 @@ function useLogsPreview({
|
||||
'timestamp'
|
||||
)
|
||||
|
||||
const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted(
|
||||
normalizedEventChartData,
|
||||
'timestamp',
|
||||
'count',
|
||||
0,
|
||||
timestampStart,
|
||||
timestampEnd || new Date().toISOString()
|
||||
)
|
||||
const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted({
|
||||
data: normalizedEventChartData,
|
||||
timestampKey: 'timestamp',
|
||||
valueKey: 'count',
|
||||
defaultValue: 0,
|
||||
startDate: timestampStart,
|
||||
endDate: timestampEnd ?? new Date().toISOString(),
|
||||
})
|
||||
|
||||
return {
|
||||
newCount,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants'
|
||||
import type {
|
||||
EventChart,
|
||||
@@ -10,6 +8,8 @@ import type {
|
||||
} from 'components/interfaces/Settings/Logs/Logs.types'
|
||||
import { genChartQuery } from 'components/interfaces/Settings/Logs/Logs.utils'
|
||||
import { get } from 'data/fetchers'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useFillTimeseriesSorted } from './useFillTimeseriesSorted'
|
||||
import useTimeseriesUnixToIso from './useTimeseriesUnixToIso'
|
||||
|
||||
@@ -97,14 +97,14 @@ function useProjectUsageStats({
|
||||
'timestamp'
|
||||
)
|
||||
|
||||
const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted(
|
||||
normalizedEventChartData,
|
||||
'timestamp',
|
||||
'count',
|
||||
0,
|
||||
timestampStart,
|
||||
timestampEnd || new Date().toISOString()
|
||||
)
|
||||
const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted({
|
||||
data: normalizedEventChartData,
|
||||
timestampKey: 'timestamp',
|
||||
valueKey: 'count',
|
||||
defaultValue: 0,
|
||||
startDate: timestampStart,
|
||||
endDate: timestampEnd ?? new Date().toISOString(),
|
||||
})
|
||||
|
||||
return {
|
||||
isLoading: !eventChartResponse,
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLatest } from 'react-use'
|
||||
|
||||
export function useHotKey(
|
||||
// [Joshen] Refactor: Remove dependencies, and just make this into a single definition
|
||||
function useHotKey(
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
key: string,
|
||||
dependencies: any[] = [],
|
||||
options?: { enabled?: boolean }
|
||||
): void
|
||||
/**
|
||||
* @deprecated The `dependencies` parameter is deprecated. Use the overload without dependencies instead.
|
||||
*/
|
||||
function useHotKey(
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
key: string,
|
||||
dependencies: unknown[],
|
||||
options?: { enabled?: boolean }
|
||||
): void
|
||||
function useHotKey(
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
key: string,
|
||||
dependenciesOrOptions?: unknown[] | { enabled?: boolean },
|
||||
options?: { enabled?: boolean }
|
||||
): void {
|
||||
const enabled = options?.enabled ?? true
|
||||
// Determine which overload was called
|
||||
const isDepsArray = Array.isArray(dependenciesOrOptions)
|
||||
const resolvedOptions = isDepsArray ? options : dependenciesOrOptions
|
||||
const enabled = resolvedOptions?.enabled ?? true
|
||||
|
||||
const enabledRef = useLatest(enabled)
|
||||
const callbackRef = useLatest(callback)
|
||||
const keyRef = useLatest(key)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
function handler(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === key && !e.altKey && !e.shiftKey) {
|
||||
callback(e)
|
||||
if (!enabledRef.current) return
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === keyRef.current && !e.altKey && !e.shiftKey) {
|
||||
callbackRef.current(e)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
window.addEventListener('keydown', handler, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler)
|
||||
window.removeEventListener('keydown', handler, true)
|
||||
}
|
||||
}, [key, enabled, ...dependencies])
|
||||
}, [callbackRef, enabledRef, keyRef])
|
||||
}
|
||||
|
||||
export { useHotKey }
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { FunctionArtifact } from './types'
|
||||
|
||||
export class FileSystemFunctionsArtifactStore {
|
||||
constructor(private folderPath: string) {}
|
||||
|
||||
async getFunctions(): Promise<FunctionArtifact[]> {
|
||||
const dirEntries = await readdir(this.folderPath, { withFileTypes: true })
|
||||
|
||||
const functionsFolders = dirEntries.filter((dir) => dir.isDirectory() && dir.name !== 'main')
|
||||
const functionsArtifacts = await Promise.all(
|
||||
functionsFolders.map(parseFolderToFunctionArtifact)
|
||||
)
|
||||
|
||||
return functionsArtifacts.filter((f) => f !== undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function parseFolderToFunctionArtifact(
|
||||
folder: Dirent
|
||||
): Promise<FunctionArtifact | undefined> {
|
||||
const folderPath = path.join(folder.parentPath, folder.name)
|
||||
const files = await readdir(folderPath, { withFileTypes: true })
|
||||
const entrypoint = files.find((file) => file.isFile() && file.name.startsWith('index'))
|
||||
|
||||
if (!entrypoint) return
|
||||
|
||||
const entrypointPath = path.join(folderPath, entrypoint.name)
|
||||
const entrypointStat = await stat(entrypointPath)
|
||||
|
||||
return {
|
||||
slug: folder.name,
|
||||
entrypoint_path: pathToFileURL(entrypointPath).href,
|
||||
created_at: entrypointStat.birthtimeMs,
|
||||
updated_at: entrypointStat.mtimeMs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { FileSystemFunctionsArtifactStore } from './fileSystemStore'
|
||||
import { assertSelfHosted } from '../util'
|
||||
|
||||
export function getFunctionsArtifactStore() {
|
||||
assertSelfHosted()
|
||||
assert(
|
||||
process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER,
|
||||
'EDGE_FUNCTIONS_MANAGEMENT_FOLDER is required'
|
||||
)
|
||||
|
||||
return new FileSystemFunctionsArtifactStore(process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type FunctionArtifact = {
|
||||
slug: string
|
||||
entrypoint_path: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
@@ -182,6 +182,11 @@ export const detectOS = () => {
|
||||
}
|
||||
}
|
||||
|
||||
export const getModKeyLabel = () => {
|
||||
const os = detectOS()
|
||||
return os === 'macos' ? '⌘' : 'Ctrl+'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of tables to SQL
|
||||
* @param t - The list of tables
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from 'next'
|
||||
|
||||
import type { components } from 'api-types'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { getFunctionsArtifactStore } from 'lib/api/self-hosted/functions'
|
||||
|
||||
export default function handlerWithErrorCatching(req: NextApiRequest, res: NextApiResponse) {
|
||||
return apiWrapper(req, res, handler, { withAuth: true })
|
||||
}
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return handleGetAll(req, res)
|
||||
default:
|
||||
res.setHeader('Allow', ['GET'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
|
||||
type EdgeFunctionsResponse = components['schemas']['FunctionResponse']
|
||||
|
||||
const handleGetAll = async (_req: NextApiRequest, res: NextApiResponse) => {
|
||||
const store = getFunctionsArtifactStore()
|
||||
|
||||
const functionsArtifacts = await store.getFunctions()
|
||||
if (functionsArtifacts.length === 0) return res.status(200).json([])
|
||||
|
||||
const functions = functionsArtifacts.map(
|
||||
(func) =>
|
||||
({
|
||||
id: uuidv4(),
|
||||
slug: func.slug,
|
||||
version: 1,
|
||||
name: func.slug,
|
||||
status: 'ACTIVE',
|
||||
entrypoint_path: func.entrypoint_path,
|
||||
created_at: func.created_at,
|
||||
updated_at: func.updated_at,
|
||||
}) satisfies EdgeFunctionsResponse
|
||||
)
|
||||
|
||||
return res.status(200).json(functions)
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import maxBy from 'lodash/maxBy'
|
||||
import meanBy from 'lodash/meanBy'
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useFlag } from 'common'
|
||||
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
@@ -19,8 +12,14 @@ import {
|
||||
useFunctionsCombinedStatsQuery,
|
||||
} from 'data/analytics/functions-combined-stats-query'
|
||||
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import maxBy from 'lodash/maxBy'
|
||||
import meanBy from 'lodash/meanBy'
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ChartIntervals, NextPageWithLayout } from 'types'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
@@ -81,7 +80,9 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
})
|
||||
|
||||
const combinedStatsData = useMemo(() => {
|
||||
const result = combinedStatsResults.data?.result
|
||||
const result = combinedStatsResults.data?.result as
|
||||
| Record<string, string | number>[]
|
||||
| undefined
|
||||
return result || []
|
||||
}, [combinedStatsResults.data])
|
||||
|
||||
@@ -98,10 +99,10 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
data: combinedStatsChartData,
|
||||
error: combinedStatsError,
|
||||
isError: isErrorCombinedStats,
|
||||
} = useFillTimeseriesSorted(
|
||||
combinedStatsData,
|
||||
'timestamp',
|
||||
[
|
||||
} = useFillTimeseriesSorted({
|
||||
data: combinedStatsData,
|
||||
timestampKey: 'timestamp',
|
||||
valueKey: [
|
||||
'requests_count',
|
||||
'log_count',
|
||||
'log_info_count',
|
||||
@@ -119,10 +120,10 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
'avg_external_memory_used',
|
||||
'max_cpu_time_used',
|
||||
],
|
||||
0,
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString()
|
||||
)
|
||||
defaultValue: 0,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
})
|
||||
|
||||
const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckPermissions(
|
||||
PermissionAction.FUNCTIONS_READ,
|
||||
@@ -179,7 +180,7 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to reterieve execution time</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
{combinedStatsError.message}
|
||||
{combinedStatsError?.message ?? 'Unknown error'}
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
@@ -224,7 +225,7 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to reterieve invocations</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
{combinedStatsError.message}
|
||||
{combinedStatsError?.message ?? 'Unknown error'}
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
@@ -326,7 +327,7 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve CPU time</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
{combinedStatsError.message}
|
||||
{combinedStatsError?.message ?? 'Unknown error'}
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
@@ -371,7 +372,7 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve memory usage</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
{combinedStatsError.message}
|
||||
{combinedStatsError?.message ?? 'Unknown error'}
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { EdgeFunctionsListItem } from 'components/interfaces/Functions/EdgeFunctionsListItem'
|
||||
import {
|
||||
FunctionsEmptyState,
|
||||
FunctionsEmptyStateLocal,
|
||||
FunctionsInstructionsLocal,
|
||||
} from 'components/interfaces/Functions/FunctionsEmptyState'
|
||||
import { TerminalInstructionsDialog } from 'components/interfaces/Functions/TerminalInstructionsDialog'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
@@ -25,6 +25,7 @@ import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
@@ -84,84 +85,90 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
|
||||
<PageContainer size="large">
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
{IS_PLATFORM ? (
|
||||
<>
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
|
||||
{isSuccess && (
|
||||
<>
|
||||
{hasFunctions ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-6">
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
{isError &&
|
||||
(IS_PLATFORM ? (
|
||||
<AlertError error={error} subject="Failed to retrieve edge functions" />
|
||||
) : (
|
||||
<Admonition type="warning" title="Failed to retrieve edge functions">
|
||||
<p className="prose [&>code]:text-xs text-sm">
|
||||
Local functions can be found at <code>supabase/functions</code> folder.
|
||||
</p>
|
||||
</Admonition>
|
||||
))}
|
||||
{isSuccess && (
|
||||
<>
|
||||
{hasFunctions ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Search function names"
|
||||
icon={<Search />}
|
||||
size="tiny"
|
||||
className="w-32 md:w-64"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
actions={[
|
||||
search && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
onClick={() => setSearch('')}
|
||||
className="p-0 h-5 w-5"
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<EdgeFunctionsSortDropdown value={sort} onChange={setSortQueryParam} />
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Search function names"
|
||||
icon={<Search />}
|
||||
size="tiny"
|
||||
className="w-32 md:w-64"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
actions={[
|
||||
search && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
onClick={() => setSearch('')}
|
||||
className="p-0 h-5 w-5"
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead className="hidden 2xl:table-cell">Created</TableHead>
|
||||
<TableHead className="lg:table-cell">Updated</TableHead>
|
||||
<TableHead className="lg:table-cell">Deployments</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<>
|
||||
{filteredFunctions.length > 0 ? (
|
||||
filteredFunctions.map((item) => (
|
||||
<EdgeFunctionsListItem key={item.id} function={item} />
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<EdgeFunctionsSortDropdown value={sort} onChange={setSortQueryParam} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FunctionsEmptyState />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<FunctionsEmptyStateLocal />
|
||||
)}
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead className="hidden 2xl:table-cell">Created</TableHead>
|
||||
<TableHead className="lg:table-cell">Updated</TableHead>
|
||||
<TableHead className="lg:table-cell">Deployments</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<>
|
||||
{filteredFunctions.length > 0 ? (
|
||||
filteredFunctions.map((item) => (
|
||||
<EdgeFunctionsListItem key={item.id} function={item} />
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<FunctionsEmptyState />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!IS_PLATFORM && <FunctionsInstructionsLocal />}
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants'
|
||||
import { useSelectedVectorBucket } from 'components/interfaces/Storage/VectorBuckets/useSelectedVectorBuckets'
|
||||
import { VectorBucketDetails } from 'components/interfaces/Storage/VectorBuckets/VectorBucketDetails'
|
||||
import { useSelectedVectorBucket } from 'components/interfaces/Storage/VectorBuckets/useSelectedVectorBuckets'
|
||||
import { DefaultLayout } from 'components/layouts/DefaultLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { VectorBucket as VectorBucketIcon } from 'icons'
|
||||
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const VectorsBucketPage: NextPageWithLayout = () => {
|
||||
const config = BUCKET_TYPES['vectors']
|
||||
const router = useRouter()
|
||||
const { ref, bucketId } = useParams()
|
||||
const { projectRef } = useStorageExplorerStateSnapshot()
|
||||
|
||||
const { data: bucket, isSuccess } = useSelectedVectorBucket()
|
||||
|
||||
const config = BUCKET_TYPES['vectors']
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && !bucket) {
|
||||
toast.info(`Bucket "${bucketId}" does not exist in your project`)
|
||||
@@ -39,7 +36,7 @@ const VectorsBucketPage: NextPageWithLayout = () => {
|
||||
</div>
|
||||
}
|
||||
breadcrumbs={[
|
||||
{ label: 'Vectors', href: `/project/${projectRef}/storage/vectors` },
|
||||
{ label: 'Vectors', href: `/project/${ref}/storage/vectors` },
|
||||
{
|
||||
label: 'Buckets',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type { PostgresColumn } from '@supabase/postgres-meta'
|
||||
import { PropsWithChildren, createContext, useContext } from 'react'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
|
||||
import { useConstant } from 'common'
|
||||
import type { SupaRow } from 'components/grid/types'
|
||||
import {
|
||||
@@ -11,15 +8,16 @@ import {
|
||||
import { ForeignKey } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types'
|
||||
import type { EditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types'
|
||||
import type { TableField } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types'
|
||||
import { PropsWithChildren, createContext, useContext } from 'react'
|
||||
import type { Dictionary } from 'types'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
|
||||
import {
|
||||
NewQueuedOperation,
|
||||
QueuedOperationType,
|
||||
type EditCellContentPayload,
|
||||
type OperationQueueState,
|
||||
type QueuedOperation,
|
||||
type QueueStatus,
|
||||
type QueuedOperation,
|
||||
QueuedOperationType,
|
||||
} from './table-editor-operation-queue.types'
|
||||
|
||||
export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100
|
||||
@@ -203,10 +201,14 @@ export const createTableEditorState = () => {
|
||||
sidePanel: { type: 'csv-import', file },
|
||||
}
|
||||
},
|
||||
onViewOperationQueue: () => {
|
||||
state.ui = {
|
||||
open: 'side-panel',
|
||||
sidePanel: { type: 'operation-queue' },
|
||||
toggleViewOperationQueue: () => {
|
||||
if (state.ui.open === 'side-panel' && state.ui.sidePanel.type === 'operation-queue') {
|
||||
state.closeSidePanel()
|
||||
} else {
|
||||
state.ui = {
|
||||
open: 'side-panel',
|
||||
sidePanel: { type: 'operation-queue' },
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -5,3 +5,172 @@
|
||||
Refer to the [Development Guide](../../DEVELOPERS.md) to learn how to run this site locally.
|
||||
|
||||
To get started copy the example env file using `cp .env.local.example .env.local`.
|
||||
|
||||
## Best practices
|
||||
|
||||
### Images
|
||||
|
||||
- **Resize images**: All new images should be resized to only the maximum resolution needed when rendering on the frontend. Don't upload images larger than what will be displayed.
|
||||
- **Compress images**: All new images should be compressed before committing. Use tools like Clop or ImageOptim to reduce file size without noticeable quality loss.
|
||||
- **Image locations**: Store blog post images in `apps/www/public/images/blog/`. Event images go in `apps/www/public/images/events/`.
|
||||
|
||||
### OG image generation
|
||||
|
||||
Open Graph (OG) images for social sharing are handled differently across content types:
|
||||
|
||||
- **Blog posts**: Use static images via `imgSocial` and `imgThumb` fields (see _Blog posts_ section below)
|
||||
- **Events**: Use dynamic generation via Edge Function (with optional `og_image` override)
|
||||
- **Customer stories**: Use dynamic generation via Edge Function (no static option)
|
||||
|
||||
The `og-images` Edge Function (`supabase/functions/og-images/`) automatically generates OG images for events and customer stories. It's deployed via `.github/workflows/og_images.yml` when changes are made to the function code.
|
||||
|
||||
**Development**: In local development, the function runs at `http://127.0.0.1:54321/functions/v1/og-images`. Ensure Supabase is running locally (`supabase start`).
|
||||
|
||||
### Content frontmatter image fields
|
||||
|
||||
Different content types use different image field conventions:
|
||||
|
||||
#### Blog posts
|
||||
|
||||
Blog posts support two image fields in their frontmatter:
|
||||
|
||||
- **`imgSocial`**: Used for Open Graph and social media sharing (X, LinkedIn, etc.). These images should include text overlays since they appear standalone in social feeds without accompanying text.
|
||||
- **`imgThumb`**: Used for internal thumbnails displayed on the blog listing pages and featured posts. These images don't need text overlays since they're always displayed alongside the post title and description.
|
||||
|
||||
This naming convention was introduced to replace the previously confusing `thumb` and `og` fields, which were often mixed up. The new names clearly indicate:
|
||||
|
||||
- **`imgSocial`**: Purpose-built for social media sharing (needs text overlays)
|
||||
- **`imgThumb`**: Optimized for site display (clean, no text overlays)
|
||||
|
||||
This separation allows you to optimize images for their specific use case while maintaining a clear, unambiguous naming convention.
|
||||
|
||||
**Image path format**
|
||||
|
||||
Always use **relative paths** (just the filename or subfolder/filename). The `/images/blog/` prefix is added automatically by the code.
|
||||
|
||||
- ✅ Correct: `imgSocial: my-post/og.png` or `imgSocial: og.png`
|
||||
- ❌ Wrong: `imgSocial: /images/blog/my-post/og.png` (creates double prefix)
|
||||
|
||||
**Image fallback behavior**
|
||||
|
||||
For site display (what visitors see):
|
||||
|
||||
- Priority 1: `imgThumb`
|
||||
- Priority 2: `imgSocial` (if `imgThumb` is missing)
|
||||
- Priority 3: `/images/blog/blog-placeholder.png` (if both missing)
|
||||
|
||||
For social sharing (Open Graph meta tags):
|
||||
|
||||
- Priority 1: `imgSocial`
|
||||
- Priority 2: `imgThumb` (if `imgSocial` is missing)
|
||||
- Priority 3: No fallback (undefined)
|
||||
|
||||
Special case for CMS posts:
|
||||
|
||||
- CMS posts may also have a `meta.image` field which takes highest priority for social sharing when available.
|
||||
|
||||
What happens if fields are not provided?
|
||||
|
||||
- If only `imgThumb` is provided: Site displays the image correctly, social sharing uses `imgThumb` as fallback
|
||||
- If only `imgSocial` is provided: Social sharing uses it, site display uses it as fallback
|
||||
- If neither is provided: Site shows placeholder image, social sharing has no image
|
||||
- Best practice: Provide both fields for optimal display and social sharing
|
||||
|
||||
**Example**
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: 'My Blog Post'
|
||||
imgSocial: 2025-01-01-my-post/og.png # Relative path - with text overlay for social sharing
|
||||
imgThumb: 2025-01-01-my-post/thumb.png # Relative path - without text, clean image
|
||||
---
|
||||
```
|
||||
|
||||
The images would be stored at:
|
||||
|
||||
- `apps/www/public/images/blog/2025-01-01-my-post/og.png`
|
||||
- `apps/www/public/images/blog/2025-01-01-my-post/thumb.png`
|
||||
|
||||
Or if using the same image for both:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: 'My Blog Post'
|
||||
imgSocial: my-image.png # Stored at: apps/www/public/images/blog/my-image.png
|
||||
imgThumb: my-image.png
|
||||
---
|
||||
```
|
||||
|
||||
#### Events
|
||||
|
||||
Events use different image fields to avoid confusion with their display patterns:
|
||||
|
||||
- **`thumb`**: Used for event grid item thumbnails (small cards in listing)
|
||||
- **`cover_url`**: Used for the featured event banner (large display on events page)
|
||||
- **`og_image`** (optional): Used to override the dynamically generated OG image for social sharing
|
||||
|
||||
**OG image generation**
|
||||
|
||||
Events automatically generate Open Graph images using the `og-images` Supabase Edge Function. The function creates images dynamically based on:
|
||||
|
||||
- Event type (conference, hackathon, etc.)
|
||||
- Title (or `meta_title` if provided)
|
||||
- Description (or `meta_description` if provided)
|
||||
- Date (formatted as "DD MMM YYYY" using the event's timezone)
|
||||
- Duration (if provided)
|
||||
|
||||
If you need a custom OG image that differs from the auto-generated one, you can provide an `og_image` field in the frontmatter. This will override the dynamic generation.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: 'Supabase Meetup'
|
||||
thumb: /images/events/2025-01-meetup/thumbnail.png
|
||||
cover_url: https://external-cdn.com/event-banner.jpg
|
||||
og_image: /images/events/2025-01-meetup/custom-og.png # Optional override
|
||||
---
|
||||
```
|
||||
|
||||
**Note**: The `og_image` field is optional. If not provided, OG images are generated automatically via the Edge Function.
|
||||
|
||||
#### Customer Stories (Case Studies)
|
||||
|
||||
Customer stories are defined in MDX files (`apps/www/_customers/*.mdx`) and use a different approach:
|
||||
|
||||
- **No `og_image` field**: Customer stories do NOT use static OG images
|
||||
- **Dynamic OG generation**: All customer story OG images are automatically generated using the `og-images` Supabase Edge Function
|
||||
- The function creates images based on the customer `slug` and `title` (or `meta_title` if provided)
|
||||
|
||||
Do not include an `og_image` field in customer story frontmatter. It will be ignored. OG images are always generated dynamically.
|
||||
|
||||
**Example** (in `apps/www/_customers/company-abc.mdx`):
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: Company ABC
|
||||
title: Company ABC built their platform with Supabase
|
||||
# DO NOT include og_image - it's generated automatically
|
||||
logo: /images/customers/logos/company-abc.png
|
||||
---
|
||||
```
|
||||
|
||||
**Legacy Case Studies** (in `data/CustomerStories.ts`):
|
||||
|
||||
- **`imgUrl`**: Path to the case study image in the source data
|
||||
- This gets mapped to `imgThumb` when rendered via `BlogGridItem` component
|
||||
- Case studies only need one image for site display (no separate social sharing image)
|
||||
|
||||
**Example** (in `data/CustomerStories.ts`):
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'Customer Story',
|
||||
title: 'Company ABC built their platform with Supabase',
|
||||
description: '...',
|
||||
organization: 'Company ABC',
|
||||
imgUrl: 'images/customers/logos/company-abc.png', // Full path from public/
|
||||
logo: '/images/customers/logos/company-abc.png',
|
||||
url: '/customers/company-abc',
|
||||
}
|
||||
```
|
||||
@@ -5,7 +5,7 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://avatars2.githubusercontent.com/u/10214025?s=400&u=c6775be2ae667e2acae3ccd347fed62bb3f5b3e7&v=4
|
||||
image: /docs/img/supabase-auth-cover.png
|
||||
imgThumb: supabase-auth-cover.png
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,7 +5,7 @@ author: thor_schaeff
|
||||
author_title: Supabase community contributor
|
||||
author_url: https://github.com/thorwebdev
|
||||
author_image_url: https://github.com/thorwebdev.png
|
||||
image: hacktoberfest.png
|
||||
imgSocial: hacktoberfest.png
|
||||
categories:
|
||||
- developers
|
||||
tags:
|
||||
|
||||
@@ -5,7 +5,7 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://avatars2.githubusercontent.com/u/10214025?s=400&u=c6775be2ae667e2acae3ccd347fed62bb3f5b3e7&v=4
|
||||
image: supabase-september-2020.png
|
||||
imgSocial: supabase-september-2020.png
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,7 +5,7 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://avatars2.githubusercontent.com/u/10214025?s=400&u=c6775be2ae667e2acae3ccd347fed62bb3f5b3e7&v=4
|
||||
image: supabase-js-1-0.png
|
||||
imgSocial: supabase-js-1-0.png
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,7 +5,7 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://avatars2.githubusercontent.com/u/10214025?s=400&u=c6775be2ae667e2acae3ccd347fed62bb3f5b3e7&v=4
|
||||
image: supabase-october-2020.png
|
||||
imgSocial: supabase-october-2020.png
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,7 +5,7 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://avatars2.githubusercontent.com/u/10214025?s=400&u=c6775be2ae667e2acae3ccd347fed62bb3f5b3e7&v=4
|
||||
image: supabase-november-2020.png
|
||||
imgSocial: supabase-november-2020.png
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,7 +5,7 @@ author: rory_wilding
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/roryw10
|
||||
author_image_url: https://github.com/roryw10.png
|
||||
image: /images/blog/supabase-monitoro.png
|
||||
imgSocial: supabase-monitoro.png
|
||||
categories:
|
||||
- company
|
||||
tags:
|
||||
|
||||
@@ -5,7 +5,7 @@ author: rory_wilding
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/roryw10
|
||||
author_image_url: https://github.com/roryw10.png
|
||||
image: /images/blog/supabase-tayfa.png
|
||||
imgSocial: supabase-tayfa.png
|
||||
categories:
|
||||
- company
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: rory_wilding
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/roryw10
|
||||
author_image_url: https://github.com/roryw10.png
|
||||
image: supabase-xendit.png
|
||||
thumb: supabase-xendit-thumb.jpg
|
||||
imgSocial: supabase-xendit.png
|
||||
imgThumb: supabase-xendit-thumb.jpg
|
||||
categories:
|
||||
- company
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: rory_wilding
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/roryw10
|
||||
author_image_url: https://github.com/roryw10.png
|
||||
image: supabase-strive-school.png
|
||||
thumb: supabase-strive-school.png
|
||||
imgSocial: supabase-strive-school.png
|
||||
imgThumb: supabase-strive-school.png
|
||||
categories:
|
||||
- company
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://github.com/kiwicopple.png
|
||||
image: supabase-december-2020.png
|
||||
thumb: supabase-december-2020.png
|
||||
imgSocial: supabase-december-2020.png
|
||||
imgThumb: supabase-december-2020.png
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://github.com/kiwicopple.png
|
||||
image: supabase-january-2021.png
|
||||
thumb: supabase-january-2021.png
|
||||
imgSocial: supabase-january-2021.png
|
||||
imgThumb: supabase-january-2021.png
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: rory_wilding
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/roryw10
|
||||
author_image_url: https://github.com/roryw10.png
|
||||
image: roboflow-og.png
|
||||
thumb: roboflow-website.png
|
||||
imgSocial: roboflow-og.png
|
||||
imgThumb: roboflow-website.png
|
||||
categories:
|
||||
- company
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: ant_wilson
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/awalias
|
||||
author_image_url: https://github.com/awalias.png
|
||||
image: elephants.png
|
||||
thumb: elephants.png
|
||||
imgSocial: elephants.png
|
||||
imgThumb: elephants.png
|
||||
categories:
|
||||
- postgres
|
||||
tags:
|
||||
|
||||
@@ -5,7 +5,7 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://github.com/kiwicopple.png
|
||||
image: /images/blog/release-feb-2021.jpg
|
||||
imgSocial: release-feb-2021.jpg
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://github.com/kiwicopple.png
|
||||
image: supabase-postgres-cron.png
|
||||
thumb: supabase-postgres-cron.png
|
||||
imgSocial: supabase-postgres-cron.png
|
||||
imgThumb: supabase-postgres-cron.png
|
||||
categories:
|
||||
- postgres
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: rory_wilding
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/roryw10
|
||||
author_image_url: https://github.com/roryw10.png
|
||||
image: toadli-og.jpg
|
||||
thumb: toadli-website.jpg
|
||||
imgSocial: toadli-og.jpg
|
||||
imgThumb: toadli-website.jpg
|
||||
categories:
|
||||
- engineering
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: ant_wilson
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/awalias
|
||||
author_image_url: https://github.com/awalias.png
|
||||
image: replit-og.jpg
|
||||
thumb: replit-og.jpg
|
||||
imgSocial: replit-og.jpg
|
||||
imgThumb: replit-og.jpg
|
||||
categories:
|
||||
- engineering
|
||||
tags:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
title: Developers stay up to date with intheloop.dev
|
||||
description: Learn why Kevin is building intheloop.dev with Supabase
|
||||
author: rory_wilding
|
||||
image: intheloop-supabase.jpg
|
||||
thumb: intheloop-supabase.jpg
|
||||
imgSocial: intheloop-supabase.jpg
|
||||
imgThumb: intheloop-supabase.jpg
|
||||
categories:
|
||||
- developers
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://github.com/kiwicopple.png
|
||||
image: angels-of-supabase.png
|
||||
thumb: angels-of-supabase.png
|
||||
imgSocial: angels-of-supabase.png
|
||||
imgThumb: angels-of-supabase.png
|
||||
categories:
|
||||
- company
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: ant_wilson
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/awalias
|
||||
author_image_url: https://github.com/awalias.png
|
||||
image: supabase-launch-week.png
|
||||
thumb: supabase-launch-week.png
|
||||
imgSocial: supabase-launch-week.png
|
||||
imgThumb: supabase-launch-week.png
|
||||
categories:
|
||||
- launch-week
|
||||
tags:
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
title: Supabase Beta Pricing
|
||||
description: Supabase launches Beta pricing structure
|
||||
author: rory_wilding
|
||||
image: pricing-og.jpg
|
||||
thumb: launch-week-pricing.jpg
|
||||
imgThumb: launch-week-pricing.jpg
|
||||
categories:
|
||||
- company
|
||||
tags:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
title: Storage is now available in Supabase
|
||||
description: Launching Supabase Storage and how you can use it in your apps
|
||||
author: inian
|
||||
image: storage/ph-1.png
|
||||
thumb: storage/ph-1.png
|
||||
imgSocial: storage/ph-1.png
|
||||
imgThumb: storage/ph-1.png
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
title: Supabase CLI
|
||||
description: Local development, database migrations, and self-hosting.
|
||||
author: soedirgo
|
||||
image: cli/cli-og.jpg
|
||||
thumb: cli/cli-og.jpg
|
||||
imgSocial: cli/cli-og.jpg
|
||||
imgThumb: cli/cli-og.jpg
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: ant_wilson
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/awalias
|
||||
author_image_url: https://github.com/awalias.png
|
||||
image: nft/nft-3.png
|
||||
thumb: nft/nft-3.png
|
||||
imgSocial: nft/nft-3.png
|
||||
imgThumb: nft/nft-3.png
|
||||
categories:
|
||||
- company
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://github.com/kiwicopple.png
|
||||
image: supabase-dot-com-og.jpg
|
||||
thumb: supabase-dot-com-og.jpg
|
||||
imgSocial: supabase-dot-com-og.jpg
|
||||
imgThumb: supabase-dot-com-og.jpg
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: angelico_de_los_reyes
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/dragarcia
|
||||
author_image_url: https://github.com/dragarcia.png
|
||||
image: bouncer/pgbouncer-og.jpg
|
||||
thumb: bouncer/pgbouncer-thumb.jpg
|
||||
imgSocial: bouncer/pgbouncer-supabase-og.jpg
|
||||
imgThumb: bouncer/pgbouncer-thumb.jpg
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -5,8 +5,8 @@ author: fracek
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/fracek
|
||||
author_image_url: https://github.com/fracek.png
|
||||
image: workflows/workflows-og.jpg
|
||||
thumb: workflows/workflows-thumb.jpg
|
||||
imgSocial: workflows/workflows-og.jpg
|
||||
imgThumb: workflows/workflows-thumb.jpg
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -4,8 +4,8 @@ description: Launch week, Storage, Supabase CLI, Connection Pooling, Supabase UI
|
||||
author: paul_copplestone
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://github.com/kiwicopple.png
|
||||
image: march-2021/release-mar-2021.jpg
|
||||
thumb: march-2021/release-mar-2021.jpg
|
||||
imgSocial: march-2021/release-mar-2021.jpg
|
||||
imgThumb: march-2021/release-mar-2021.jpg
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -4,8 +4,8 @@ description: Supabase "gardening" - stability, security, and community support.
|
||||
author: paul_copplestone
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://github.com/kiwicopple.png
|
||||
image: april-2021/release-apr-2021.jpg
|
||||
thumb: april-2021/release-apr-2021.jpg
|
||||
imgSocial: april-2021/release-apr-2021.jpg
|
||||
imgThumb: april-2021/release-apr-2021.jpg
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -4,8 +4,8 @@ description: Apple & Twitter Logins, Supabase Grid, Go & Swift Libraries
|
||||
author: paul_copplestone
|
||||
author_url: https://github.com/kiwicopple
|
||||
author_image_url: https://github.com/kiwicopple.png
|
||||
image: 2021-may/release-may-2021.jpg
|
||||
thumb: 2021-may/release-may-2021.jpg
|
||||
imgSocial: 2021-may/release-may-2021.jpg
|
||||
imgThumb: 2021-may/release-may-2021.jpg
|
||||
categories:
|
||||
- product
|
||||
tags:
|
||||
|
||||
@@ -4,8 +4,8 @@ description: Using Postgres Hooks to protect functionality in your Postgres data
|
||||
author: steve_chavez
|
||||
author_url: https://github.com/steve-chavez
|
||||
author_image_url: https://github.com/steve-chavez.png
|
||||
image: postgres-hooks/og-postgres-hooks.jpg
|
||||
thumb: postgres-hooks/cover-postgres-hooks.jpg
|
||||
imgSocial: postgres-hooks/og-postgres-hooks.jpg
|
||||
imgThumb: postgres-hooks/cover-postgres-hooks.jpg
|
||||
categories:
|
||||
- postgres
|
||||
tags:
|
||||
|
||||
Loaded 100 of 540 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user