diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ef7a2ecb2e0..56122b17af5 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -67,7 +67,7 @@ jobs: if: always() with: github_repo: test-reports - allure_results: allure-results + allure_results: tests/allure-results allure_history: allure-history subfolder: supabase-integration keep_reports: 50 diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 179e06199fd..465efd1a6ec 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -50,7 +50,7 @@ To build Supabase, you clone the source code repository: ### Choosing Directory -Before you start a development server, you must choose if you want to work on the [Supabase Website](https://supabase.com), [Supabase Docs](https://supabase.com/docs/), or [Supabase Studio](https://app.supabase.io). +Before you start a development server, you must choose if you want to work on the [Supabase Website](https://supabase.com), [Supabase Docs](https://supabase.com/docs), or [Supabase Studio](https://app.supabase.io). 1. Go to the [Supabase Website](https://supabase.com) directory @@ -58,7 +58,7 @@ Before you start a development server, you must choose if you want to work on th cd www ``` - Go to the [Supabase Docs](https://supabase.com/docs/) directory + Go to the [Supabase Docs](https://supabase.com/docs) directory ```sh cd web diff --git a/README.md b/README.md index c3ce702b7fd..b886e991019 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Supabase is a combination of open source tools. We’re building the features of **Architecture** Supabase is a [hosted platform](https://app.supabase.io). You can sign up and start using Supabase without installing anything. -You can also [self-host](https://supabase.com/docs/guides/self-hosting) and [develop locally](https://supabase.com/docs/guides/local-development). +You can also [self-host](https://supabase.com/docs/guides/hosting/overview) and [develop locally](https://supabase.com/docs/guides/local-development). ![Architecture](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/about/docs/careers/developer-success.mdx b/about/docs/careers/developer-success.mdx index 25a81df2d7c..e7be5de18a2 100644 --- a/about/docs/careers/developer-success.mdx +++ b/about/docs/careers/developer-success.mdx @@ -34,7 +34,7 @@ Supabase is hiring Developer Success Engineers. Help us build the easiest databa ## Bonus if you: -- Are a Polyglot developer, with some skill in NodeJS/Javascript. +- Are a Polyglot developer, with some skill in Node.js/JavaScript. - Have PostgreSQL experience. ## About Supabase diff --git a/about/docs/careers/front-end-developers.mdx b/about/docs/careers/front-end-developers.mdx index 5f0768f27c5..3f190eaef65 100644 --- a/about/docs/careers/front-end-developers.mdx +++ b/about/docs/careers/front-end-developers.mdx @@ -42,7 +42,7 @@ You've worked in multi disciplinary teams before, working with designers, develo ## Must have skills -- 5+ years experience in web development (Javascript, HTML5, CSS3, SCSS/LESS). +- 5+ years experience in web development (JavaScript, HTML5, CSS3, SCSS/LESS). - Have an understanding of modern JS (ES6 etc). - Experience with Node. - Experience with React (We use NextJS everywhere). diff --git a/examples/nuxtjs-user-management/plugins/README.md b/examples/nuxtjs-user-management/plugins/README.md index ca1f9d8a452..1736413ee11 100644 --- a/examples/nuxtjs-user-management/plugins/README.md +++ b/examples/nuxtjs-user-management/plugins/README.md @@ -2,6 +2,6 @@ **This directory is not required, you can delete it if you don't want to use it.** -This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. +This directory contains JavaScript plugins that you want to run before mounting the root Vue.js application. More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). diff --git a/i18n/README.ar.md b/i18n/README.ar.md index 3e5d00ba73c..b9df263a5db 100644 --- a/i18n/README.ar.md +++ b/i18n/README.ar.md @@ -46,7 +46,7 @@ Supabase عبارة عن مجموعة من الأدوات مفتوحة المص **الهيكلة الحالية** -(supabase) هي [منصة مستضافة](https://app.supabase.io), يمكنك التسجيل والبدأ باستخدامها دون الحاجة لتثبيت أي شئ. يمكنك أيضا [استضافتها ذاتيا](https://supabase.com/docs/guides/self-hosting) و [تطويرها داخليا](https://supabase.com/docs/guides/local-development). +(supabase) هي [منصة مستضافة](https://app.supabase.io), يمكنك التسجيل والبدأ باستخدامها دون الحاجة لتثبيت أي شئ. يمكنك أيضا [استضافتها ذاتيا](https://supabase.com/docs/guides/hosting/overview) و [تطويرها داخليا](https://supabase.com/docs/guides/local-development). ![Architecture](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/i18n/README.bn.md b/i18n/README.bn.md index 7f68d584a1b..3436c0af18c 100644 --- a/i18n/README.bn.md +++ b/i18n/README.bn.md @@ -49,7 +49,7 @@ **স্থাপত্য** সুপাবেস হল একটি [হোস্ট করা প্ল্যাটফর্ম](https://app.supabase.io)। আপনি সাইন আপ করতে পারেন এবং কিছু ইনস্টল না করে সুপাবেস ব্যবহার শুরু করতে পারেন। -এছাড়াও আপনি [স্ব-হোস্ট](https://supabase.com/docs/guides/self-hosting) এবং [স্থানীয়ভাবে বিকাশ](https://supabase.com/docs/guides/local-development) করতে পারেন। +এছাড়াও আপনি [স্ব-হোস্ট](https://supabase.com/docs/guides/hosting/overview) এবং [স্থানীয়ভাবে বিকাশ](https://supabase.com/docs/guides/local-development) করতে পারেন। ![আর্কিটেকচার](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/i18n/README.ca.md b/i18n/README.ca.md index f6b9711e60c..436a42b1ec2 100644 --- a/i18n/README.ca.md +++ b/i18n/README.ca.md @@ -46,7 +46,7 @@ Supabase és una combinació d’eines de codi obert. Estem construint les funci **Arquitectura actual** -Supabase és una [plataforma allotjada](https://app.supabase.io). Et pots registrar i començar a utilitzar Supabase sense instal·lar res. També podeu tenir una [_host_ pròpia](https://supabase.com/docs/guides/self-hosting) i [desenvolupar localment](https://supabase.com/docs/guides/local-development). +Supabase és una [plataforma allotjada](https://app.supabase.io). Et pots registrar i començar a utilitzar Supabase sense instal·lar res. També podeu tenir una [_host_ pròpia](https://supabase.com/docs/guides/hosting/overview) i [desenvolupar localment](https://supabase.com/docs/guides/local-development). ![Arquitectura](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/i18n/README.es.md b/i18n/README.es.md index 8ec6da9d511..86d297d3092 100644 --- a/i18n/README.es.md +++ b/i18n/README.es.md @@ -46,7 +46,7 @@ Supabase es una combinación de herramientas de código abierto. Estamos constru **Arquitectura actual** -Supabase es una [plataforma alojada](https://app.supabase.io). Te puedes registrar y comenzar a utilizar Supabase sin instalar nada. También puedes tener un [_host_ propio](https://supabase.com/docs/guides/self-hosting) y [desarrollar en local](https://supabase.com/docs/guides/local-development). +Supabase es una [plataforma alojada](https://app.supabase.io). Te puedes registrar y comenzar a utilizar Supabase sin instalar nada. También puedes tener un [_host_ propio](https://supabase.com/docs/guides/hosting/overview) y [desarrollar en local](https://supabase.com/docs/guides/local-development). ![Arquitectura](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/i18n/README.fr.md b/i18n/README.fr.md index 462ed1cbcea..ecd4b7cfb63 100644 --- a/i18n/README.fr.md +++ b/i18n/README.fr.md @@ -49,7 +49,7 @@ Supabase est une combinaison d'outils open source. Nous développons les fonctio **Architecture actuelle** Supabase est une [plateforme hébergée](https://app.supabase.io). Vous pouvez vous inscrire et commencer à utiliser Supabase sans rien installer. -Vous pouvez aussi [auto-héberger](https://supabase.com/docs/guides/self-hosting) et [développer localement](https://supabase.com/docs/guides/local-development). +Vous pouvez aussi [auto-héberger](https://supabase.com/docs/guides/hosting/overview) et [développer localement](https://supabase.com/docs/guides/local-development). ![Architecture](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/i18n/README.hu.md b/i18n/README.hu.md index d28319c7468..1328c350d87 100644 --- a/i18n/README.hu.md +++ b/i18n/README.hu.md @@ -47,7 +47,7 @@ A Supabase nyílt forráskódú eszközök kombinációja.A Firebase funkcióit **Jelenlegi architektúra** Supabase egy [hosztolt platform](https://app.supabase.io). Regisztrálással letöltés nélkül is elkezdheted használni. -De [hosztolhatod magadnak](https://supabase.com/docs/guides/self-hosting) és akár [fejlesztheted helyileg](https://supabase.com/docs/guides/local-development). +De [hosztolhatod magadnak](https://supabase.com/docs/guides/hosting/overview) és akár [fejlesztheted helyileg](https://supabase.com/docs/guides/local-development). ![Architektúra](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/i18n/README.ms.md b/i18n/README.ms.md index eb7a60fb183..f6c05d6bdcc 100644 --- a/i18n/README.ms.md +++ b/i18n/README.ms.md @@ -46,7 +46,7 @@ Supabase adalah gabungan alat sumber terbuka. Kami membina ciri Firebase menggun **Seni bina semasa** Supabase ialah [platform yang dihoskan](https://app.supabase.io). Anda boleh mendaftar dan mula menggunakan Supabase tanpa memasang apa-apa. -Anda juga boleh [host sendiri](https://supabase.com/docs/guides/self-hosting) dan [lokal](https://supabase.com/docs/guides/local-development). +Anda juga boleh [host sendiri](https://supabase.com/docs/guides/hosting/overview) dan [lokal](https://supabase.com/docs/guides/local-development). ![Seni bina](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/i18n/README.ta.md b/i18n/README.ta.md new file mode 100644 index 00000000000..f453b9f8776 --- /dev/null +++ b/i18n/README.ta.md @@ -0,0 +1,190 @@ +

+ +

+ +--- + +# சுபாபேஸ் + +[சுபாபேஸ்](https://supabase.com) என்பது ஒரு திறந்த மூல ஃபயர்பேஸ் மாற்றாகும். நிறுவன-தர திறந்த மூல கருவிகளைப் பயன்படுத்தி ஃபயர்பேஸ் இன் அம்சங்களை நாங்கள் உருவாக்குகிறோம். + +- [x] ஹோஸ்ட் செய்யப்பட்ட பூஸ்ட்ஜீஆர்இஎஸ் தரவுத்தளம். [ஆவணம்](https://supabase.com/docs/guides/database) +- [x] அங்கீகாரம் மற்றும் அங்கீகாரம்.. [ஆவணம்](https://supabase.com/docs/guides/auth) +- [x] தானாக உருவாக்கப்பட்ட ஏபிஐ கள். + - [x] ராஸ்ட். [ஆவணம்](https://supabase.com/docs/guides/api#rest-api) + - [x] நிகழ்நேர சந்தாக்கள். [ஆவணம்](https://supabase.com/docs/guides/api#realtime-api) + - [x] கிராப்க்யூஎல் (பீட்டா). [ஆவணம்](https://supabase.com/docs/guides/api#graphql-api) +- [x] செயல்பாடுகள். + - [x] தரவுத்தளம் செயல்பாடுகள். [ஆவணம்](https://supabase.com/docs/guides/database/functions) + - [x] விளிம்பு செயல்பாடுகள் [ஆவணம்](https://supabase.com/docs/guides/functions) +- [x] கோப்பு சேமிப்பகம். [ஆவணம்](https://supabase.com/docs/guides/storage) +- [x] டாஷ்போர்டு + +![Supabase Dashboard](https://raw.githubusercontent.com/supabase/supabase/master/www/public/images/github/supabase-dashboard.png) + +## ஆவணப்படுத்தல் + +முழு ஆவணங்களுக்கு, பார்வையிடவும் [supabase.com/docs](https://supabase.com/docs) + +எவ்வாறு பங்களிப்பது என்பதைப் பார்க்க, பார்வையிடவும் [தொடங்குதல்](./DEVELOPERS.md) + +## சமூகம் மற்றும் ஆதரவு + +- [சமூக மன்றம்](https://github.com/supabase/supabase/discussions). சிறந்த: கட்டிடம் உதவி, தரவுத்தள சிறந்த நடைமுறைகள் பற்றி விவாதம். +- [கிட்ஹப் சிக்கல்கள்](https://github.com/supabase/supabase/issues). சிறந்தது: சுபாபேஸைப் பயன்படுத்தி நீங்கள் சந்திக்கும் பிழைகள் மற்றும் பிழைகள். +- [மின்னஞ்சல் ஆதரவு](https://supabase.com/docs/support#business-support). சிறந்தது: உங்கள் தரவுத்தளம் அல்லது உள்கட்டமைப்பில் உள்ள சிக்கல்கள். +- [திஸ்கார்ட்](https://discord.supabase.com). இதற்கு சிறந்தது: உங்கள் விண்ணப்பங்களைப் பகிர்வது மற்றும் சமூகத்துடன் ஹேங்அவுட் செய்வது. + +## நிலை + +- [x] ஆல்ஃபா: நாங்கள் வாடிக்கையாளர்களின் மூடிய தொகுப்புடன் சுபாபேஸை சோதிக்கிறோம் +- [x] பொது ஆல்ஃபா: எவரும் [app.supabase.io](https://app.supabase.io) இல் பதிவு செய்யலாம், ஆனால் எங்களுக்கு எளிதாக செல்லுங்கள், ஒரு சில கின்க்ஸ் உள்ளன +- [x] பொது பீட்டா: பெரும்பாலான அல்லாத நிறுவன பயன்பாட்டு-வழக்குகளுக்கு போதுமான நிலையானது +- [ ] பொது: உற்பத்தி-தயார் + +நாங்கள் தற்போது பொது பீட்டாவில் இருக்கிறோம். முக்கிய துப்பிப்புகளைப் பற்றி அறிவிக்க இந்த ரெப்போவின் "வெளியீடுகளைப்" பாருங்கள். + +இந்த ரெப்போவைப் பாருங்கள் + +--- + +## இது எவ்வாறு செயல்படுகிறது + +சுபாபேஸ் என்பது திறந்த மூல கருவிகளின் கலவையாகும். நிறுவன-தர, திறந்த மூல தயாரிப்புகளைப் பயன்படுத்தி ஃபயர்பேஸ் இன் அம்சங்களை நாங்கள் உருவாக்குகிறோம். கருவிகள் மற்றும் சமூகங்கள் இருந்தால், ஒரு எம்ஐடி, அப்பாச்சி 2, அல்லது அதற்கு சமமான திறந்த உரிமத்துடன், நாங்கள் அந்த கருவியைப் பயன்படுத்துவோம் மற்றும் ஆதரிப்போம். கருவி இல்லை என்றால், நாமே அதை உருவாக்கி திறக்கிறோம். சுபாபேஸ் ஃபயர்பேஸின் 1-க்கு-1 மேப்பிங் அல்ல. திறந்த மூல கருவிகளைப் பயன்படுத்தி டெவலப்பர்களுக்கு ஃபயர்பேஸ் போன்ற டெவலப்பர் அனுபவத்தை வழங்குவதே எங்கள் நோக்கம். + +**கட்டிடக்கலை** + +சுபாபேஸ் ஒரு [ஹோஸ்ட் செய்யப்பட்ட தளம்](https://app.supabase.io). எதையும் நிறுவாமல் நீங்கள் பதிவுசெய்து Supabase ஐப் பயன்படுத்தத் தொடங்கலாம். +நீங்கள் [சுய ஹோஸ்ட்](https://supabase.com/docs/guides/hosting/overview) மற்றும் [உள்நாட்டில் உருவாக்க](https://supabase.com/docs/guides/local-development) ஆகியவற்றையும் செய்யலாம். + +![கட்டிடக்கலை](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) + +- [பூஸ்ட்ஜீஆர்இஎஸ்கியூஎல்](https://www.postgresql.org/) என்பது 30 ஆண்டுகளுக்கும் மேலான செயலில் உள்ள வளர்ச்சியுடன் கூடிய ஒரு பொருள்-தொடர்புடைய தரவுத்தள அமைப்பாகும், இது நம்பகத்தன்மை, அம்ச உறுதிப்பாடு மற்றும் செயல்திறனுக்கான வலுவான நற்பெயரைப் பெற்றுள்ளது. +- [நிகழ்நேரம்](https://github.com/supabase/realtime) என்பது ஒரு எலிக்சர் சேவையகமாகும், இது வெப்சாக்கெட்டுகளைப் பயன்படுத்தி பூஸ்ட்ஜீஆர்இஎஸ்கியூஎல் செருகல்கள், புதுப்பிப்புகள் மற்றும் நீக்குதல்களைக் கேட்க உங்களை அனுமதிக்கிறது. நிகழ்நேர கருத்துக் கணிப்புகள் தரவுத்தள மாற்றங்களுக்கான போஸ்ட்கிரஸின் உள்ளமைக்கப்பட்ட பிரதிபலிப்பு செயல்பாடு, ஜே.எஸ்.ஆனுக்கு மாற்றங்களை மாற்றுகிறது, பின்னர் அங்கீகரிக்கப்பட்ட வாடிக்கையாளர்களுக்கு வெப்சாக்கெட்டுகள் மூலம் ஜே.எஸ்.என் ஐ ஒளிபரப்புகிறது. +- [போஸ்ட்ஜீராஸ்ட்](http://postgrest.org/) என்பது உங்கள் பூஸ்ட்ஜீஆர்இஎஸ்கியூஎல் தரவுத்தளத்தை நேரடியாக ராஸ்ட் ஏபிஐ ஆக மாற்றும் ஒரு வலை சேவையகமாகும் +- [சேமிப்பகம்](https://github.com/supabase/storage-api) அனுமதிகளை நிர்வகிக்க போஸ்ட்கிரெஸைப் பயன்படுத்தி, S3 இல் சேமிக்கப்பட்ட கோப்புகளை நிர்வகிப்பதற்கான ராஸ்ட் இடைமுகத்தை வழங்குகிறது. +- [பூஸ்ட்ஜீஆர்இ-மெட்டா](https://github.com/supabase/postgres-meta) என்பது உங்கள் போஸ்ட்கிரேஸை நிர்வகிப்பதற்கான ஒரு ராஸ்ட் ஏபிஐ ஆகும், இது அட்டவணைகளைப் பெறவும், பாத்திரங்களைச் சேர்க்கவும், கேள்விகளை இயக்கவும் உங்களை அனுமதிக்கிறது. +- [கூட்ரூ](https://github.com/netlify/gotrue) என்பது பயனர்களை நிர்வகிப்பதற்கும் எஸ்டபிள்யூடி டோக்கன்கள் வழங்குவதற்கும் எஸ்டபிள்யூடி அடிப்படையிலான ஏபிஐ ஆகும். +- [காங்](https://github.com/Kong/kong) என்பது கிளவுட்-நேட்டிவ் ஏபிஐ நுழைவாயில் ஆகும். + +#### வாடிக்கையாளர் நூலகங்கள் + +கிளையன்ட் லைப்ரரிகளுக்கான எங்கள் அணுகுமுறை மட்டு. ஒவ்வொரு துணை நூலகமும் ஒரு வெளிப்புற அமைப்பிற்கான ஒரு முழுமையான செயலாக்கமாகும். ஏற்கனவே உள்ள கருவிகளை நாங்கள் ஆதரிக்கும் வழிகளில் இதுவும் ஒன்றாகும். + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
மொழிவாடிக்கையாளர் +அம்சம்-வாடிக்கையாளர் (சுபாபேஸ் கிளையண்டில் தொகுக்கப்பட்டுள்ளது)
சுபாபேஸ்போஸ்ட்ஜீராஸ்ட்கூட்ரூநிகழ்நேரம்சேமிப்பு
⚡️ அதிகாரி ⚡️
ஜாவாஸ்கிரிப்ட் (டைப்ஸ்கிரிப்ட்)சுபாபேஸ்-ஜேஎஸ்போஸ்ட்ஜீராஸ்ட்-ஜேஎஸ்கூட்ரூ-ஜேஎஸ்நிகழ்நேரம்-ஜேஎஸ்சேமிப்பு-ஜேஎஸ்
💚 பொதுச் சொத்துரிமை 💚
C#சுபாபேஸ்-ஸேசர்பபோஸ்ட்ஜீராஸ்ட்-ஸேசர்பகூட்ரூ-ஸேசர்பநிகழ்நேரம்-ஸேசர்பசேமிப்பு-ஸேசர்ப
டார்ட் (ஃப்ளடர்)சுபாபேஸ்-டார்ட்போஸ்ட்ஜீராஸ்ட்-டார்ட்கூட்ரூ-டார்ட்நிகழ்நேரம்-டார்ட்சேமிப்பு-டார்ட்
கோ-போஸ்ட்ஜீராஸ்ட்-கோ---
ஜாவா--கூட்ரூ-ஜாவா--
கோட்லின்-போஸ்ட்ஜீராஸ்ட்-கேடிகூட்ரூ-கேடி--
பித்தன்சுபாபேஸ்-பைபோஸ்ட்ஜீராஸ்ட்-பைகூட்ரூ-பைநிகழ்நேரம்-பை-
ராபிசுபாபேஸ்-ஆர்பிபோஸ்ட்ஜீராஸ்ட்-ஆர்பி---
ரோஸ்ட்-போஸ்ட்ஜீராஸ்ட்-ஆர்எஸ்---
சஃப்ட்சுபாபேஸ்-சஃப்ட்போஸ்ட்ஜீராஸ்ட்-சஃப்ட்கூட்ரூ-சஃப்ட்நிகழ்நேரம்-சஃப்ட்சேமிப்பு-சஃப்ட்
+ +## மொழிபெயர்ப்புகள் + +- [மொழிபெயர்ப்புகளின் பட்டியல்](/i18n/languages.md) + +## ஸ்பான்சர்கள் + +[![புதிய ஸ்பான்சர்கள்](https://user-images.githubusercontent.com/10214025/90518111-e74bbb00-e198-11ea-8f88-c9e3c1aa4b5b.png)](https://github.com/sponsors/supabase) diff --git a/i18n/README.vi-vn.md b/i18n/README.vi-vn.md index ad323e6ada1..30213af5019 100644 --- a/i18n/README.vi-vn.md +++ b/i18n/README.vi-vn.md @@ -55,7 +55,7 @@ Supabase là sự kết hợp của các công cụ mã nguồn mở. Các tính **Kiến trúc** Supabase là một [nền tảng lưu trữ cơ sở dữ liệu](https://app.supabase.io). Bạn có thể đăng ký và bắt đầu sử dụng Supabase mà không cần cài đặt bất kỳ thứ gì. -Bạn cũng có thể [tự quản lý](https://supabase.com/docs/guides/self-hosting) và [phát triển cục bộ](https://supabase.com/docs/guides/local-development). +Bạn cũng có thể [tự quản lý](https://supabase.com/docs/guides/hosting/overview) và [phát triển cục bộ](https://supabase.com/docs/guides/local-development). ![Kiến trúc](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/i18n/README.zh-cn.md b/i18n/README.zh-cn.md index 497ff586df2..982ac77cc3e 100644 --- a/i18n/README.zh-cn.md +++ b/i18n/README.zh-cn.md @@ -53,7 +53,7 @@ Supabase 是一个开源工具的组合。我们正在使用企业级的开源 **当前架构** Supabase 是一个[托管平台](https://app.supabase.io)。你可以注册并开始使用 Supabase,而无需安装任何软件。 -你也可以[自托管](https://supabase.com/docs/guides/self-hosting)和[本地开发](https://supabase.com/docs/guides/local-development)。 +你也可以[自托管](https://supabase.com/docs/guides/hosting/overview)和[本地开发](https://supabase.com/docs/guides/local-development)。 ![架构](https://supabase.com/docs/assets/images/supabase-architecture-9050a7317e9ec7efb7807f5194122e48.png) diff --git a/i18n/languages.md b/i18n/languages.md index 3c98bb5228e..4efbb0e398a 100644 --- a/i18n/languages.md +++ b/i18n/languages.md @@ -32,6 +32,7 @@ - [Spanish / Español](/i18n/README.es.md) - [Simplified Chinese / 简体中文](/i18n/README.zh-cn.md) - [Swedish / Svenska](/i18n/README.sv.md) +- [Tamil / தமிழ்](/i18n/README.ta.md) - [Thai / ไทย](/i18n/README.th.md) - [Traditional Chinese / 繁体中文](/i18n/README.zh-tw.md) - [Turkish / Türkçe](/i18n/README.tr.md) diff --git a/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.constants.ts b/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.constants.ts index 8bc077f2ce9..5917d61ef6d 100644 --- a/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.constants.ts +++ b/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.constants.ts @@ -10,12 +10,14 @@ export const chargeableProducts: ChargeableProduct[] = [ attribute: 'total_db_size_bytes', costPerUnit: 0.125, unitQuantity: 1000000000, // 1GB + pricingModel: 'max', }, { title: 'Database Egress', - attribute: 'total_db_egress_bytes', + attribute: 'total_egress_modified', costPerUnit: 0.09, unitQuantity: 1000000000, // 1GB + pricingModel: 'sum', }, ], }, @@ -28,12 +30,14 @@ export const chargeableProducts: ChargeableProduct[] = [ attribute: 'total_storage_size_bytes', costPerUnit: 0.021, unitQuantity: 1000000000, // 1GB + pricingModel: 'max', }, { title: 'Storage Egress', attribute: 'total_storage_egress', costPerUnit: 0.09, unitQuantity: 1000000000, // 1GB + pricingModel: 'sum', }, ], }, diff --git a/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.tsx b/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.tsx index 57678df3d1b..0ddc5682fe1 100644 --- a/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.tsx +++ b/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.tsx @@ -3,14 +3,14 @@ import dayjs from 'dayjs' import { Typography } from '@supabase/ui' import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler' -import { ChargeableProduct, ProductFeature } from './PAYGUsage.types' +import { ChargeableProduct, PaygStats, ProductFeature } from './PAYGUsage.types' import { deriveProductCost } from './PAYGUsage.utils' import { chargeableProducts } from './PAYGUsage.constants' import { DATE_FORMAT } from 'lib/constants' import { Dictionary } from '@supabase/grid' interface Props { - paygStats: Dictionary + paygStats: PaygStats } const PAYGUsage: FC = ({ paygStats }) => { @@ -21,6 +21,7 @@ const PAYGUsage: FC = ({ paygStats }) => {
{chargeableProducts.map((product: ChargeableProduct) => { const productCost = deriveProductCost(paygStats, product) + return (
= ({ paygStats }) => { endDate={endDate} interval="1d" highlight="maximum" - defaultChartStyle="line" + defaultChartStyle="bar" customDateFormat={'MMM D, YYYY'} />
diff --git a/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.types.ts b/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.types.ts index a1497f6c05a..64f953da2c3 100644 --- a/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.types.ts +++ b/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.types.ts @@ -9,4 +9,9 @@ export interface ProductFeature { attribute: string costPerUnit: number unitQuantity: number + pricingModel: PricingModel } + +export type PaygStats = { [key: string]: { sum: number; max: number } } + +export type PricingModel = 'sum' | 'max' diff --git a/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.utils.ts b/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.utils.ts index aecd59dd324..ff60a4905ed 100644 --- a/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.utils.ts +++ b/studio/components/interfaces/Billing/PAYGUsage/PAYGUsage.utils.ts @@ -1,8 +1,18 @@ -import { sum } from 'lodash' -import { ChargeableProduct, ProductFeature } from './PAYGUsage.types' +import { get, maxBy, sum } from 'lodash' +import { ChargeableProduct, PaygStats, ProductFeature } from './PAYGUsage.types' -export const deriveFeatureCost = (paygStats: any, feature: ProductFeature) => { - const maximumValueOfTheMonth = paygStats?.[feature.attribute] ?? 0 +/** + * + * Derives the most appropriate cost + * depending on what kind of product feature we are displaying cost for. + * + * @param paygStats + * @param feature + * + * @returns number + */ +export const deriveFeatureCost = (paygStats: PaygStats | undefined, feature: ProductFeature) => { + const maximumValueOfTheMonth = paygStats?.[feature.attribute]?.[feature.pricingModel] ?? 0 return (maximumValueOfTheMonth / feature.unitQuantity) * feature.costPerUnit } diff --git a/studio/components/interfaces/Billing/Subscription/Subscription.tsx b/studio/components/interfaces/Billing/Subscription/Subscription.tsx index 33e6fcdf9f0..437b6bc4381 100644 --- a/studio/components/interfaces/Billing/Subscription/Subscription.tsx +++ b/studio/components/interfaces/Billing/Subscription/Subscription.tsx @@ -12,11 +12,12 @@ import CostBreakdownRow from './CostBreakdownRow' import { StripeSubscription } from './Subscription.types' import { deriveFeatureCost, deriveProductCost } from '../PAYGUsage/PAYGUsage.utils' import { chargeableProducts } from '../PAYGUsage/PAYGUsage.constants' +import { PaygStats } from '../PAYGUsage/PAYGUsage.types' interface Props { project: any subscription: StripeSubscription - paygStats?: Dictionary + paygStats: PaygStats | undefined loading?: boolean showProjectName?: boolean currentPeriodStart: number @@ -132,12 +133,16 @@ const Subscription: FC = ({ {isPayg && chargeableProducts.map((product) => product.features.map((feature) => { - const cost = deriveFeatureCost(paygStats, feature).toFixed(3) + const derivedCost = deriveFeatureCost(paygStats, feature) + const cost = derivedCost.toFixed(3) + return ( diff --git a/studio/components/interfaces/Functions/FunctionsListItem.tsx b/studio/components/interfaces/Functions/FunctionsListItem.tsx index afb149f81b4..db721e96c4a 100644 --- a/studio/components/interfaces/Functions/FunctionsListItem.tsx +++ b/studio/components/interfaces/Functions/FunctionsListItem.tsx @@ -14,8 +14,6 @@ interface Props { const FunctionsListItem: FC = ({ function: item }) => { const { ui } = useStore() - - console.log('_function', item) const is_functionConfirmed = item.email_confirmed_at || item.phone_confirmed_at const router = useRouter() diff --git a/studio/components/interfaces/Home/Home.constants.ts b/studio/components/interfaces/Home/Home.constants.ts index d49bcb8907e..f2cab0a2e69 100644 --- a/studio/components/interfaces/Home/Home.constants.ts +++ b/studio/components/interfaces/Home/Home.constants.ts @@ -1,6 +1,6 @@ export const CLIENT_LIBRARIES = [ { - language: 'Javascript', + language: 'JavaScript', officialSupport: true, releaseState: undefined, docsUrl: 'https://supabase.com/docs/reference/javascript/installing', diff --git a/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx b/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx index fba67f8d2b9..2bae2c671a6 100644 --- a/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx +++ b/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx @@ -1,11 +1,6 @@ +import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings' import { FC } from 'react' -import { Typography } from '@supabase/ui' - import GetStartedPanel from './GetStartedPanel' -import { - DisplayApiSettings, - DisplayConfigSettings, -} from 'components/to-be-cleaned/DisplayProjectSettings' interface Props {} diff --git a/studio/components/interfaces/Settings/Logs/LogTable.tsx b/studio/components/interfaces/Settings/Logs/LogTable.tsx index 710616fe345..33bad467f84 100644 --- a/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -1,15 +1,6 @@ import dayjs from 'dayjs' import { useEffect, useState, useMemo } from 'react' -import { - Alert, - Button, - IconDownloadCloud, - IconEye, - IconEyeOff, - IconLoader, - Input, - Typography, -} from '@supabase/ui' +import { Alert, Button, IconEye, IconEyeOff, Input } from '@supabase/ui' import DataGrid from '@supabase/react-data-grid' import LogSelection from './LogSelection' @@ -262,52 +253,69 @@ const LogTable = ({ !isLoading ? ( <>
-
-
-
-
-
- - - + {!error && ( +
+ <> +
+
+
+
+ + + +
+
-
+
+

No results

+

+ Try another search, or adjusting the filters +

+
+
-
-

No results

-

- Try another search, or adjusting the filters -

-
-
+ )} {error && (
void + to: string + from: string + onChange: React.ComponentProps["onChange"] + helpers: DatetimeHelper[] } - -const HELPERS = [ - { text: 'Last hour', value: '1_hour' }, - { text: 'Last 3 hours', value: '3_hour' }, - { text: 'Last day', value: '1_day' }, -] -const DEFAULT_HELPER_VALUE = HELPERS[0].value - -const DatePickers: React.FC = ({ to, from, onChange }) => { - const [helperValue, setHelperValue] = useState('') - +const DatePickers: React.FC = ({ to, from, onChange, helpers }) => { + const defaultHelper = getDefaultHelper(helpers) + const [helperValue, setHelperValue] = useState(to || from ? '' : defaultHelper.text) const handleHelperChange = (newValue: string) => { setHelperValue(newValue) - const toDate = new Date() - const fromDate = { - '1_hour': new Date(toDate.getTime() - 60 * 60 * 1000), - '3_hour': new Date(toDate.getTime() - 3 * 60 * 60 * 1000), - '1_day': new Date(toDate.getTime() - 24 * 60 * 60 * 1000), - }[newValue] - if (onChange) onChange({ to: toDate.toISOString(), from: fromDate?.toISOString() as string }) + const selectedHelper = helpers.find((h) => h.text === newValue) + if (onChange && selectedHelper) { + onChange({ to: selectedHelper.calcTo(), from: selectedHelper.calcFrom() }) + } } - const selectedHelper = HELPERS.find((h) => h.value === (helperValue || DEFAULT_HELPER_VALUE)) + + const selectedHelper = helpers.find((helper) => { + if (to === helper.calcTo() && from === helper.calcFrom()) { + return true + } else if (helper.text === helperValue) { + return true + } else { + return false + } + }) + useEffect(() => { + if (selectedHelper && helperValue !== selectedHelper.text) { + setHelperValue(selectedHelper.text) + } + }, [selectedHelper]) + return (
= ({ to, from, onChange }) => { align="start" overlay={ <> - - {HELPERS.map((helper) => ( - + + {helpers.map((helper) => ( + {helper.text} ))} @@ -57,18 +60,27 @@ const DatePickers: React.FC = ({ to, from, onChange }) => { icon={} className="rounded-r-none" > - {selectedHelper?.text} + {selectedHelper?.text || defaultHelper.text} { + triggerButtonType={selectedHelper ? 'default' : 'secondary'} + onChange={(value) => { setHelperValue('') if (onChange) onChange(value) }} to={!helperValue ? to : undefined} from={!helperValue ? from : undefined} + renderFooter={({ to, from }) => { + if (to && from && Math.abs(dayjs(from).diff(dayjs(to), 'day')) > LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD) { + return ( + + Large ranges may result in memory errors for big projects. + + ) + } + }} />
) diff --git a/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx b/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx index 8bd1cf7e361..fed7ea84ebb 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx +++ b/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx @@ -15,7 +15,6 @@ const SavedQueriesItem: FC = ({ item }: Props) => { const router = useRouter() const { ref } = router.query - console.log('item in component', item) return ( <> @@ -47,7 +46,9 @@ const SavedQueriesItem: FC = ({ item }: Props) => { type="alternative" iconRight={} onClick={() => - router.push(`/project/${ref}/logs-explorer?sql=${encodeURIComponent(item.content.sql)}`) + router.push( + `/project/${ref}/logs-explorer?sql=${encodeURIComponent(item.content.sql)}` + ) } > Run diff --git a/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/studio/components/interfaces/Settings/Logs/Logs.constants.ts index 75577262633..17ed2ce2dd8 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -1,4 +1,7 @@ -import { FilterTableSet, LogTemplate } from '.' +import dayjs from 'dayjs' +import { DatetimeHelper, FilterTableSet, LogTemplate } from '.' + +export const LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD = 4 export const TEMPLATES: LogTemplate[] = [ { @@ -185,10 +188,8 @@ export const LOG_TYPE_LABEL_MAPPING: { [k: string]: string } = { database: 'Database', } - - const _SQL_FILTER_COMMON = { - 'search_query': (value: string) => `regexp_contains(event_message, '${value}')` + search_query: (value: string) => `regexp_contains(event_message, '${value}')`, } export const SQL_FILTER_TEMPLATES: any = { @@ -424,3 +425,43 @@ export const LOGS_TAILWIND_CLASSES = { log_selection_x_padding: 'px-8', space_y: 'px-6', } + +export const PREVIEWER_DATEPICKER_HELPERS: DatetimeHelper[] = [ + { + text: 'Last hour', + calcFrom: () => dayjs().subtract(1, 'hour').startOf('hour').toISOString(), + calcTo: () => '', + }, + { + text: 'Last 3 hours', + calcFrom: () => dayjs().subtract(3, 'hour').startOf('hour').toISOString(), + calcTo: () => '', + }, + { + text: 'Last day', + calcFrom: () => dayjs().subtract(1, 'day').startOf('day').toISOString(), + calcTo: () => '', + default: true, + }, +] +export const EXPLORER_DATEPICKER_HELPERS: DatetimeHelper[] = [ + { + text: 'Last day', + calcFrom: () => dayjs().subtract(1, 'day').startOf('day').toISOString(), + calcTo: () => '', + default: true, + }, + { + text: 'Last 3 days', + calcFrom: () => dayjs().subtract(3, 'day').startOf('day').toISOString(), + calcTo: () => '', + }, + { + text: 'Last 7 days', + calcFrom: () => dayjs().subtract(7, 'day').startOf('day').toISOString(), + calcTo: () => '', + }, +] + +export const getDefaultHelper = (helpers: DatetimeHelper[]) => + helpers.find((helper) => helper.default) || helpers[0] diff --git a/studio/components/interfaces/Settings/Logs/Logs.types.ts b/studio/components/interfaces/Settings/Logs/Logs.types.ts index 2d2036353d8..52ff5db6b35 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -1,19 +1,29 @@ +import React from "react" + interface Metadata { [key: string]: string | number | Object | Object[] } -export type LogSearchCallback = (filters: { query: string; to?: string; from?: string; fromMicro?: number, toMicro?: number }) => void +export type DatePickerToFrom = { to: string | null; from: string | null } + +export type LogSearchCallback = ( + filters: { + query: string + } & DatePickerToFrom +) => void + +export interface LogsWarning { + text: string | React.ReactNode + link?: string + linkText?: string +} export interface LogsEndpointParams { // project ref project: string - // micro unix timestamp - timestamp_start?: string - // micro timestamp - timestamp_end?: string - period_start?: string - period_end?: string - sql: string - rawSql: string + iso_timestamp_start?: string + iso_timestamp_end?: string + sql?: string + rawSql?: string } export interface LogData { @@ -65,7 +75,6 @@ export interface FilterObject { te?: string } - export interface FilterSet { label: string key: string @@ -91,3 +100,10 @@ export type Override = { key: string value: string | string[] | undefined } + +export interface DatetimeHelper { + text: string + calcTo: () => string + calcFrom: () => string + default?: boolean +} diff --git a/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 3dac12eca1b..13ad0208445 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -1,37 +1,45 @@ -import { get } from "lodash" -import { Filters, LogsTableName, SQL_FILTER_TEMPLATES } from "." +import { Filters, LogsTableName, SQL_FILTER_TEMPLATES } from '.' +import dayjs from 'dayjs' +import { get } from 'lodash' + +/** + * Convert a micro timestamp from number/string to iso timestamp + */ +export const unixMicroToIsoTimestamp = (unix: string | number): string => { + return dayjs.unix(Number(unix) / 1000).toISOString() +} /** * Recursively retrieve all nested object key paths. - * + * * TODO: move to utils - * + * * @param obj any object * @param parent a string representing the parent key * @returns string[] all dot paths for keys. */ const getDotKeys = (obj: { [k: string]: unknown }, parent?: string): string[] => { - const keys = Object.keys(obj).filter(k => obj[k]) - return keys.flatMap(k => { - const currKey = parent ? `${parent}.${k}` : k - if (typeof obj[k] === 'object') { - return getDotKeys(obj[k] as any, currKey) - } else { - return [currKey] - } - }) + const keys = Object.keys(obj).filter((k) => obj[k]) + return keys.flatMap((k) => { + const currKey = parent ? `${parent}.${k}` : k + if (typeof obj[k] === 'object') { + return getDotKeys(obj[k] as any, currKey) + } else { + return [currKey] + } + }) } /** * Root keys in the filter object are considered to be AND filters. * Nested keys under a root key are considered to be OR filters. - * + * * For example: * ``` * {my_value: 'something', nested: {id: 123, test: 123 }} * ``` * This would be converted into `WHERE (my_value = 'something') and (id = 123 or test = 123) - * + * * The template of the filter determines the actual filter statement. If no template is provided, a generic equality statement will be used. * This only applies for root keys of the filter. * For example: @@ -39,60 +47,71 @@ const getDotKeys = (obj: { [k: string]: unknown }, parent?: string): string[] => * {'my.nested.value': 123} * ``` * with no template, it will be converted into `WHERE (my.nested.value = 123) - * - * @returns a whewhere statement with WHERE clause. + * + * @returns a where statement with WHERE clause. */ const _genWhereStatement = (table: LogsTableName, filters: Filters) => { - const keys = Object.keys(filters) - const filterTemplates = SQL_FILTER_TEMPLATES[table] - const _resolveTemplateToStatement = (dotKey: string): string | null => { - const template = filterTemplates[dotKey] - const value = get(filters, dotKey) - if (value !== undefined && typeof template === 'function') { - return template(value) - } else if (template === undefined) { - // resolve unknwon filters (possibly from filter overrides) - // no template, set a default - if (typeof value === 'string') { - return `${dotKey} = '${value}'` - } else { - return `${dotKey} = ${value}` - } - } else if (value === undefined && typeof template === 'function') { - return null - } else { - return template - } - } - - const statement = keys.map(rootKey => { - if (typeof filters[rootKey] === 'object') { - // join all statements with an OR - const nestedStatement = getDotKeys(filters[rootKey] as Filters, rootKey).map(_resolveTemplateToStatement).filter(Boolean).join(" or ") - return `(${nestedStatement})` - } else { - const nestedStatement = _resolveTemplateToStatement(rootKey) - if (nestedStatement === null) return null - return `(${nestedStatement})` - } - - }).filter(Boolean) - // join all root statements with AND - .join(" and ") - - if (statement) { - return 'where ' + statement + const keys = Object.keys(filters) + const filterTemplates = SQL_FILTER_TEMPLATES[table] + const _resolveTemplateToStatement = (dotKey: string): string | null => { + const template = filterTemplates[dotKey] + const value = get(filters, dotKey) + if (value !== undefined && typeof template === 'function') { + return template(value) + } else if (template === undefined) { + // resolve unknwon filters (possibly from filter overrides) + // no template, set a default + if (typeof value === 'string') { + return `${dotKey} = '${value}'` + } else { + return `${dotKey} = ${value}` + } + } else if (value === undefined && typeof template === 'function') { + return null + } else if (template && value === false) { + // template present, but value is false + return null } else { - return '' + return template } + } + + const statement = keys + .map((rootKey) => { + if (typeof filters[rootKey] === 'object') { + // join all statements with an OR + const nestedStatements = getDotKeys(filters[rootKey] as Filters, rootKey) + .map(_resolveTemplateToStatement) + .filter(Boolean) + + if (nestedStatements.length > 0) { + return `(${nestedStatements.join(' or ')})` + } else { + return null + } + } else { + const nestedStatement = _resolveTemplateToStatement(rootKey) + if (nestedStatement === null) return null + return `(${nestedStatement})` + } + }) + .filter(Boolean) + // join all root statements with AND + .join(' and ') + + if (statement) { + return 'where ' + statement + } else { + return '' + } } export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { - const where = _genWhereStatement(table, filters) + const where = _genWhereStatement(table, filters) - switch (table) { - case 'edge_logs': - return `select id, timestamp, event_message, metadata, request, response, request.method, request.path, response.status_code + switch (table) { + case 'edge_logs': + return `select id, timestamp, event_message, metadata, request, response, request.method, request.path, response.status_code from ${table} cross join unnest(metadata) as m cross join unnest(m.request) as request @@ -100,27 +119,27 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { ${where} limit 100 ` - break + break - case 'postgres_logs': - return `select postgres_logs.timestamp, id, event_message, metadata, metadataparsed.error_severity from ${table} + case 'postgres_logs': + return `select postgres_logs.timestamp, id, event_message, metadata, metadataparsed.error_severity from ${table} cross join unnest(metadata) as m cross join unnest(m.parsed) as metadataparsed ${where} limit 100 ` - break + break - case 'function_logs': - return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.level, metadata from ${table} + case 'function_logs': + return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.level, metadata from ${table} cross join unnest(metadata) as metadata ${where} limit 100 ` - break + break - case 'function_edge_logs': - return `select id, ${table}.timestamp, event_message, response.status_code, response, request, request.method, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table} + case 'function_edge_logs': + return `select id, ${table}.timestamp, event_message, response.status_code, response, request, request.method, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table} cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request @@ -128,8 +147,8 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { limit 100 ` - default: - return "" - break - } -} \ No newline at end of file + default: + return '' + break + } +} diff --git a/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index acf657d4900..e99ad93ee2a 100644 --- a/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useReducer, useState } from 'react' +import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { Typography, IconAlertCircle, IconRewind, Button, Card, Input } from '@supabase/ui' @@ -10,15 +10,13 @@ import { LogsTableName, QueryType, LogEventChart, - FilterObject, Filters, + unixMicroToIsoTimestamp, } from 'components/interfaces/Settings/Logs' -import dayjs from 'dayjs' import useLogsPreview from 'hooks/analytics/useLogsPreview' import PreviewFilterPanel from 'components/interfaces/Settings/Logs/PreviewFilterPanel' import { LOGS_TABLES } from './Logs.constants' -import { Override } from './Logs.types' import ShimmerLine from 'components/ui/ShimmerLine' import LoadingOpacity from 'components/ui/LoadingOpacity' @@ -47,29 +45,26 @@ export const LogsPreviewer: React.FC = ({ tableName, }) => { const router = useRouter() - const { s, te, ts } = router.query + const { s, ite, its } = router.query const [showChart, setShowChart] = useState(true) const table = !tableName ? LOGS_TABLES[queryType] : tableName const [ - { error, logData, params, newCount, filters, isLoading, oldestTimestamp }, - { loadOlder, setFilters, refresh, setTo, setFrom }, + { error, logData, params, newCount, filters, isLoading }, + { loadOlder, setFilters, refresh, setParams }, ] = useLogsPreview(projectRef as string, table, filterOverride) useEffect(() => { setFilters((prev) => ({ ...prev, search_query: s as string })) - if (te) { - setTo(te as string) - } else { - setTo('') + if (ite || its) { + setParams((prev) => ({ + ...prev, + iso_timestamp_start: (its || '') as string, + iso_timestamp_end: (ite || '') as string, + })) } - if (ts) { - setFrom(ts as string) - } else { - setFrom('') - } - }, [s, te, ts]) + }, [s, ite, its]) const onSelectTemplate = (template: LogTemplate) => { setFilters((prev: any) => ({ ...prev, search_query: template.searchString })) @@ -81,33 +76,27 @@ export const LogsPreviewer: React.FC = ({ pathname: router.pathname, query: { ...router.query, - te: undefined, - ts: undefined, + ite: undefined, + its: undefined, // ...whereFilters, }, }) } - const handleSearch: LogSearchCallback = async ({ query, to, from, fromMicro, toMicro }) => { - let toValue, fromValue - if (to || toMicro) { - toValue = toMicro ? toMicro : dayjs(to).valueOf() * 1000 - - setTo(String(toValue)) - } - if (from || fromMicro) { - fromValue = fromMicro ? fromMicro : dayjs(from).valueOf() * 1000 - setFrom(String(fromValue)) - } - setFilters((prev) => ({ ...prev, search_query: query || '' })) - + const handleSearch: LogSearchCallback = async ({ query = '', to, from }) => { + setParams((prev) => ({ + ...prev, + iso_timestamp_start: from || prev.iso_timestamp_start || '', + iso_timestamp_end: to || prev.iso_timestamp_end || '', + })) + setFilters((prev) => ({ ...prev, search_query: query })) router.push({ pathname: router.pathname, query: { ...router.query, s: query || '', - ts: fromValue, - te: toValue, + its: from || its || '', + ite: to || ite || '', }, }) } @@ -123,18 +112,16 @@ export const LogsPreviewer: React.FC = ({ onRefresh={handleRefresh} onSearch={handleSearch} defaultSearchValue={filters.search_query as string} - defaultToValue={ - params.timestamp_end ? dayjs(Number(params.timestamp_end) / 1000).toISOString() : '' - } - defaultFromValue={ - params.timestamp_start - ? dayjs(Number(params.timestamp_start) / 1000).toISOString() - : oldestTimestamp - ? dayjs(Number(oldestTimestamp) / 1000).toISOString() - : '' - } + defaultToValue={params.iso_timestamp_end} + defaultFromValue={params.iso_timestamp_start} onExploreClick={() => { - router.push(`/project/${projectRef}/logs-explorer?q=${encodeURIComponent(params.rawSql)}`) + router.push( + `/project/${projectRef}/logs-explorer?q=${encodeURIComponent( + params.rawSql || '' + )}&its=${encodeURIComponent(params.iso_timestamp_start || '')}&ite=${encodeURIComponent( + params.iso_timestamp_end || '' + )}` + ) }} onSelectTemplate={onSelectTemplate} filters={filters} @@ -157,7 +144,8 @@ export const LogsPreviewer: React.FC = ({ { - handleSearch({ query: filters.search_query as string, toMicro: timestampMicro }) + const to = unixMicroToIsoTimestamp(timestampMicro) + handleSearch({ query: filters.search_query as string, to, from: null }) }} /> )} diff --git a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 91d60213a50..8f4d6a1e93a 100644 --- a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -1,6 +1,24 @@ -import { Button, Dropdown, Typography, IconChevronDown, IconPlay } from '@supabase/ui' +import { + Button, + Dropdown, + Typography, + IconChevronDown, + IconPlay, + Badge, + Popover, + Alert, +} from '@supabase/ui' import Flag from 'components/ui/Flag/Flag' -import { LogsTableName, LOGS_SOURCE_DESCRIPTION, LogTemplate } from '.' +import { + EXPLORER_DATEPICKER_HELPERS, + LogsTableName, + LogsWarning, + LOGS_SOURCE_DESCRIPTION, + LogTemplate, +} from '.' +import DatePickers from './Logs.DatePickers' +import Link from 'next/link' + interface Props { templates?: LogTemplate[] onSelectTemplate: (template: LogTemplate) => void @@ -10,6 +28,10 @@ interface Props { onSave?: () => void hasEditorValue: boolean isLoading: boolean + onDateChange: React.ComponentProps['onChange'] + defaultTo: string + defaultFrom: string + warnings: LogsWarning[] } const LogsQueryPanel: React.FC = ({ @@ -21,6 +43,10 @@ const LogsQueryPanel: React.FC = ({ onSave, onSelectSource, isLoading, + defaultFrom, + defaultTo, + onDateChange, + warnings, }) => (
+
0 ? 'opacity-100' : 'invisible w-0 h-0 opacity-0' + }`} + > + +
+ {warnings.map((warning, index) => ( +

+ {warning.text}{' '} + {warning.link && ( + {warning.linkText || 'View'} + )} +

+ ))} +
+ + } + > + + {warnings.length} {warnings.length > 1 ? 'warnings' : 'warning'} + +
+
+
diff --git a/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx b/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx index 47cd0ce3ef8..ccb80f13030 100644 --- a/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx @@ -9,7 +9,7 @@ import { IconEye, IconEyeOff, } from '@supabase/ui' -import { Filters, LogSearchCallback, LogTemplate } from '.' +import { Filters, LogSearchCallback, LogTemplate, PREVIEWER_DATEPICKER_HELPERS } from '.' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' import { FILTER_OPTIONS, LogsTableName } from './Logs.constants' @@ -101,9 +101,8 @@ const PreviewFilterPanel: FC = ({ Refresh ) - - const handleSearch = (partial: Partial<{ query: string; to: string; from: string }>) => - onSearch({ query: search, ...partial }) + const handleSearch = (partial: Partial[0]>) => + onSearch({ query: search, to: partial?.to || null, from: partial?.from || null }) return (
= ({ { - handleSearch(e) - }} + onChange={handleSearch} to={defaultToValue} from={defaultFromValue} + helpers={PREVIEWER_DATEPICKER_HELPERS} />
diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx index c7f02815118..460b4fb811d 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnDefaultValue.tsx @@ -1,17 +1,18 @@ import React, { FC } from 'react' import { isNil } from 'lodash' import { Select } from '@supabase/ui' +import { PostgresType } from '@supabase/postgres-meta' import InputWithSuggestions from './InputWithSuggestions' import { POSTGRES_DATA_TYPES } from '../SidePanelEditor.constants' -import { ColumnField, EnumType } from '../SidePanelEditor.types' +import { ColumnField } from '../SidePanelEditor.types' import { Suggestion } from './ColumnEditor.types' import { getSelectedEnumValues } from './ColumnEditor.utils' import { typeExpressionSuggestions } from './ColumnEditor.constants' interface Props { columnFields: ColumnField - enumTypes: EnumType[] + enumTypes: PostgresType[] onUpdateField: (changes: Partial) => void } @@ -27,6 +28,7 @@ const ColumnDefaultValue: FC = ({ isNil(columnFields.format) && !POSTGRES_DATA_TYPES.includes(columnFields.format) if (isUserDefinedEnum) { + const enumValues = getSelectedEnumValues(columnFields.format, enumTypes) return ( - {x.tags?.split(',').map((x: any, i: number) => ( - - {x} - - ))} - {x.tags === 'service_role' && ( - <> - {'secret'} - - )} - {x.tags === 'anon' && {'public'}} - - } - readOnly - copy={isNotUpdatingJwtSecret} - className="input-mono" - disabled - reveal={x.tags !== 'anon' && isNotUpdatingJwtSecret} - value={ - jwtSecretUpdateStatus === JwtSecretUpdateStatus.Failed - ? 'JWT secret update failed, new API key may have issues' - : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating - ? 'Updating JWT secret...' - : x.api_key - } - onChange={() => {}} - descriptionText={ - x.tags === 'service_role' - ? 'This key has the ability to bypass Row Level Security. Never share it publicly.' - : 'This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies.' - } - /> - - )) - )} - - ) -} - -export const DisplayConfigSettings = () => { - const router = useRouter() - const { ref } = router.query - - const { data, error }: any = useSWR(`${API_URL}/props/project/${ref}/settings`, get) - const { - isError: isJwtSecretUpdateStatusError, - isLoading: isJwtSecretUpdateStatusLoading, - jwtSecretUpdateStatus, - }: any = useJwtSecretUpdateStatus(ref) - - const isNotUpdatingJwtSecret = - jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - - if (!data || isJwtSecretUpdateStatusLoading) - return ( - - - {error || isJwtSecretUpdateStatusError ? ( -
- - - - - Failed to fetch project configuration - -
- ) : ( -
- {/* @ts-ignore */} - -
- )} -
-
- ) - - // Get the API service - const jwtSecret = data?.project.jwt_secret ?? '' - const apiService = (data?.services ?? []).find((x: any) => x.app.id == API_SERVICE_ID) - const apiConfig = apiService.app_config - - return ( -
- - Project Configuration - - } - > - - - - - - - -
- ) -} - -const ApiContentWrapper: FC = ({ children }) => { - return ( - -
Project API keys
-

- Your API is secured behind an API gateway which requires an API Key for every request. -
- You can use the keys below to use Supabase client libraries. -

-
- } - > - {children} - - ) -} - -const ConfigContentWrapper: FC = ({ children }) => { - return ( - -
Project Configuration
-
- } - > - {children} - - ) -} diff --git a/studio/components/to-be-cleaned/Panel.tsx b/studio/components/to-be-cleaned/Panel.tsx index 59ffd62aa81..9ee51559ef7 100644 --- a/studio/components/to-be-cleaned/Panel.tsx +++ b/studio/components/to-be-cleaned/Panel.tsx @@ -22,7 +22,7 @@ function Panel(props: any) { className={` border border-panel-border-light dark:border-panel-border-dark shadow-sm overflow-hidden - rounded rounded-md ${props.noMargin ? '' : 'mb-8'} ${props.className}`} + rounded-md ${props.noMargin ? '' : 'mb-8'} ${props.className}`} > {props.title && (
diff --git a/studio/components/to-be-cleaned/Storage/README.md b/studio/components/to-be-cleaned/Storage/README.md new file mode 100644 index 00000000000..a3b03939136 --- /dev/null +++ b/studio/components/to-be-cleaned/Storage/README.md @@ -0,0 +1,6 @@ +## What to clean for Storage + +- Convert files from javascript to typescript +- Not use mobx store for UI related states, these could simply be done via react's useState or useReducer + - Involves some heavy surgery nonetheless as 90% of the explorer logic is written a mobx store StorageExplorerStore + - If anyone is able to get to it before I do, please feel free to take this on and perhaps even write it more simply 🙏 diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js index 57b3d45bda1..3123b00ef11 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.js @@ -12,8 +12,6 @@ const FileExplorer = ({ openedFolders = [], selectedItems = [], selectedFilePreview = {}, - isSearching = false, - itemSearchString = '', onCheckItem = () => {}, onSelectItemDelete = () => {}, onSelectItemRename = () => {}, @@ -31,6 +29,7 @@ const FileExplorer = ({ onSelectCreateFolder = () => {}, onChangeView = () => {}, onChangeSortBy = () => {}, + onColumnLoadMore = () => {}, }) => { const fileExplorerRef = useRef(null) @@ -93,6 +92,7 @@ const FileExplorer = ({ onSelectItemMove={onSelectItemMove} onSelectAllItemsInColumn={onSelectAllItemsInColumn} onSelectColumnEmptySpace={onSelectColumnEmptySpace} + onColumnLoadMore={onColumnLoadMore} /> ))}
@@ -106,8 +106,6 @@ const FileExplorer = ({ column={columns[columns.length - 1]} selectedItems={selectedItems} selectedFilePreview={selectedFilePreview} - isSearching={isSearching} - itemSearchString={itemSearchString} onCheckItem={onCheckItem} onSelectFile={onSelectFile} onRenameFile={onRenameFile} @@ -122,6 +120,7 @@ const FileExplorer = ({ onSelectItemMove={onSelectItemMove} onSelectAllItemsInColumn={onSelectAllItemsInColumn} onSelectColumnEmptySpace={onSelectColumnEmptySpace} + onColumnLoadMore={onColumnLoadMore} /> )} diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js index 8fbc9855296..727e53c17a4 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.js @@ -56,8 +56,6 @@ const FileExplorerColumn = ({ openedFolders = [], selectedItems = [], selectedFilePreview = {}, - isSearching = false, - itemSearchString = '', onCheckItem = () => {}, onSelectItemDelete = () => {}, onSelectItemRename = () => {}, @@ -72,6 +70,7 @@ const FileExplorerColumn = ({ onCreateFolder = () => {}, onSelectAllItemsInColumn = () => {}, onSelectColumnEmptySpace = () => {}, + onColumnLoadMore = () => {}, }) => { const [isDraggedOver, setIsDraggedOver] = useState(false) const fileExplorerColumnRef = useRef(null) @@ -93,12 +92,7 @@ const FileExplorerColumn = ({ (item) => item.type === STORAGE_ROW_TYPES.FILE ) - const columnItems = isSearching - ? column.items.filter((item) => - item.name.toLowerCase().includes(itemSearchString.toLowerCase()) - ) - : column.items - + const columnItems = column.items const columnItemsSize = sum(columnItems.map((item) => get(item, ['metadata', 'size'], 0))) const { show } = useContextMenu() @@ -207,8 +201,7 @@ const FileExplorerColumn = ({
@@ -239,21 +232,12 @@ const FileExplorerColumn = ({ onSelectItemMove, }} ItemComponent={FileExplorerRow} - getItemSize={(index) => { - if (index !== 0 && index === columnItems.length) return 85 - return 37 - }} - // Scaffold props for infinite loading - hasNextPage={false} - isLoadingNextPage={false} - onLoadNextPage={() => {}} + getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)} + hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems} + isLoadingNextPage={column.isLoadingMoreItems} + onLoadNextPage={() => onColumnLoadMore(index, column)} /> - {/* Search empty state */} - {isSearching && columnItems.length === 0 && column.items.length > 0 && ( -
No results found based on your search
- )} - {/* Drag drop upload CTA for when column is empty */} {column.items.length === 0 && column.status !== STORAGE_ROW_STATUS.LOADING && (
diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.js b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.js index 7c4ed36deed..f00f270de55 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.js +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.js @@ -180,7 +180,7 @@ const FileExplorerRow = ({
{ addNewFolderPlaceholder, addNewFolder, fetchFolderContents, + fetchMoreFolderContents, fetchFoldersByPath, renameFolder, deleteFolder, @@ -72,6 +73,22 @@ const StorageExplorer = observer(({ bucket }) => { // Requires a fixed height to ensure that explorer is constrained to the viewport const fileExplorerHeight = window.innerHeight - 122 + useEffect(async () => { + const currentFolderIdx = openedFolders.length - 1 + const currentFolder = openedFolders[currentFolderIdx] + + if (itemSearchString) { + await fetchFolderContents( + currentFolder.id, + currentFolder.name, + currentFolderIdx, + itemSearchString + ) + } else if (currentFolder) { + await fetchFolderContents(currentFolder.id, currentFolder.name, currentFolderIdx) + } + }, [itemSearchString]) + useEffect(() => { // Load user preferences (view and sort) loadExplorerPreferences() @@ -243,7 +260,9 @@ const StorageExplorer = observer(({ bucket }) => { const onToggleSearch = (bool) => { setIsSearching(bool) - if (bool === false) setItemSearchString('') + if (bool === false) { + setItemSearchString('') + } } return ( @@ -289,8 +308,6 @@ const StorageExplorer = observer(({ bucket }) => { openedFolders={openedFolders} selectedItems={selectedItems} selectedFilePreview={selectedFilePreview} - isSearching={isSearching} - itemSearchString={itemSearchString} onCheckItem={onCheckItem} onSelectFile={onSelectFile} onRenameFile={onRenameFile} @@ -308,6 +325,9 @@ const StorageExplorer = observer(({ bucket }) => { onSelectCreateFolder={onSelectCreateFolder} onChangeView={onChangeView} onChangeSortBy={onChangeSortBy} + onColumnLoadMore={(index, column) => + fetchMoreFolderContents(index, column, itemSearchString) + } />

Based on the operations you have selected, you can use any of the highlighted functions in - the supabase-js Javascript library + the supabase-js JavaScript library

diff --git a/studio/components/ui/DatePicker/DatePicker.tsx b/studio/components/ui/DatePicker/DatePicker.tsx index 3b53c88c482..ce1d7cf7874 100644 --- a/studio/components/ui/DatePicker/DatePicker.tsx +++ b/studio/components/ui/DatePicker/DatePicker.tsx @@ -1,9 +1,5 @@ -import React, { useState, forwardRef, useEffect } from 'react' - +import React, { useState, useEffect } from 'react' import DatePicker from 'react-datepicker' - -// import dayjs from 'dayjs' - import { Button, Popover, @@ -11,40 +7,31 @@ import { IconChevronRight, IconArrowRight, IconCalendar, - IconGlobe, - Input, - Toggle, - Select, } from '@supabase/ui' import { format } from 'date-fns' - import TimeSplitInput from './TimeSplitInput' -// import DateSplitInput from './DateSplitInput' -import DividerSlash from './DividerSlash' - import dayjs from 'dayjs' import { ButtonProps } from '@supabase/ui/dist/cjs/components/Button/Button' +import { DatePickerToFrom } from 'components/interfaces/Settings/Logs' var utc = require('dayjs/plugin/utc') var timezone = require('dayjs/plugin/timezone') // dependent on utc plugin - dayjs.extend(utc) dayjs.extend(timezone) -interface RootProps { - onChange?: ({ to, from }: { to: string; from: string }) => void +export interface DatePickerProps { + onChange?: (args: DatePickerToFrom) => void to?: string from?: string triggerButtonType?: ButtonProps['type'] triggerButtonClassName?: string + renderFooter?: (args: DatePickerToFrom) => React.ReactNode | void } const START_DATE_DEFAULT = new Date() const END_DATE_DEFAULT = new Date() -// console.log('START_DATE_DEFAULT', START_DATE_DEFAULT) - const START_TIME_DEFAULT = { HH: '00', mm: '00', ss: '00' } const END_TIME_DEFAULT = { HH: '23', mm: '59', ss: '59' } @@ -54,24 +41,31 @@ function _DatePicker({ onChange, triggerButtonType = 'default', triggerButtonClassName = '', -}: RootProps) { + renderFooter = () => null, +}: DatePickerProps) { const [open, setOpen] = useState(false) - - const [showTime, setShowTime] = useState(true) - - const [appliedStartDate, setAppliedStartDate] = useState(null) - const [appliedEndDate, setAppliedEndDate] = useState(null) - - const [startDate, setStartDate] = useState(START_DATE_DEFAULT) - const [endDate, setEndDate] = useState(END_DATE_DEFAULT) - + const [appliedStartDate, setAppliedStartDate] = useState(null) + const [appliedEndDate, setAppliedEndDate] = useState(null) + const [startDate, setStartDate] = useState(START_DATE_DEFAULT) + const [endDate, setEndDate] = useState(END_DATE_DEFAULT) const [startTime, setStartTime] = useState(START_TIME_DEFAULT) const [endTime, setEndTime] = useState(END_TIME_DEFAULT) useEffect(() => { - if (!to || !from) { + if (!from) { setAppliedStartDate(null) + } else if (from !== appliedStartDate?.toISOString()) { + const start = dayjs(from).toDate() + setAppliedStartDate(start) + setStartDate(start) + } + + if (!to) { setAppliedEndDate(null) + } else if (to !== appliedEndDate?.toISOString()) { + const end = dayjs(to).toDate() + setAppliedEndDate(end) + setEndDate(end) } }, [to, from]) @@ -115,168 +109,77 @@ function _DatePicker({ setAppliedStartDate(null) setAppliedEndDate(null) } - - const DEFAULT_DATE_FORMAT = 'ddd MMM YYYY HH:mm:ss' - return ( - <> - setOpen(e)} - size="small" - align="center" - side="bottom" - header={ - <> -
-
- -
-
setOpen(e)} + size="small" + align="center" + side="bottom" + header={ + <> +
+
+ +
+
- -
-
- -
+ > +
- - } - overlay={ - <> -
- { - handleDatePickerChange(dates) - }} - dateFormat="MMMM d, yyyy h:mm aa" +
+ } - // dateFormat={DEFAULT_DATE_FORMAT} - // dateFormat={(locale, date) => dayjs(date).format('MM-YYYY')} - inline - dayClassName={() => 'cursor-pointer'} - renderCustomHeader={({ - date, - decreaseMonth, - increaseMonth, - prevMonthButtonDisabled, - nextMonthButtonDisabled, - }) => ( -
-
- - {format(date, 'MMMM yyyy')} - -
-
- )} />
- - -
- - -
- - } - > - - - - {/*
- setEndDate(date)} - selectsEnd - startDate={startDate} - endDate={endDate} - nextMonthButtonLabel=">" - previousMonthButtonLabel="<" - popperClassName="react-datepicker-right" - customInput={} - renderCustomHeader={({ - date, - decreaseMonth, - increaseMonth, - prevMonthButtonDisabled, - nextMonthButtonDisabled, - }) => ( - <> +
+ + } + overlay={ + <> +
+ { + handleDatePickerChange(dates) + }} + dateFormat="MMMM d, yyyy h:mm aa" + startDate={startDate} + endDate={endDate} + selectsRange + inline + dayClassName={() => 'cursor-pointer'} + renderCustomHeader={({ + date, + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + }) => (
- - {format(date, 'MMMM yyyy')} - + {format(date, 'MMMM yyyy')}
- - )} - /> -
-
-
- Default Components: -
- setStartDate(date)} - selectsStart - startDate={startDate} - endDate={endDate} - nextMonthButtonLabel=">" - previousMonthButtonLabel="<" - popperClassName="react-datepicker-left" - /> -
-
- setEndDate(date)} - selectsEnd - startDate={startDate} - endDate={endDate} - nextMonthButtonLabel=">" - previousMonthButtonLabel="<" - popperClassName="react-datepicker-right" - /> -
*/} - + )} + /> +
+ {renderFooter({ + from: startDate?.toISOString() || null, + to: endDate?.toISOString() || null, + })} + +
+ + +
+ + } + > + + ) } -const ButtonInput = forwardRef(({ value, onClick }: any, ref): any => ( - -)) - export default _DatePicker diff --git a/studio/components/ui/DatePicker/index.tsx b/studio/components/ui/DatePicker/index.tsx index 25865361516..cb250378e19 100644 --- a/studio/components/ui/DatePicker/index.tsx +++ b/studio/components/ui/DatePicker/index.tsx @@ -1 +1,2 @@ export { default as DatePicker } from './DatePicker' +export type {DatePickerProps} from "./DatePicker" \ No newline at end of file diff --git a/studio/components/ui/InfiniteList.tsx b/studio/components/ui/InfiniteList.tsx index 03bc7799214..e469b1a59d4 100644 --- a/studio/components/ui/InfiniteList.tsx +++ b/studio/components/ui/InfiniteList.tsx @@ -23,7 +23,7 @@ interface ItemProps { const Item: FC = memo(({ data, index, style }) => { const { items, itemProps, ItemComponent } = data - const item = items[index] + const item = index < items.length ? items[index] : undefined return item ? (
@@ -52,7 +52,9 @@ const InfiniteList = ({ const loadMoreItems = isLoadingNextPage ? () => {} : onLoadNextPage // Every row is loaded except for our loading indicator row - const isItemLoaded = (index: number) => !hasNextPage || !!items[index] + const isItemLoaded = (index: number) => { + return !hasNextPage || index < items.length + } const itemCount = hasNextPage ? items.length + 1 : items.length const itemData = createItemData(items, { itemProps, ItemComponent }) @@ -63,8 +65,8 @@ const InfiniteList = ({ {({ height, width }) => ( {({ onItemsRendered, ref }) => ( diff --git a/studio/components/ui/ProductMenu/ProductMenuItem.tsx b/studio/components/ui/ProductMenu/ProductMenuItem.tsx index db1090ecb59..077f4e40fd2 100644 --- a/studio/components/ui/ProductMenu/ProductMenuItem.tsx +++ b/studio/components/ui/ProductMenu/ProductMenuItem.tsx @@ -1,6 +1,6 @@ import { FC, ReactNode } from 'react' import Link from 'next/link' -import { Menu, Typography } from '@supabase/ui' +import { Menu } from '@supabase/ui' interface Props { name: string | ReactNode @@ -25,10 +25,8 @@ const ProductMenuItem: FC = ({ }) => { const menuItem = ( -
- - {name} - +
+ {name} {action}
diff --git a/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx new file mode 100644 index 00000000000..512a986c997 --- /dev/null +++ b/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -0,0 +1,110 @@ +import { FC } from 'react' +import { useRouter } from 'next/router' +import { Input } from '@supabase/ui' +import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { useJwtSecretUpdateStatus, useProjectSettings } from 'hooks' +import { SettingsLoadingState } from './SettingsLoadingState' +import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' +import Panel from 'components/to-be-cleaned/Panel' + +const DisplayApiSettings = () => { + const router = useRouter() + const { ref } = router.query + const { + services, + isLoading: isProjectSettingsLoading, + isError: isProjectSettingsError, + } = useProjectSettings(ref as string | undefined) + const { + isError: isJwtSecretUpdateStatusError, + isLoading: isJwtSecretUpdateStatusLoading, + jwtSecretUpdateStatus, + }: any = useJwtSecretUpdateStatus(ref) + + const isNotUpdatingJwtSecret = + jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated + // Get the API service + const apiService = (services ?? []).find((x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID) + const apiKeys = apiService?.service_api_keys ?? [] + // api keys should not be empty. However it can be populated with a delay on project creation + const isApikeysEmpty = apiKeys.length === 0 + + return ( + + {isProjectSettingsLoading || isJwtSecretUpdateStatusLoading || isApikeysEmpty ? ( + + ) : ( + apiKeys.map((x: any, i: number) => ( + = 1 && + 'border-t border-panel-border-interior-light dark:border-panel-border-interior-dark' + } + > + + {x.tags?.split(',').map((x: any, i: number) => ( + + {x} + + ))} + {x.tags === 'service_role' && ( + <> + {'secret'} + + )} + {x.tags === 'anon' && {'public'}} + + } + readOnly + copy={isNotUpdatingJwtSecret} + className="input-mono" + disabled + reveal={x.tags !== 'anon' && isNotUpdatingJwtSecret} + value={ + jwtSecretUpdateStatus === JwtSecretUpdateStatus.Failed + ? 'JWT secret update failed, new API key may have issues' + : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating + ? 'Updating JWT secret...' + : x.api_key + } + onChange={() => {}} + descriptionText={ + x.tags === 'service_role' + ? 'This key has the ability to bypass Row Level Security. Never share it publicly.' + : 'This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies.' + } + /> + + )) + )} + + ) +} +export default DisplayApiSettings + +const ApiContentWrapper: FC = ({ children }) => { + return ( + +
Project API keys
+

+ Your API is secured behind an API gateway which requires an API Key for every request. +
+ You can use the keys below to use Supabase client libraries. +

+
+ } + > + {children} + + ) +} diff --git a/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx b/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx new file mode 100644 index 00000000000..eb099f75976 --- /dev/null +++ b/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx @@ -0,0 +1,92 @@ +import { FC } from 'react' +import { useRouter } from 'next/router' +import { Input } from '@supabase/ui' +import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { useJwtSecretUpdateStatus, useProjectSettings } from 'hooks' +import { SettingsLoadingState } from './SettingsLoadingState' +import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' +import Panel from 'components/to-be-cleaned/Panel' + +const DisplayConfigSettings = () => { + const router = useRouter() + const { ref } = router.query + const { + project, + services, + isLoading: isProjectSettingsLoading, + isError: isProjectSettingsError, + } = useProjectSettings(ref as string | undefined) + const { + isError: isJwtSecretUpdateStatusError, + isLoading: isJwtSecretUpdateStatusLoading, + jwtSecretUpdateStatus, + }: any = useJwtSecretUpdateStatus(ref) + + const isNotUpdatingJwtSecret = + jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated + // Get the API service + const jwtSecret = project?.jwt_secret ?? '' + const apiService = (services ?? []).find((x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID) + const apiConfig = apiService?.app_config ?? {} + + return ( + + {isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? ( + + ) : ( + <> + + + + + + + + )} + + ) +} + +export default DisplayConfigSettings + +const ConfigContentWrapper: FC = ({ children }) => { + return ( + +
Project Configuration
+
+ } + > + {children} + + ) +} diff --git a/studio/components/ui/ProjectSettings/SettingsLoadingState.tsx b/studio/components/ui/ProjectSettings/SettingsLoadingState.tsx new file mode 100644 index 00000000000..1c8dd232659 --- /dev/null +++ b/studio/components/ui/ProjectSettings/SettingsLoadingState.tsx @@ -0,0 +1,24 @@ +import { FC } from 'react' +import { IconAlertCircle, Loading, Typography } from '@supabase/ui' +import { SettingsLoadingStateProps } from './SettingsLoadingState.types' +import Panel from 'components/to-be-cleaned/Panel' + +export const SettingsLoadingState: FC = ({ isError, errorMessage }) => { + return ( + + {isError ? ( +
+ + + + {errorMessage} +
+ ) : ( +
+ {/* @ts-ignore */} + +
+ )} +
+ ) +} diff --git a/studio/components/ui/ProjectSettings/SettingsLoadingState.types.tsx b/studio/components/ui/ProjectSettings/SettingsLoadingState.types.tsx new file mode 100644 index 00000000000..b3c9248594d --- /dev/null +++ b/studio/components/ui/ProjectSettings/SettingsLoadingState.types.tsx @@ -0,0 +1,4 @@ +export interface SettingsLoadingStateProps { + isError: boolean + errorMessage: string +} diff --git a/studio/components/ui/ProjectSettings/index.tsx b/studio/components/ui/ProjectSettings/index.tsx new file mode 100644 index 00000000000..37e2dcc5b8b --- /dev/null +++ b/studio/components/ui/ProjectSettings/index.tsx @@ -0,0 +1,4 @@ +import DisplayApiSettings from './DisplayApiSettings' +import DisplayConfigSettings from './DisplayConfigSettings' + +export { DisplayApiSettings, DisplayConfigSettings } diff --git a/studio/hooks/analytics/useLogsPreview.tsx b/studio/hooks/analytics/useLogsPreview.tsx index b372099b09d..3d01a59f8c8 100644 --- a/studio/hooks/analytics/useLogsPreview.tsx +++ b/studio/hooks/analytics/useLogsPreview.tsx @@ -5,17 +5,22 @@ import { genCountQuery, genDefaultQuery, genQueryParams, + getDefaultHelper, LogData, Logs, LogsEndpointParams, LogsTableName, + PREVIEWER_DATEPICKER_HELPERS, } from 'components/interfaces/Settings/Logs' import { Dispatch, SetStateAction, useEffect, useState } from 'react' import useSWR from 'swr' import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite' import { API_URL } from 'lib/constants' import { get } from 'lib/common/fetch' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +dayjs.extend(utc) interface Data { logData: LogData[] error: string | Object | null @@ -30,14 +35,14 @@ interface Handlers { loadOlder: () => void refresh: () => void setFilters: (filters: Filters | ((previous: Filters) => Filters)) => void - setFrom: (value: string) => void - setTo: (value: string) => void + setParams: Dispatch> } function useLogsPreview( projectRef: string, table: LogsTableName, filterOverride?: Filters ): [Data, Handlers] { + const defaultHelper = getDefaultHelper(PREVIEWER_DATEPICKER_HELPERS) const [latestRefresh, setLatestRefresh] = useState(new Date().toISOString()) const [filters, setFilters] = useState({ ...filterOverride }) @@ -46,10 +51,8 @@ function useLogsPreview( project: projectRef, sql: '', rawSql: '', - period_start: '', - period_end: '', - timestamp_start: '', - timestamp_end: '', + iso_timestamp_start: defaultHelper.calcFrom(), + iso_timestamp_end: defaultHelper.calcTo(), }) useEffect(() => { @@ -79,8 +82,9 @@ function useLogsPreview( } else { const len = prevPageData.result.length const { timestamp: tsLimit }: LogData = prevPageData.result[len - 1] + const isoTsLimit = dayjs.utc(Number(tsLimit / 1000)).toISOString() // create new key from params - queryParams = genQueryParams({ ...params, timestamp_end: String(tsLimit) } as any) + queryParams = genQueryParams({ ...params, iso_timestamp_end: isoTsLimit } as any) } return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` } @@ -105,7 +109,7 @@ function useLogsPreview( return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams({ ...params, sql: genCountQuery(table), - period_start: String(latestRefresh), + iso_timestamp_start: latestRefresh, } as any)}` } @@ -157,11 +161,10 @@ function useLogsPreview( oldestTimestamp: oldestTimestamp ? String(oldestTimestamp) : undefined, }, { - setFrom: (value) => setParams((prev) => ({ ...prev, timestamp_start: value })), - setTo: (value) => setParams((prev) => ({ ...prev, timestamp_end: value })), setFilters: handleSetFilters, refresh, loadOlder: () => setSize((prev) => prev + 1), + setParams, }, ] } diff --git a/studio/hooks/analytics/useLogsQuery.tsx b/studio/hooks/analytics/useLogsQuery.tsx index a9523b3951c..3fe3dc72655 100644 --- a/studio/hooks/analytics/useLogsQuery.tsx +++ b/studio/hooks/analytics/useLogsQuery.tsx @@ -1,9 +1,13 @@ -import { cleanQuery, genQueryParams } from 'components/interfaces/Settings/Logs' +import { + cleanQuery, + EXPLORER_DATEPICKER_HELPERS, + genQueryParams, + getDefaultHelper, +} from 'components/interfaces/Settings/Logs' import { Dispatch, SetStateAction, useState } from 'react' import { LogsEndpointParams, Logs, LogData } from 'components/interfaces/Settings/Logs/Logs.types' -import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite/dist/infinite' import { API_URL } from 'lib/constants' -import useSWR, { mutate } from 'swr' +import useSWR from 'swr' import { get } from 'lib/common/fetch' interface Data { params: LogsEndpointParams @@ -14,18 +18,20 @@ interface Data { interface Handlers { changeQuery: (newQuery?: string) => void runQuery: () => void + setParams: Dispatch> } const useLogsQuery = ( projectRef: string, initialParams: Partial = {} ): [Data, Handlers] => { + const defaultHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS) const [params, setParams] = useState({ project: projectRef, sql: '', rawSql: '', - // timestamp_start: '', - // timestamp_end: '', + iso_timestamp_start: defaultHelper.calcFrom(), + iso_timestamp_end: defaultHelper.calcTo(), ...initialParams, }) @@ -36,7 +42,9 @@ const useLogsQuery = ( isValidating: isLoading, mutate, } = useSWR( - params.sql ? `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` : null, + params.sql + ? `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` + : null, get, { revalidateOnFocus: false } ) @@ -51,7 +59,7 @@ const useLogsQuery = ( return [ { params, isLoading, logData: data?.result ? data?.result : [], error }, - { changeQuery, runQuery: () => mutate() }, + { changeQuery, runQuery: () => mutate(), setParams }, ] } export default useLogsQuery diff --git a/studio/hooks/misc/withAuth.tsx b/studio/hooks/misc/withAuth.tsx index 1ebbe3ef816..2c6e1b84f74 100644 --- a/studio/hooks/misc/withAuth.tsx +++ b/studio/hooks/misc/withAuth.tsx @@ -1,10 +1,8 @@ import { useProfile, useStore } from 'hooks' import { ComponentType, useEffect, useState } from 'react' -import { flatten, isUndefined } from 'lodash' import { NextRouter, useRouter } from 'next/router' import { IS_PLATFORM } from 'lib/constants' import Connecting from 'components/ui/Loading' -import { Project } from 'types' const PLATFORM_ONLY_PAGES = ['storage', 'reports', 'settings'] @@ -79,7 +77,7 @@ export function withAuth( } function defaultRedirectTo(ref: string | string[] | undefined) { - return IS_PLATFORM ? '/' : !isUndefined(ref) ? `/project/${ref}` : '/' + return IS_PLATFORM ? '/' : ref !== undefined ? `/project/${ref}` : '/' } function checkRedirectTo( diff --git a/studio/hooks/queries/useProjectPaygStatistics.ts b/studio/hooks/queries/useProjectPaygStatistics.ts index bc4451fbe25..08f9d9efc9c 100644 --- a/studio/hooks/queries/useProjectPaygStatistics.ts +++ b/studio/hooks/queries/useProjectPaygStatistics.ts @@ -4,7 +4,7 @@ import { get as _get, maxBy } from 'lodash' import { get } from 'lib/common/fetch' import { API_URL, DATE_FORMAT, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import { chargeableProducts } from 'components/interfaces/Billing' -import { Dictionary } from '@supabase/grid' +import { PaygStats } from 'components/interfaces/Billing/PAYGUsage/PAYGUsage.types' /** * Get project payg statistics @@ -16,7 +16,7 @@ export function useProjectPaygStatistics(ref?: string, tierProdId?: string) { const startDate = dayjs().utc().startOf('month').format(DATE_FORMAT) const endDate = dayjs().utc().endOf('month').format(DATE_FORMAT) const attributes = - 'total_db_size_bytes,total_db_egress_bytes,total_storage_size_bytes,total_storage_egress' + 'total_egress,total_db_size_bytes,total_db_egress_bytes,total_storage_size_bytes,total_storage_egress' const url = `${API_URL}/projects/${ref}/daily-stats?attribute=${attributes}&startDate=${encodeURIComponent( startDate )}&endDate=${encodeURIComponent(endDate)}&interval='1d'` @@ -44,13 +44,29 @@ export function useProjectPaygStatistics(ref?: string, tierProdId?: string) { } } -function getPaygStats(data: any): Dictionary { +function getPaygStats(data: any): PaygStats { const paygStats: any = {} if (!data) return paygStats - chargeableProducts.forEach((product: any) => { - product.features.forEach((feature: any) => { - paygStats[feature.attribute] = _get(maxBy(data, feature.attribute), feature.attribute) + let dataModified: any = [] + data.map((x: any) => { + dataModified.push({ ...x, total_egress_modified: x.total_egress - x.total_storage_egress }) + }) + + chargeableProducts.forEach((product) => { + product.features.forEach((feature) => { + // calculate max + const max = _get(maxBy(dataModified, feature.attribute), feature.attribute) + // calculate sum + let sum = 0 + dataModified.map((x: any) => { + sum = sum + x[feature.attribute] + }) + // set sum and max under attribute name + paygStats[feature.attribute] = { + sum, + max, + } }) }) return paygStats diff --git a/studio/hooks/queries/useProjectSettings.ts b/studio/hooks/queries/useProjectSettings.ts index 833bb17f489..946c4cc074d 100644 --- a/studio/hooks/queries/useProjectSettings.ts +++ b/studio/hooks/queries/useProjectSettings.ts @@ -1,10 +1,23 @@ import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { API_URL, DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import useSWR from 'swr' export function useProjectSettings(ref?: string) { const url = `${API_URL}/props/project/${ref}/settings` - const { data, error } = useSWR(ref ? url : null, get) + const { data, error } = useSWR(ref ? url : null, get, { + /** + * on project creation, the service_api_keys will be populated with a delay + * check for data.services.service_api_keys, return a valid refresh interval if it's empty + */ + refreshInterval: function (data: any) { + const apiService = (data?.services ?? []).find( + (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID + ) + const apiKeys = apiService?.service_api_keys ?? [] + const interval = apiKeys.length === 0 ? 2000 : 0 + return interval + }, + }) const anyError = data?.error || error return { diff --git a/studio/lib/common/fetch/head.ts b/studio/lib/common/fetch/head.ts index 550fc2663b1..9d8f62fefdc 100644 --- a/studio/lib/common/fetch/head.ts +++ b/studio/lib/common/fetch/head.ts @@ -24,3 +24,32 @@ export async function head( return handleError(error, requestId) } } + +export async function headWithTimeout( + url: string, + headersToRetrieve: string[], + options?: { [prop: string]: any } +): Promise> { + const requestId = uuidv4() + try { + const timeout = options?.timeout ?? 60000 + const controller = new AbortController() + const id = setTimeout(() => controller.abort(), timeout) + const { headers: optionHeaders, ...otherOptions } = options ?? {} + const headers = constructHeaders(requestId, optionHeaders) + const response = await fetch(url, { + method: 'HEAD', + credentials: 'include', + referrerPolicy: 'no-referrer-when-downgrade', + headers, + ...otherOptions, + signal: controller.signal, + }) + clearTimeout(id) + + if (!response.ok) return handleResponseError(response, requestId) + return handleHeadResponse(response, requestId, headersToRetrieve) + } catch (error) { + return handleError(error, requestId) + } +} diff --git a/studio/lib/constants/infrastructure.ts b/studio/lib/constants/infrastructure.ts index da1d6c4efaf..4dbba6f83e8 100644 --- a/studio/lib/constants/infrastructure.ts +++ b/studio/lib/constants/infrastructure.ts @@ -140,3 +140,5 @@ export const PASSWORD_STRENGTH_PERCENTAGE = { export const DEFAULT_FREE_PROJECTS_LIMIT = 2 export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB + +export const DEFAULT_PROJECT_API_SERVICE_ID = 1 diff --git a/studio/localStores/storageExplorer/StorageExploreStore.types.ts b/studio/localStores/storageExplorer/StorageExploreStore.types.ts new file mode 100644 index 00000000000..d995445ddf5 --- /dev/null +++ b/studio/localStores/storageExplorer/StorageExploreStore.types.ts @@ -0,0 +1,30 @@ +// [Joshen] This file isn't being used at the moment but its to prepare type files when we refactor +// storage dashboard and use typescript for it + +export interface ExplorerColumn { + id: string + name: string + items: ExplorerItem[] + status?: 'READY' | 'LOADING' + + // New variables to support infinite scrolling + hasMoreItems: boolean + isLoadingMoreItems: boolean +} + +export interface ExplorerItem { + id?: string + name: string + type: 'FILE' | 'FOLDER' + status: 'READY' | 'LOADING' | 'EDITING' + metadata?: ItemMetadata + updated_at?: string + last_accessed_at?: string + created_at?: string +} + +export interface ItemMetadata { + cacheControl: string + mimetype: string + size: number +} diff --git a/studio/localStores/storageExplorer/StorageExplorerStore.js b/studio/localStores/storageExplorer/StorageExplorerStore.js index e7be4f8c0c5..3eb66d6d031 100644 --- a/studio/localStores/storageExplorer/StorageExplorerStore.js +++ b/studio/localStores/storageExplorer/StorageExplorerStore.js @@ -42,7 +42,7 @@ export const useStorageExplorerStore = () => { return useContext(StorageExplorerContext) } -const LIMIT = 100000 +const LIMIT = 200 const OFFSET = 0 const DEFAULT_EXPIRY = 10 * 365 * 24 * 60 * 60 // in seconds, default to 1 year const PREVIEW_SIZE_LIMIT = 10000000 // 10MB @@ -225,6 +225,12 @@ class StorageExplorerStore { this.columns = this.columns.slice(0, index + 1) } + setColumnIsLoadingMore = (index, isLoadingMoreItems = true) => { + this.columns = this.columns.map((col, idx) => { + return idx === index ? { ...col, isLoadingMoreItems } : col + }) + } + pushOpenedFolderAtIndex = (folder, index) => { this.openedFolders = this.openedFolders.slice(0, index).concat(folder) } @@ -266,10 +272,11 @@ class StorageExplorerStore { } addNewFolderPlaceholder = (columnIndex) => { + const isPrepend = true const folderName = 'Untitled folder' const folderType = STORAGE_ROW_TYPES.FOLDER const columnIdx = columnIndex === -1 ? this.getLatestColumnIndex() : columnIndex - this.addTempRow(folderType, folderName, STORAGE_ROW_STATUS.EDITING, columnIdx) + this.addTempRow(folderType, folderName, STORAGE_ROW_STATUS.EDITING, columnIdx, {}, isPrepend) } addNewFolder = async (folderName, columnIndex) => { @@ -917,38 +924,78 @@ class StorageExplorerStore { /* Folders CRUD */ - fetchFolderContents = async (folderId, folderName, index, showLoading = true) => { + fetchFolderContents = async (folderId, folderName, index, searchString = '') => { this.abortApiCalls() - if (showLoading) { - this.updateRowStatus(folderName, STORAGE_ROW_STATUS.LOADING, index) - this.pushColumnAtIndex( - { id: folderId, name: folderName, status: STORAGE_ROW_STATUS.LOADING, items: [] }, - index - ) - } + this.updateRowStatus(folderName, STORAGE_ROW_STATUS.LOADING, index) + this.pushColumnAtIndex( + { id: folderId, name: folderName, status: STORAGE_ROW_STATUS.LOADING, items: [] }, + index + ) const prefix = this.openedFolders.map((folder) => folder.name).join('/') - const options = { limit: LIMIT, offset: OFFSET, sortBy: { column: this.sortBy, order: 'asc' } } + const options = { + limit: LIMIT, + offset: OFFSET, + search: searchString, + sortBy: { column: this.sortBy, order: 'asc' }, + } const parameters = { signal: this.abortController.signal } const { data: items, error } = await this.supabaseClient.storage .from(this.selectedBucket.name) .list(prefix, options, parameters) - if (showLoading) { - this.updateRowStatus(folderName, STORAGE_ROW_STATUS.READY, index) - } + this.updateRowStatus(folderName, STORAGE_ROW_STATUS.READY, index) if (!error) { const formattedItems = this.formatFolderItems(items) this.pushColumnAtIndex( - { id: folderId || folderName, name: folderName, items: formattedItems }, + { + id: folderId || folderName, + name: folderName, + items: formattedItems, + hasMoreItems: formattedItems.length === LIMIT, + isLoadingMoreItems: false, + }, index ) } } + fetchMoreFolderContents = async (index, column, searchString = '') => { + this.setColumnIsLoadingMore(index) + + const prefix = this.openedFolders.map((folder) => folder.name).join('/') + const options = { + limit: LIMIT, + offset: column.items.length, + search: searchString, + sortBy: { column: this.sortBy, order: 'asc' }, + } + const parameters = { signal: this.abortController.signal } + + const { data: items, error } = await this.supabaseClient.storage + .from(this.selectedBucket.name) + .list(prefix, options, parameters) + + if (!error) { + // Add items to column + const formattedItems = this.formatFolderItems(items) + this.columns = this.columns.map((col, idx) => { + if (idx === index) { + return { + ...col, + items: col.items.concat(formattedItems), + isLoadingMoreItems: false, + hasMoreItems: items.length === LIMIT, + } + } + return col + }) + } + } + refetchAllOpenedFolders = async () => { const paths = this.openedFolders.map((folder) => folder.name) await this.fetchFoldersByPath(paths) @@ -984,6 +1031,8 @@ class StorageExplorerStore { id: null, name: idx === 0 ? this.selectedBucket.name : pathsWithEmptyPrefix[idx], items: formattedItems, + hasMoreItems: formattedItems.length === LIMIT, + isLoadingMoreItems: false, } }) @@ -1182,11 +1231,13 @@ class StorageExplorerStore { return formattedItems } - addTempRow = (type, name, status, columnIndex, metadata = {}) => { + addTempRow = (type, name, status, columnIndex, metadata = {}, isPrepend = false) => { const updatedColumns = this.columns.map((column, idx) => { if (idx === columnIndex) { const tempRow = { type, name, status, metadata } - const updatedItems = column.items.concat([tempRow]) + const updatedItems = isPrepend + ? [tempRow].concat(column.items) + : column.items.concat([tempRow]) return { ...column, items: updatedItems } } return column diff --git a/studio/pages/api/auth/[ref]/config.ts b/studio/pages/api/auth/[ref]/config.ts index bea278795ab..86d60579887 100644 --- a/studio/pages/api/auth/[ref]/config.ts +++ b/studio/pages/api/auth/[ref]/config.ts @@ -38,6 +38,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { MAILER_URLPATHS_CONFIRMATION: '', MAILER_URLPATHS_RECOVERY: '', MAILER_URLPATHS_EMAIL_CHANGE: '', + SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: false, SMTP_ADMIN_EMAIL: '', SMTP_HOST: null, SMTP_PORT: null, diff --git a/studio/pages/api/pg-meta/[ref]/types.ts b/studio/pages/api/pg-meta/[ref]/types.ts new file mode 100644 index 00000000000..821152ed9cf --- /dev/null +++ b/studio/pages/api/pg-meta/[ref]/types.ts @@ -0,0 +1,38 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { PG_META_URL } from 'lib/constants' +import apiWrapper from 'lib/api/apiWrapper' +import { constructHeaders } from 'lib/api/apiHelpers' +import { get } from 'lib/common/fetch' + +export default (req: NextApiRequest, res: NextApiResponse) => + 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({ error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { + const headers = constructHeaders(req.headers) + const response = await get(`${PG_META_URL}/types`, { headers }) + + const includeSystemSchemas = req.query['include_system_schemas'] === 'true' + + if (response.error) { + return res.status(400).json({ error: response.error }) + } + + if (!includeSystemSchemas) { + const types = response?.filter((x: any) => x.schema === 'public') + return res.status(200).json(types) + } + + return res.status(200).json(response) +} diff --git a/studio/pages/api/projects/[ref]/index.ts b/studio/pages/api/projects/[ref]/index.ts index 4c525abd79f..83aedce1b06 100644 --- a/studio/pages/api/projects/[ref]/index.ts +++ b/studio/pages/api/projects/[ref]/index.ts @@ -33,6 +33,9 @@ const handleGet = async (req: NextApiRequest, res: NextApiResponse) => { db_name: 'postgres', db_ssl: false, }), + kpsVersion: 'kps-v1.0.0', + restUrl: process.env.SUPABASE_REST_URL || 'http://localhost:8000/rest/v1/', + internalApiKey: process.env.SUPABASE_ANON_KEY, } return res.status(200).json(response) diff --git a/studio/pages/index.tsx b/studio/pages/index.tsx index 31db7089a43..749e495e572 100644 --- a/studio/pages/index.tsx +++ b/studio/pages/index.tsx @@ -60,7 +60,7 @@ const Home: NextPage = () => { const onDeleteProject = async (project?: Project) => { if (!project) return setIsDeletingProject(true) - const response = await delete_(`${API_URL}/projects/${project.ref}/remove`) + const response = await delete_(`${API_URL}/projects/${project.ref}`) if (response.error) { return ui.setNotification({ category: 'error', message: response.error.message }) diff --git a/studio/pages/new/[slug].tsx b/studio/pages/new/[slug].tsx index 3fe60a07230..b8d9107377c 100644 --- a/studio/pages/new/[slug].tsx +++ b/studio/pages/new/[slug].tsx @@ -188,9 +188,8 @@ export const Wizard = observer(() => { message: `Failed to create new project: ${response.error.message}`, }) } else { - // Use redirect to reload store data properly - // after creating a new project - window.location.replace(`/project/${response.ref}/building`) + app.onProjectCreated(response) + router.push(`/project/${response.ref}/building`) } } diff --git a/studio/pages/project/[ref]/auth/settings.tsx b/studio/pages/project/[ref]/auth/settings.tsx index 90a74ea2d9d..898bd73a8c6 100644 --- a/studio/pages/project/[ref]/auth/settings.tsx +++ b/studio/pages/project/[ref]/auth/settings.tsx @@ -121,6 +121,7 @@ const Settings = () => { 'JWT_EXP', 'DISABLE_SIGNUP', 'PASSWORD_MIN_LENGTH', + 'SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION', ])} model={{ SITE_URL: model.SITE_URL || undefined, @@ -128,6 +129,7 @@ const Settings = () => { DISABLE_SIGNUP: model.DISABLE_SIGNUP, JWT_EXP: model.JWT_EXP || undefined, PASSWORD_MIN_LENGTH: model.PASSWORD_MIN_LENGTH || undefined, + SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: model.SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION || false, }} onSubmit={(model: any) => onFormSubmit(model)} > @@ -143,6 +145,7 @@ const Settings = () => { /> +
diff --git a/studio/pages/project/[ref]/building.tsx b/studio/pages/project/[ref]/building.tsx index 75e2e84f99c..3215ec8e6a3 100644 --- a/studio/pages/project/[ref]/building.tsx +++ b/studio/pages/project/[ref]/building.tsx @@ -1,33 +1,26 @@ -import * as React from 'react' +import { FC, useEffect } from 'react' import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { useStore, withAuth } from 'hooks' - +import { withAuth } from 'hooks' import ProjectLayout from 'components/layouts' -type ProjectBuildingPageState = {} & any -const ProjectBuildingPage: React.FC = () => { - const { ui } = useStore() - const project: any = ui.selectedProject +const ProjectBuildingPage: FC = () => { return ( - + ) } -export default withAuth(observer(ProjectBuildingPage)) +export default withAuth(ProjectBuildingPage) -type RedirectToDashboardState = { - projectRef: string -} -const RedirectToDashboard: React.FC = ({ projectRef }) => { +const RedirectToDashboard: FC = () => { const router = useRouter() + const { ref } = router.query - React.useEffect(() => { + useEffect(() => { // Use redirect to reload store data properly after project has been // been created or unpaused, this is necessarily especially for unpausing // so that the dashboard fetches the updated connection strings - window.location.replace(`/project/${projectRef}`) + window.location.replace(`/project/${ref}`) }, []) return null } diff --git a/studio/pages/project/[ref]/functions/[id]/details.tsx b/studio/pages/project/[ref]/functions/[id]/details.tsx index c34fdca806e..2c42719413f 100644 --- a/studio/pages/project/[ref]/functions/[id]/details.tsx +++ b/studio/pages/project/[ref]/functions/[id]/details.tsx @@ -5,9 +5,7 @@ import FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout' import CommandRender from 'components/interfaces/Functions/CommandRender' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' -import { toJS } from 'mobx' -import { IconExternalLink, IconGlobe, IconTerminal, Loading } from '@supabase/ui' -import Link from 'next/link' +import { IconGlobe, IconTerminal } from '@supabase/ui' import dayjs from 'dayjs' import { NextPageWithLayout } from 'types' @@ -23,10 +21,6 @@ const PageLayout: NextPageWithLayout = () => { setSelectedFunction(functions.byId(id)) }, [functions.isLoaded, ui.selectedProject]) - // if (!functions.isLoading) return <> - - console.log('selected', functions.byId(id)) - const managementCommands: any = [ { command: `supabase functions deploy ${selectedFunction?.slug}`, diff --git a/studio/pages/project/[ref]/index.tsx b/studio/pages/project/[ref]/index.tsx index 36d861d5660..4cb7affddc4 100644 --- a/studio/pages/project/[ref]/index.tsx +++ b/studio/pages/project/[ref]/index.tsx @@ -5,7 +5,7 @@ import { useStore, withAuth } from 'hooks' import { ExampleProject, ClientLibrary } from 'components/interfaces/Home' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' import { IS_PLATFORM } from 'lib/constants' -import BaseLayout from 'components/layouts' +import ProjectLayout from 'components/layouts' import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' const Home: NextPage = () => { @@ -18,7 +18,7 @@ const Home: NextPage = () => { : 'Welcome to your project' return ( - +

{projectName}

@@ -45,7 +45,7 @@ const Home: NextPage = () => {
- + ) } diff --git a/studio/pages/project/[ref]/logs-explorer/index.tsx b/studio/pages/project/[ref]/logs-explorer/index.tsx index 85059a869e4..026f43bf5ec 100644 --- a/studio/pages/project/[ref]/logs-explorer/index.tsx +++ b/studio/pages/project/[ref]/logs-explorer/index.tsx @@ -2,12 +2,15 @@ import React, { useEffect, useState } from 'react' import { NextPage } from 'next' import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' -import { Typography, IconAlertCircle, Card, Input, Alert, Modal, Form, Button } from '@supabase/ui' +import { Input, Modal, Form, Button } from '@supabase/ui' import { useStore, withAuth } from 'hooks' import CodeEditor from 'components/ui/CodeEditor' import { + DatePickerToFrom, LogsQueryPanel, LogsTableName, + LogsWarning, + LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD, LogTable, LogTemplate, TEMPLATES, @@ -17,37 +20,58 @@ import useLogsQuery from 'hooks/analytics/useLogsQuery' import { LogsExplorerLayout } from 'components/layouts' import ShimmerLine from 'components/ui/ShimmerLine' import LoadingOpacity from 'components/ui/LoadingOpacity' -import { LogSqlSnippets, UserContent } from 'types' +import { UserContent } from 'types' import toast from 'react-hot-toast' +import dayjs from 'dayjs' export const LogsExplorerPage: NextPage = () => { const router = useRouter() - const { ref, q } = router.query + const { ref, q, ite, its } = router.query const [editorId, setEditorId] = useState(uuidv4()) const [editorValue, setEditorValue] = useState('') const [saveModalOpen, setSaveModalOpen] = useState(false) - + const [warnings, setWarnings] = useState([]) const { content } = useStore() - const [{ logData, error, isLoading }, { changeQuery, runQuery }] = useLogsQuery(ref as string) + const [{ params, logData, error, isLoading }, { changeQuery, runQuery, setParams }] = + useLogsQuery(ref as string, { + iso_timestamp_start: (its || '') as string, + iso_timestamp_end: (ite || '') as string, + }) useEffect(() => { // on mount, set initial values - if (q !== undefined && q !== '') { - changeQuery(q as string) - runQuery() + if (q) { onSelectTemplate({ mode: 'custom', searchString: q as string, }) } - }, [q]) + }, []) + + useEffect(() => { + let newWarnings = [] + const start = params.iso_timestamp_start ? dayjs(params.iso_timestamp_start) : dayjs() + const end = params.iso_timestamp_end ? dayjs(params.iso_timestamp_end) : dayjs() + const daysDiff = Math.abs(start.diff(end, 'days')) + if ( + editorValue && + !editorValue.includes('limit') && + daysDiff > LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD + ) { + newWarnings.push({ text: 'When querying large date ranges, include a LIMIT clause.' }) + } + setWarnings(newWarnings) + }, [editorValue, params.iso_timestamp_start, params.iso_timestamp_end]) const onSelectTemplate = (template: LogTemplate) => { setEditorValue(template.searchString) changeQuery(template.searchString) setEditorId(uuidv4()) - runQuery() + router.push({ + pathname: router.pathname, + query: { ...router.query, q: template.searchString }, + }) } const handleRun = () => { @@ -74,11 +98,26 @@ export const LogsExplorerPage: NextPage = () => { setSaveModalOpen(!saveModalOpen) } + const handleDateChange = ({ to, from }: DatePickerToFrom) => { + setParams((prev) => ({ + ...prev, + iso_timestamp_start: from || '', + iso_timestamp_end: to || '', + })) + router.push({ + pathname: router.pathname, + query: { ...router.query, its: from || '', ite: to || '' }, + }) + } + return (
{ onSelectTemplate={onSelectTemplate} onSave={handleOnSave} isLoading={isLoading} + warnings={warnings} />
diff --git a/studio/pages/project/[ref]/logs-explorer/saved.tsx b/studio/pages/project/[ref]/logs-explorer/saved.tsx index 074b4779e37..642ae52ba7d 100644 --- a/studio/pages/project/[ref]/logs-explorer/saved.tsx +++ b/studio/pages/project/[ref]/logs-explorer/saved.tsx @@ -28,7 +28,6 @@ export const LogsExplorerPage: NextPage = () => { const saved = content.list() - console.log('saved in parent', saved.length) return (
diff --git a/studio/pages/project/[ref]/settings/api.tsx b/studio/pages/project/[ref]/settings/api.tsx index dfe96adce81..4e83547e1ef 100644 --- a/studio/pages/project/[ref]/settings/api.tsx +++ b/studio/pages/project/[ref]/settings/api.tsx @@ -45,7 +45,7 @@ import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' import Panel from 'components/to-be-cleaned/Panel' import MultiSelectUI from 'components/to-be-cleaned/MultiSelect' import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel' -import { DisplayApiSettings } from 'components/to-be-cleaned/DisplayProjectSettings' +import { DisplayApiSettings } from 'components/ui/ProjectSettings' const JWT_SECRET_UPDATE_ERROR_MESSAGES = { [JwtSecretUpdateError.APIServicesConfigurationUpdateFailed]: diff --git a/studio/pages/project/[ref]/settings/billing/index.tsx b/studio/pages/project/[ref]/settings/billing/index.tsx index 0acccb90b46..7239867bb1a 100644 --- a/studio/pages/project/[ref]/settings/billing/index.tsx +++ b/studio/pages/project/[ref]/settings/billing/index.tsx @@ -11,6 +11,7 @@ import { PAYGUsage, Subscription, Invoices } from 'components/interfaces/Billing import ProjectUsage from 'components/to-be-cleaned/Usage' import DateRangePicker from 'components/to-be-cleaned/DateRangePicker' +import { PaygStats } from 'components/interfaces/Billing/PAYGUsage/PAYGUsage.types' type ProjectBillingProps = {} & any const ProjectBilling: FC = ({ store }) => { @@ -35,15 +36,18 @@ type SettingsProps = { } const Settings: FC = ({ project }) => { const { ui } = useStore() + const { subscription, isLoading: loading, error, } = useProjectSubscription(ui.selectedProject?.ref) - const { paygStats } = useProjectPaygStatistics( + + const { paygStats }: { paygStats: PaygStats | undefined } = useProjectPaygStatistics( ui.selectedProject?.ref, subscription?.tier?.supabase_prod_id ) + const [dateRange, setDateRange] = useState() useEffect(() => { diff --git a/studio/pages/project/[ref]/settings/general.tsx b/studio/pages/project/[ref]/settings/general.tsx index 8875bfac2a6..c6f4978d7d0 100644 --- a/studio/pages/project/[ref]/settings/general.tsx +++ b/studio/pages/project/[ref]/settings/general.tsx @@ -1,10 +1,10 @@ -import React, { FC, useState } from 'react' +import { FC, useState } from 'react' import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { toJS } from 'mobx' import { projects } from 'stores/jsonSchema' import { AutoField } from 'uniforms-bootstrap4' -import { Alert, Button, Input, Typography, IconRefreshCcw } from '@supabase/ui' +import { Alert, Button, Input, IconRefreshCcw } from '@supabase/ui' import { API_URL } from 'lib/constants' import { pluckJsonSchemaFields, pluckObjectFields } from 'lib/helpers' @@ -30,8 +30,13 @@ const ProjectSettings = () => { export default withAuth(observer(ProjectSettings)) -const RestartServerButton: FC = ({ projectRef }: any) => { - const { ui } = useStore() +interface RestartServerButtonProps { + projectId: number + projectRef: string +} +const RestartServerButton: FC = observer(({ projectRef, projectId }) => { + const { ui, app } = useStore() + const router = useRouter() const [loading, setLoading] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) @@ -42,8 +47,9 @@ const RestartServerButton: FC = ({ projectRef }: any) => { setLoading(true) try { await post(`${API_URL}/projects/${projectRef}/restart`, {}) - ui.setNotification({ category: 'info', message: 'Requested server restart' }) - setLoading(false) + app.onProjectPostgrestStatusUpdated(projectId, 'OFFLINE') + ui.setNotification({ category: 'success', message: 'Restarting server' }) + router.push(`/project/${projectRef}`) } catch (error) { ui.setNotification({ error, category: 'error', message: 'Unable to restart server' }) setLoading(false) @@ -68,7 +74,7 @@ const RestartServerButton: FC = ({ projectRef }: any) => { ) -} +}) const GeneralSettings = observer(() => { const { app, ui } = useStore() @@ -106,9 +112,9 @@ const GeneralSettings = observer(() => {
+
Infrastructure - +
} > @@ -126,36 +132,31 @@ const GeneralSettings = observer(() => {
- Restart Server +

Restart server

Your project will not be available for a few minutes.

- + {project && }
- Danger Zone}> + Danger Zone

}> - Deleting this project will also remove your database. - - } + title="Deleting this project will also remove your database." >
- +

Make sure you have made a backup if you want to keep your data. - +

@@ -181,7 +182,7 @@ const ProjectDeleteModal = ({ project }: any) => { async function handleDeleteProject() { setLoading(true) try { - const response = await delete_(`${API_URL}/projects/${project.ref}/remove`) + const response = await delete_(`${API_URL}/projects/${project.ref}`) if (response.error) throw response.error app.onProjectDeleted(response) ui.setNotification({ category: 'success', message: `Successfully deleted ${project.name}` }) diff --git a/studio/stores/app/AppStore.ts b/studio/stores/app/AppStore.ts index 63ff300c127..03e4d6a4f0c 100644 --- a/studio/stores/app/AppStore.ts +++ b/studio/stores/app/AppStore.ts @@ -12,10 +12,12 @@ export interface IAppStore { projects: IProjectStore organizations: OrganizationStore database: IDatabaseStore + onProjectCreated: (project: any) => void onProjectUpdated: (project: any) => void onProjectDeleted: (project: any) => void onProjectConnectionStringUpdated: (projectId: number, value: string) => void onProjectStatusUpdated: (projectId: number, value: string) => void + onProjectPostgrestStatusUpdated: (projectId: number, value: 'OFFLINE' | 'ONLINE') => void onOrgAdded: (org: any) => void onOrgUpdated: (org: any) => void onOrgDeleted: (org: any) => void @@ -39,6 +41,23 @@ export default class AppStore implements IAppStore { this.database = new DatabaseStore(rootStore, `${this.baseUrl}/database`, headers) } + onProjectCreated(project: any) { + if (project && project.id) { + const temp: Project = { + id: project.id, + ref: project.ref, + name: project.name, + status: project.status, + organization_id: project.organization_id, + cloud_provider: project.cloud_provider, + region: project.region, + inserted_at: project.inserted_at, + subscription_id: project.subscription_id, + } + this.projects.data[project.id] = temp + } + } + onProjectUpdated(project: any) { if (project && project.id) { const clone = cloneDeep(this.projects.data[project.id]) @@ -69,6 +88,12 @@ export default class AppStore implements IAppStore { this.projects.data[projectId] = clone } + onProjectPostgrestStatusUpdated(projectId: number, value: 'OFFLINE' | 'ONLINE') { + const clone = cloneDeep(this.projects.data[projectId]) + clone.postgrestStatus = value + this.projects.data[projectId] = clone + } + onOrgUpdated(updatedOrg: Organization) { if (updatedOrg && updatedOrg.id) { const originalOrg = this.organizations.data[updatedOrg.id] diff --git a/studio/stores/app/ProjectStore.ts b/studio/stores/app/ProjectStore.ts index c8157869d43..5d646c65097 100644 --- a/studio/stores/app/ProjectStore.ts +++ b/studio/stores/app/ProjectStore.ts @@ -1,8 +1,9 @@ -import { Project, ResponseError } from 'types' +import { Project } from 'types' import { IRootStore } from '../RootStore' import { constructHeaders } from 'lib/api/apiHelpers' -import { get } from 'lib/common/fetch' +import { get, headWithTimeout } from 'lib/common/fetch' import PostgresMetaInterface, { IPostgresMetaInterface } from '../common/PostgresMetaInterface' +import { PROJECT_STATUS } from 'lib/constants' export interface IProjectStore extends IPostgresMetaInterface { fetchDetail: (projectRef: string) => void @@ -26,7 +27,31 @@ export default class ProjectStore extends PostgresMetaInterface { const response = await get(url, { headers }) if (!response.error) { const project = response as Project + // [TODO] Tempfix comment out ConnectingState + // if ( + // project.status === PROJECT_STATUS.ACTIVE_HEALTHY && + // project.restUrl && + // project.internalApiKey + // ) { + // const success = await this.pingPostgrest(project.restUrl, project.internalApiKey) + // project.postgrestStatus = success ? 'ONLINE' : 'OFFLINE' + // } this.data[project.id] = project } } + + /** + * Send a HEAD request to postgrest OpenAPI + * + * @return true if there's no error else false + */ + async pingPostgrest(restUrl: string, apikey: string) { + const headers = { apikey, Authorization: `Bearer ${apikey}` } + const { error } = await headWithTimeout(restUrl, [], { + headers, + credentials: 'omit', + timeout: 2000, + }) + return error === undefined + } } diff --git a/studio/stores/content/ProjectContentStore.ts b/studio/stores/content/ProjectContentStore.ts index 1c8fddff0ad..cdf5abcd506 100644 --- a/studio/stores/content/ProjectContentStore.ts +++ b/studio/stores/content/ProjectContentStore.ts @@ -116,7 +116,6 @@ export default class ProjectContentStore implements IProjectContentStore { } sqlSnippets(filter?: any) { - console.log('i ran') let arr = Object.values(this.data) if (!!filter) { const arr_filtered = arr diff --git a/studio/stores/jsonSchema/auth_gotrue_config.json b/studio/stores/jsonSchema/auth_gotrue_config.json index d244d25a64b..2b1c7013634 100644 --- a/studio/stores/jsonSchema/auth_gotrue_config.json +++ b/studio/stores/jsonSchema/auth_gotrue_config.json @@ -6,6 +6,7 @@ "JWT_EXP", "JWT_AUD", "JWT_DEFAULT_GROUP_NAME", + "SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION", "EXTERNAL_EMAIL_ENABLED", "EXTERNAL_PHONE_ENABLED", "EXTERNAL_APPLE_ENABLED", @@ -54,27 +55,27 @@ "help": "The base URL of your website. Used as an allow-list for redirects and for constructing URLs used in emails." }, "URI_ALLOW_LIST": { - "title": "Additional Redirect URLs", + "title": "Additional redirect URLs", "type": "string", "help": "A comma separated list of *exact* URLs that auth providers are permitted to redirect to post authentication." }, "DISABLE_SIGNUP": { - "title": "Disable Signup", + "title": "Disable signup", "type": "boolean", "help": "Allow/disallow new user signups to your project." }, "EXTERNAL_EMAIL_ENABLED": { - "title": "Enable Email Signup", + "title": "Enable email signup", "type": "boolean", "help": "Allow/disallow new user signups via email to your project." }, "EXTERNAL_PHONE_ENABLED": { - "title": "Enable Phone Signup", + "title": "Enable phone signup", "type": "boolean", "help": "Allow/disallow new user signups via phone to your project." }, "JWT_EXP": { - "title": "JWT Expiry", + "title": "JWT expiry", "type": "integer", "help": "How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one week).", "minimum": 1, @@ -82,7 +83,7 @@ "multipleof": 1 }, "JWT_AUD": { - "title": "JWT Audience", + "title": "JWT audience", "type": "string" }, "JWT_DEFAULT_GROUP_NAME": { @@ -310,35 +311,35 @@ "type": "string" }, "SMTP_ADMIN_EMAIL": { - "title": "SMTP Admin Email", + "title": "SMTP admin email", "type": "string" }, "SMTP_HOST": { - "title": "SMTP Host", + "title": "SMTP host", "type": "string" }, "SMTP_PORT": { - "title": "SMTP Port", + "title": "SMTP port", "type": "string" }, "SMTP_USER": { - "title": "SMTP User", + "title": "SMTP user", "type": "string" }, "SMTP_PASS": { - "title": "SMTP Password", + "title": "SMTP password", "type": "string" }, "SMTP_PASS_ENCRYPTED": { - "title": "SMTP Password", + "title": "SMTP password", "type": "string" }, "SMTP_SENDER_NAME": { - "title": "SMTP Sender Name", + "title": "SMTP sender name", "type": "string" }, "RATE_LIMIT_EMAIL_SENT": { - "title": "Rate Limit", + "title": "Rate limit", "type": "number", "help": "Maximum number of emails sent per hour (Default: 30, Max: 32,767)", "minimum": 1, @@ -430,13 +431,18 @@ "title": "Minimum password length", "type": "integer" }, + "SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION": { + "title": "Enable strict password updates", + "type": "boolean", + "help": "If enabled, users need to reauthenticate via email or phone before updating their passwords." + }, "SMS_AUTOCONFIRM": { "title": "Enable phone confirmations", "type": "boolean", "help": "If enabled, users need to confirm their phone number before signing in." }, "SMS_PROVIDER": { - "title": "Sms Provider", + "title": "Sms provider", "type": "string", "options": [ { @@ -458,24 +464,24 @@ ] }, "SMS_TEXTLOCAL_API_KEY": { - "title": "Textlocal API Key", + "title": "Textlocal API key", "type": "string" }, "SMS_TEXTLOCAL_SENDER": { - "title": "Textlocal Sender", + "title": "Textlocal sender", "type": "string", "help": "Textlocal sender phone number" }, "SMS_TWILIO_ACCOUNT_SID": { - "title": "Twilio Account SID", + "title": "Twilio account SID", "type": "string" }, "SMS_TWILIO_AUTH_TOKEN": { - "title": "Twilio Auth Token", + "title": "Twilio auth token", "type": "string" }, "SMS_TWILIO_MESSAGE_SERVICE_SID": { - "title": "Twilio Message Service SID", + "title": "Twilio message service SID", "type": "string", "help": "Twilio message service SID or twilio phone number" }, @@ -489,15 +495,15 @@ "help": "Messagebird sender name or phone number" }, "SMS_VONAGE_API_KEY": { - "title": "Vonage API Key", + "title": "Vonage API key", "type": "string" }, "SMS_VONAGE_API_SECRET": { - "title": "Vonage API Secret", + "title": "Vonage API secret", "type": "string" }, "SMS_VONAGE_FROM": { - "title": "Vonage Sender", + "title": "Vonage sender", "type": "string", "help": "Vonage sender phone number" }, diff --git a/studio/stores/pgmeta/MetaStore.ts b/studio/stores/pgmeta/MetaStore.ts index c2954314903..ad47c091ae7 100644 --- a/studio/stores/pgmeta/MetaStore.ts +++ b/studio/stores/pgmeta/MetaStore.ts @@ -1,6 +1,6 @@ import Papa from 'papaparse' import { makeObservable, observable } from 'mobx' -import { find, isUndefined, isEqual, isEmpty, chunk, maxBy } from 'lodash' +import { find, isUndefined, isEqual, isEmpty, chunk } from 'lodash' import { Query } from '@supabase/grid' import { PostgresColumn, @@ -38,6 +38,7 @@ import PublicationStore, { IPublicationStore } from './PublicationStore' import FunctionsStore from './FunctionsStore' import HooksStore from './HooksStore' import ExtensionsStore from './ExtensionsStore' +import TypesStore from './TypesStore' const BATCH_SIZE = 1000 const CHUNK_SIZE = 1024 * 1024 * 0.25 // 0.25MB @@ -57,6 +58,7 @@ export interface IMetaStore { functions: IPostgresMetaInterface extensions: IPostgresMetaInterface publications: IPublicationStore + types: IPostgresMetaInterface query: (value: string) => Promise validateQuery: (value: string) => Promise @@ -113,6 +115,7 @@ export default class MetaStore implements IMetaStore { functions: FunctionsStore extensions: ExtensionsStore publications: PublicationStore + types: TypesStore connectionString?: string baseUrl: string @@ -155,6 +158,7 @@ export default class MetaStore implements IMetaStore { identifier: 'name', }) this.publications = new PublicationStore(rootStore, `${this.baseUrl}/publications`, headers) + this.types = new TypesStore(rootStore, `${this.baseUrl}/types`, headers) makeObservable(this, { excludedSchemas: observable, @@ -516,6 +520,7 @@ export default class MetaStore implements IMetaStore { } // Add any new columns / Update any existing columns + let hasError = false for (const column of columns) { if (!column.id.includes(table.id.toString())) { this.rootStore.ui.setNotification({ @@ -546,7 +551,19 @@ export default class MetaStore implements IMetaStore { category: 'loading', message: `Updating column ${column.name} from ${updatedTable.name}`, }) - await this.updateColumn(column.id, columnPayload, updatedTable, column.foreignKey) + const res: any = await this.updateColumn( + column.id, + columnPayload, + updatedTable, + column.foreignKey + ) + if (res?.error) { + hasError = true + this.rootStore.ui.setNotification({ + category: 'error', + message: `Failed to update column "${column.name}": ${res.error.message}`, + }) + } } } } @@ -562,7 +579,7 @@ export default class MetaStore implements IMetaStore { if (primaryKeys.error) throw primaryKeys.error } - return await this.tables.loadById(table.id) + return { table: await this.tables.loadById(table.id), hasError } } async insertRowsViaSpreadsheet( diff --git a/studio/stores/pgmeta/SchemaStore.ts b/studio/stores/pgmeta/SchemaStore.ts index 208aa26e5c1..b8762efec64 100644 --- a/studio/stores/pgmeta/SchemaStore.ts +++ b/studio/stores/pgmeta/SchemaStore.ts @@ -6,8 +6,6 @@ import { ResponseError } from 'types' export interface ISchemaStore extends IPostgresMetaInterface { getViews: (schema: string) => Promise - getEnums: () => Promise - // getEnumValues: (id: string) => Promise } export default class SchemaStore extends PostgresMetaInterface { constructor( @@ -29,15 +27,4 @@ export default class SchemaStore extends PostgresMetaInterface { ` return await this.rootStore.meta.query(query) } - - async getEnums() { - const query = `SELECT "oid" as "id", "typname" as "name" FROM pg_type WHERE typcategory = 'E';` - return await this.rootStore.meta.query(query) - } - - // [Joshen] I realised we probably don't need this (yet) for table editor - // async getEnumValues(id: string) { - // const query = `SELECT enumlabel FROM pg_enum WHERE enumtypid = ${id};` - // return await this.rootStore.meta.query(query) - // } } diff --git a/studio/stores/pgmeta/TypesStore.ts b/studio/stores/pgmeta/TypesStore.ts new file mode 100644 index 00000000000..c7057dbc333 --- /dev/null +++ b/studio/stores/pgmeta/TypesStore.ts @@ -0,0 +1,16 @@ +import PostgresMetaInterface from '../common/PostgresMetaInterface' +import { PostgresType } from '@supabase/postgres-meta' +import { IRootStore } from '../RootStore' + +export default class TypesStore extends PostgresMetaInterface { + constructor( + rootStore: IRootStore, + dataUrl: string, + headers?: { + [prop: string]: any + }, + options?: { identifier: string } + ) { + super(rootStore, dataUrl, headers, options) + } +} diff --git a/studio/tests/pages/projects/LogTable.test.js b/studio/tests/pages/projects/LogTable.test.js index 56b596396fc..458aa84a6f0 100644 --- a/studio/tests/pages/projects/LogTable.test.js +++ b/studio/tests/pages/projects/LogTable.test.js @@ -63,3 +63,17 @@ test('toggle histogram', async () => { userEvent.click(toggle) expect(mockFn).toBeCalled() }) + + +test('error message handling', async () => { + const {rerender} = render() + await expect(screen.findByText("some \nstring")).rejects.toThrow() + await screen.findByDisplayValue(/some/) + await screen.findByDisplayValue(/string/) + + rerender() + await screen.findByText(/some \\nstring/) + await screen.findByText(/some/) + await screen.findByText(/string/) + await screen.findByText(/my_error/) +}) diff --git a/studio/tests/pages/projects/Logs.Datepickers.test.js b/studio/tests/pages/projects/Logs.Datepickers.test.js new file mode 100644 index 00000000000..2a578d480fb --- /dev/null +++ b/studio/tests/pages/projects/Logs.Datepickers.test.js @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PREVIEWER_DATEPICKER_HELPERS } from 'components/interfaces/Settings/Logs' +import DatePickers from 'components/interfaces/Settings/Logs/Logs.DatePickers' +import dayjs from 'dayjs' + +test('renders warning', async () => { + const from = dayjs().subtract(60, 'days') + const to = dayjs() + render( + + ) + userEvent.click(await screen.findByText(RegExp(from.format('DD MMM')))) + await screen.findByText(/memory errors/) +}) diff --git a/studio/tests/pages/projects/Logs.utils.test.js b/studio/tests/pages/projects/Logs.utils.test.js index 053d45e670a..eb3d0a56fe1 100644 --- a/studio/tests/pages/projects/Logs.utils.test.js +++ b/studio/tests/pages/projects/Logs.utils.test.js @@ -1,6 +1,19 @@ -import { genDefaultQuery, LogsTableName } from 'components/interfaces/Settings/Logs' +import { + genDefaultQuery, + LogsTableName, + SQL_FILTER_TEMPLATES, +} from 'components/interfaces/Settings/Logs' describe.each(Object.values(LogsTableName))('%s', (table) => { + const templates = SQL_FILTER_TEMPLATES[table] + const stringTemplateKey = Object.keys(templates).find((key) => { + if (typeof templates[key] === 'string' && key.split('.').length === 2) { + return true + } else { + false + } + }) + const [root, child] = (stringTemplateKey || '').split('.') describe.each([ { filter: { search_query: '' } }, { filter: { search_query: undefined } }, @@ -27,15 +40,25 @@ describe.each(Object.values(LogsTableName))('%s', (table) => { filter: { 'override.nested': 'something' }, contains: ['override.nested', 'something'], }, - ])('generates sql for filter $filter', ({ filter, contains = [] }) => { + // check for when string templates are set to false. + ...(stringTemplateKey + ? [ + { + filter: { search_query: 'some-search-query', [root]: { [child]: false } }, + excludes: [templates[stringTemplateKey], '()'], + contains: ['some-search-query'], + }, + ] + : []), + ])('generates sql for filter $filter', ({ filter, contains = [], excludes = [] }) => { test('generates query correctly', async () => { const generated = genDefaultQuery(table, filter) // should not contain functions expect(generated).not.toMatch(/function[ A-Za-z_-]+\(.+\).+\{.+return.+\}/) expect(generated).not.toMatch(/\=\>|\$\{.+\}/) expect(generated).not.toContain('return') - contains.forEach((str) => expect(generated).toContain(str)) + excludes.forEach((str) => expect(generated).not.toContain(str)) }) }) }) diff --git a/studio/tests/pages/projects/LogsPreviewer.test.js b/studio/tests/pages/projects/LogsPreviewer.test.js index 9b736329441..9a41d6d7978 100644 --- a/studio/tests/pages/projects/LogsPreviewer.test.js +++ b/studio/tests/pages/projects/LogsPreviewer.test.js @@ -170,40 +170,36 @@ test('te= query param will populate the timestamp to input', async () => { // get time 20 mins before const newDate = new Date() newDate.setMinutes(new Date().getMinutes() - 20) - const isoString = newDate.toISOString() - const unixMicro = newDate.getTime() * 1000 //microseconds + const iso = newDate.toISOString() const router = defaultRouterMock() - router.query = { ...router.query, te: unixMicro } + router.query = { ...router.query, ite: iso } useRouter.mockReturnValue(router) render() await waitFor(() => { expect(get).toHaveBeenCalledWith( - expect.stringContaining(`timestamp_end=${encodeURIComponent(unixMicro)}`) + expect.stringContaining(`iso_timestamp_end=${encodeURIComponent(iso)}`) ) }) userEvent.click(await screen.findByText('Custom')) - expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end=' + unixMicro)) }) test('ts= query param will populate the timestamp from input', async () => { // get time 20 mins before const newDate = new Date() newDate.setMinutes(new Date().getMinutes() - 20) - const unixMicro = newDate.getTime() * 1000 //microseconds + const iso = newDate.toISOString() const router = defaultRouterMock() - router.query = { ...router.query, ts: unixMicro } + router.query = { ...router.query, its: iso } useRouter.mockReturnValue(router) render() await waitFor(() => { expect(get).toHaveBeenCalledWith( - expect.stringContaining(`timestamp_start=${encodeURIComponent(unixMicro)}`) + expect.stringContaining(`iso_timestamp_start=${encodeURIComponent(iso)}`) ) }) userEvent.click(await screen.findByText('Custom')) await screen.findByText(new RegExp(newDate.getFullYear())) - await screen.findByText(/Apply/) - expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_start=' + unixMicro)) }) test('load older btn will fetch older logs', async () => { @@ -284,14 +280,12 @@ test('bug: nav backwards with params change results in ui changing', async () => await screen.findByDisplayValue('simple-query') }) -test("bug: nav to explorer preserves newlines", async ()=>{ - render( - - ) +test('bug: nav to explorer preserves newlines', async () => { + render() const router = useRouter() userEvent.click(await screen.findByText(/Explore/)) - await expect(router.push).toBeCalledWith(expect.stringContaining(encodeURIComponent("\n"))) -} + await expect(router.push).toBeCalledWith(expect.stringContaining(encodeURIComponent('\n'))) +}) test('filters alter generated query', async () => { render() userEvent.click(await screen.findByRole('button', { name: 'Status' })) diff --git a/studio/tests/pages/projects/LogsQueryPanel.test.js b/studio/tests/pages/projects/LogsQueryPanel.test.js index 0eeb11152c5..5c3e8614eea 100644 --- a/studio/tests/pages/projects/LogsQueryPanel.test.js +++ b/studio/tests/pages/projects/LogsQueryPanel.test.js @@ -14,7 +14,7 @@ test.todo('templates') test('run and clear', async () => { const mockRun = jest.fn() const mockClear = jest.fn() - render() + render() await expect(screen.findByPlaceholderText(/Search/)).rejects.toThrow() const run = await screen.findByText(/Run/) userEvent.click(run) @@ -22,5 +22,4 @@ test('run and clear', async () => { const clear = await screen.findByText(/Clear/) userEvent.click(clear) expect(mockClear).toBeCalled() - }) diff --git a/studio/tests/pages/projects/PreviewFilterPanel.test.js b/studio/tests/pages/projects/PreviewFilterPanel.test.js index 2165d4b7261..b478aabcacf 100644 --- a/studio/tests/pages/projects/PreviewFilterPanel.test.js +++ b/studio/tests/pages/projects/PreviewFilterPanel.test.js @@ -50,10 +50,10 @@ test('Manual refresh', async () => { }) test('Datepicker dropdown', async () => { render() - clickDropdown(await screen.findByText(/Last hour/)) + clickDropdown(await screen.findByText(/Last day/)) userEvent.click(await screen.findByText(/Last 3 hours/)) await screen.findByText(/Last 3 hours/) - await expect(screen.findByText(/Last hour/)).rejects.toThrow() + await expect(screen.findByText(/Last day/)).rejects.toThrow() }) test.todo('timestamp to/from filter default value') diff --git a/studio/tests/pages/projects/logs-query.test.js b/studio/tests/pages/projects/logs-query.test.js index 3979f81ef69..a08cf7ba040 100644 --- a/studio/tests/pages/projects/logs-query.test.js +++ b/studio/tests/pages/projects/logs-query.test.js @@ -62,7 +62,8 @@ const LogsExplorerPage = (props) => ( import { render, fireEvent, waitFor, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { logDataFixture } from '../../fixtures' - +import { clickDropdown } from 'tests/helpers' +import dayjs from 'dayjs' beforeEach(() => { // reset mocks between tests get.mockReset() @@ -80,7 +81,7 @@ test('can display log data', async () => { }), ], }) - const {container} = render() + const { container } = render() let editor = container.querySelector('.monaco-editor') await waitFor(() => { editor = container.querySelector('.monaco-editor') @@ -90,7 +91,7 @@ test('can display log data', async () => { userEvent.type(editor, 'select \ncount(*) as my_count \nfrom edge_logs') userEvent.click(await screen.findByText(/Run/)) - const row = await screen.findByText("some-event-happened") + const row = await screen.findByText('some-event-happened') userEvent.click(row) await screen.findByText(/something_value/) }) @@ -105,6 +106,27 @@ test('q= query param will populate the query input', async () => { expect(get).toHaveBeenCalledWith(expect.stringContaining('sql=some_query')) }) }) +test('ite= and its= query param will populate the datepicker', async () => { + const router = defaultRouterMock() + const start = dayjs().subtract(1, 'day') + const end = dayjs() + router.query = { + ...router.query, + type: 'api', + q: 'some_query', + its: start.toISOString(), + ite: end.toISOString(), + } + useRouter.mockReturnValue(router) + render() + // should populate editor with the query param + await waitFor(() => { + expect(get).toHaveBeenCalledWith( + expect.stringContaining(encodeURIComponent(start.toISOString())) + ) + expect(get).toHaveBeenCalledWith(expect.stringContaining(encodeURIComponent(end.toISOString()))) + }) +}) test('custom sql querying', async () => { get.mockImplementation((url) => { @@ -155,3 +177,31 @@ test('custom sql querying', async () => { // should not see chronological features await expect(screen.findByText(/Load older/)).rejects.toThrow() }) + +test('datepicker interaction updates query params', async () => { + render() + clickDropdown(await screen.findByText(/Last day/)) + userEvent.click(await screen.findByText(/Last 3 days/)) + + const router = useRouter() + expect(router.push).toBeCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + its: expect.any(String), + }), + }) + ) +}) + +test('query warnings', async () => { + const router = defaultRouterMock() + router.query = { + ...router.query, + q: 'some_query', + its: dayjs().subtract(10, 'days').toISOString(), + ite: dayjs().toISOString(), + } + useRouter.mockReturnValue(router) + render() + await screen.findByText('1 warning') +}) diff --git a/studio/types/base.ts b/studio/types/base.ts index 138490aecb1..30b2ea99d1d 100644 --- a/studio/types/base.ts +++ b/studio/types/base.ts @@ -25,13 +25,24 @@ export interface ProjectBase { } export interface Project extends ProjectBase { - // only available after projects.fetchDetail - kpsVersion?: string + // available after projects.fetchDetail connectionString?: string + kpsVersion?: string + internalApiKey?: string + restUrl?: string - // Possibly deprecated, just double check + /** + * postgrestStatus is available on client side only. + * We use this status to check if a project instance is HEALTHY or not + * If not we will show ConnectingState and run a polling until it's back online + */ + postgrestStatus?: 'ONLINE' | 'OFFLINE' + + /** + * TODO: not available from projects.fetchDetail + * Retrieve it from GET /platform/projects/:ref/subscription + */ subscription_tier?: string - subscription_tier_prod_id?: string } export interface User { diff --git a/web/docs/about.mdx b/web/docs/about.mdx index d2d8be7253d..a3522c74355 100755 --- a/web/docs/about.mdx +++ b/web/docs/about.mdx @@ -36,7 +36,7 @@ const frameworks = [ }, { name: 'Flutter', logo: DartLogo, href: '/docs/guides/with-flutter' }, { - name: 'Javascript', + name: 'JavaScript', logo: JavascriptLogo, href: 'https://github.com/supabase/supabase/tree/master/examples/javascript-auth', }, diff --git a/web/docs/guides/api.mdx b/web/docs/guides/api.mdx index 7bd540a5550..ec0773f13f0 100644 --- a/web/docs/guides/api.mdx +++ b/web/docs/guides/api.mdx @@ -160,7 +160,7 @@ values={[ ```sh 1. Go to the "API" section. 2. Find "todos" in the "Tables and Views" section. -3. Switch between the Javascript and the cURL docs using the tabs. +3. Switch between the JavaScript and the cURL docs using the tabs. ```