diff --git a/.playwright-cli/page-2026-03-31T13-15-23-214Z.yml b/.playwright-cli/page-2026-03-31T13-15-23-214Z.yml new file mode 100644 index 0000000000..f0b44d51bf --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-15-23-214Z.yml @@ -0,0 +1 @@ +- img "Appwrite Logo" [ref=e7] diff --git a/.playwright-cli/page-2026-03-31T13-15-36-196Z.yml b/.playwright-cli/page-2026-03-31T13-15-36-196Z.yml new file mode 100644 index 0000000000..e7da443b55 --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-15-36-196Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e8]: + - main [ref=e10]: + - generic [ref=e11]: + - link "Appwrite Logo" [ref=e13] [cursor=pointer]: + - /url: /console + - img "Appwrite Logo" [ref=e14] + - paragraph [ref=e17]: Build like a team of hundreds_ + - generic [ref=e20]: + - heading "Sign in" [level=3] [ref=e21] + - generic [ref=e24]: + - button "Sign in with GitHub" [ref=e26] [cursor=pointer]: + - generic [ref=e27]:  + - generic [ref=e28]: Sign in with GitHub + - generic [ref=e29]: or + - generic [ref=e30]: + - generic [ref=e31]: Email + - textbox "Email" [ref=e33] + - generic [ref=e34]: + - generic [ref=e35]: Password + - generic [ref=e36]: + - textbox "Password" [ref=e37] + - button [ref=e38] [cursor=pointer]: + - img [ref=e40] + - button "Sign in" [ref=e43] [cursor=pointer] + - list [ref=e44]: + - listitem [ref=e45]: + - link "Forgot password?" [ref=e46] [cursor=pointer]: + - /url: /console/recover + - listitem [ref=e47]: + - link "Sign up" [ref=e48] [cursor=pointer]: + - /url: /console/register?redirect=%2Fconsole%2F + - img "Appwrite Logo" diff --git a/.playwright-cli/page-2026-03-31T13-15-46-630Z.yml b/.playwright-cli/page-2026-03-31T13-15-46-630Z.yml new file mode 100644 index 0000000000..5258647a54 --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-15-46-630Z.yml @@ -0,0 +1,66 @@ +- generic [ref=e8]: + - generic [ref=e9]: + - main [ref=e50]: + - generic [ref=e51]: + - link "Appwrite Logo" [ref=e54] [cursor=pointer]: + - /url: /console + - img "Appwrite Logo" [ref=e55] + - generic [ref=e57]: + - generic [ref=e60]: + - paragraph [ref=e61]: With its comprehensive suite of services, Appwrite emerged as an ideal choice for my needs. + - generic [ref=e62]: + - img "Xue" [ref=e63] + - generic [ref=e64]: + - paragraph [ref=e65]: Xue + - paragraph [ref=e66]: Founder at LangX + - generic [ref=e67]: + - heading "LangX handled millions of requests using Appwrite" [level=4] [ref=e68] + - paragraph [ref=e69]: Join thousands of developers building amazing apps with Appwrite + - generic [ref=e72]: + - heading "Sign up" [level=3] [ref=e73] + - generic [ref=e76]: + - button "Sign up with GitHub" [ref=e78] [cursor=pointer]: + - generic [ref=e79]:  + - generic [ref=e80]: Sign up with GitHub + - generic [ref=e81]: or + - generic [ref=e82]: + - generic [ref=e83]: Name + - textbox "Name" [active] [ref=e85]: + - /placeholder: Your name + - generic [ref=e86]: + - generic [ref=e87]: Email + - textbox "Email" [ref=e89]: + - /placeholder: Your email + - generic [ref=e90]: + - generic [ref=e91]: Password + - generic [ref=e92]: + - textbox "Password" [ref=e93]: + - /placeholder: Your password + - button [ref=e94] [cursor=pointer]: + - img [ref=e96] + - generic [ref=e99]: + - img [ref=e102] + - generic [ref=e104]: Password must be at least 8 characters long + - generic [ref=e106]: + - generic [ref=e107]: + - button [ref=e108] [cursor=pointer] + - checkbox + - paragraph [ref=e110]: + - text: By registering, you agree that you have read, understand, and acknowledge our + - link "Privacy Policy" [ref=e111] [cursor=pointer]: + - /url: https://appwrite.io/privacy + - generic [ref=e112]: Privacy Policy + - text: and accept our + - link "General Terms of Use" [ref=e113] [cursor=pointer]: + - /url: https://appwrite.io/terms + - generic [ref=e114]: General Terms of Use + - text: . + - button "Sign up" [disabled] + - list [ref=e115]: + - paragraph [ref=e116]: + - text: Already got an account? + - link "Sign in" [ref=e117] [cursor=pointer]: + - /url: /console/login?redirect=%2Fconsole%2F + - generic [ref=e118]: Sign in + - generic [ref=e49]: Sign up - Appwrite + - img "Appwrite Logo" diff --git a/.playwright-cli/page-2026-03-31T13-16-12-029Z.yml b/.playwright-cli/page-2026-03-31T13-16-12-029Z.yml new file mode 100644 index 0000000000..b13285e294 --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-16-12-029Z.yml @@ -0,0 +1,70 @@ +- generic [ref=e8]: + - generic [ref=e9]: + - main [ref=e50]: + - generic [ref=e51]: + - link "Appwrite Logo" [ref=e54] [cursor=pointer]: + - /url: /console + - img "Appwrite Logo" [ref=e55] + - generic [ref=e57]: + - generic [ref=e60]: + - paragraph [ref=e61]: With its comprehensive suite of services, Appwrite emerged as an ideal choice for my needs. + - generic [ref=e62]: + - img "Xue" [ref=e63] + - generic [ref=e64]: + - paragraph [ref=e65]: Xue + - paragraph [ref=e66]: Founder at LangX + - generic [ref=e67]: + - heading "LangX handled millions of requests using Appwrite" [level=4] [ref=e68] + - paragraph [ref=e69]: Join thousands of developers building amazing apps with Appwrite + - generic [ref=e72]: + - heading "Sign up" [level=3] [ref=e73] + - generic [ref=e76]: + - button "Sign up with GitHub" [ref=e78] [cursor=pointer]: + - generic [ref=e79]:  + - generic [ref=e80]: Sign up with GitHub + - generic [ref=e81]: or + - generic [ref=e82]: + - generic [ref=e83]: Name + - textbox "Name" [ref=e85]: + - /placeholder: Your name + - text: Test User + - generic [ref=e86]: + - generic [ref=e87]: Email + - textbox "Email" [ref=e89]: + - /placeholder: Your email + - text: test@test.com + - generic [ref=e90]: + - generic [ref=e91]: Password + - generic [ref=e92]: + - textbox "Password" [ref=e93]: + - /placeholder: Your password + - text: password123! + - button [ref=e94] [cursor=pointer]: + - img [ref=e96] + - generic [ref=e99]: + - img [ref=e102] + - generic [ref=e104]: Password must be at least 8 characters long + - generic [ref=e106]: + - generic [ref=e107]: + - button [active] [ref=e108] [cursor=pointer]: + - img [ref=e121] + - checkbox [checked] + - paragraph [ref=e110]: + - text: By registering, you agree that you have read, understand, and acknowledge our + - link "Privacy Policy" [ref=e111] [cursor=pointer]: + - /url: https://appwrite.io/privacy + - generic [ref=e112]: Privacy Policy + - text: and accept our + - link "General Terms of Use" [ref=e113] [cursor=pointer]: + - /url: https://appwrite.io/terms + - generic [ref=e114]: General Terms of Use + - text: . + - button "Sign up" [ref=e123] [cursor=pointer] + - list [ref=e115]: + - paragraph [ref=e116]: + - text: Already got an account? + - link "Sign in" [ref=e117] [cursor=pointer]: + - /url: /console/login?redirect=%2Fconsole%2F + - generic [ref=e118]: Sign in + - generic [ref=e49]: Sign up - Appwrite + - img "Appwrite Logo" diff --git a/.playwright-cli/page-2026-03-31T13-16-16-015Z.yml b/.playwright-cli/page-2026-03-31T13-16-16-015Z.yml new file mode 100644 index 0000000000..b13285e294 --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-16-16-015Z.yml @@ -0,0 +1,70 @@ +- generic [ref=e8]: + - generic [ref=e9]: + - main [ref=e50]: + - generic [ref=e51]: + - link "Appwrite Logo" [ref=e54] [cursor=pointer]: + - /url: /console + - img "Appwrite Logo" [ref=e55] + - generic [ref=e57]: + - generic [ref=e60]: + - paragraph [ref=e61]: With its comprehensive suite of services, Appwrite emerged as an ideal choice for my needs. + - generic [ref=e62]: + - img "Xue" [ref=e63] + - generic [ref=e64]: + - paragraph [ref=e65]: Xue + - paragraph [ref=e66]: Founder at LangX + - generic [ref=e67]: + - heading "LangX handled millions of requests using Appwrite" [level=4] [ref=e68] + - paragraph [ref=e69]: Join thousands of developers building amazing apps with Appwrite + - generic [ref=e72]: + - heading "Sign up" [level=3] [ref=e73] + - generic [ref=e76]: + - button "Sign up with GitHub" [ref=e78] [cursor=pointer]: + - generic [ref=e79]:  + - generic [ref=e80]: Sign up with GitHub + - generic [ref=e81]: or + - generic [ref=e82]: + - generic [ref=e83]: Name + - textbox "Name" [ref=e85]: + - /placeholder: Your name + - text: Test User + - generic [ref=e86]: + - generic [ref=e87]: Email + - textbox "Email" [ref=e89]: + - /placeholder: Your email + - text: test@test.com + - generic [ref=e90]: + - generic [ref=e91]: Password + - generic [ref=e92]: + - textbox "Password" [ref=e93]: + - /placeholder: Your password + - text: password123! + - button [ref=e94] [cursor=pointer]: + - img [ref=e96] + - generic [ref=e99]: + - img [ref=e102] + - generic [ref=e104]: Password must be at least 8 characters long + - generic [ref=e106]: + - generic [ref=e107]: + - button [active] [ref=e108] [cursor=pointer]: + - img [ref=e121] + - checkbox [checked] + - paragraph [ref=e110]: + - text: By registering, you agree that you have read, understand, and acknowledge our + - link "Privacy Policy" [ref=e111] [cursor=pointer]: + - /url: https://appwrite.io/privacy + - generic [ref=e112]: Privacy Policy + - text: and accept our + - link "General Terms of Use" [ref=e113] [cursor=pointer]: + - /url: https://appwrite.io/terms + - generic [ref=e114]: General Terms of Use + - text: . + - button "Sign up" [ref=e123] [cursor=pointer] + - list [ref=e115]: + - paragraph [ref=e116]: + - text: Already got an account? + - link "Sign in" [ref=e117] [cursor=pointer]: + - /url: /console/login?redirect=%2Fconsole%2F + - generic [ref=e118]: Sign in + - generic [ref=e49]: Sign up - Appwrite + - img "Appwrite Logo" diff --git a/.playwright-cli/page-2026-03-31T13-16-30-185Z.yml b/.playwright-cli/page-2026-03-31T13-16-30-185Z.yml new file mode 100644 index 0000000000..e240dcfe1e --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-16-30-185Z.yml @@ -0,0 +1,49 @@ +- generic [ref=e8]: + - generic [ref=e9]: + - main [ref=e124]: + - generic [ref=e125]: + - generic [ref=e126]: + - link "Logo Appwrite" [ref=e127] [cursor=pointer]: + - /url: /console/organization-69cbc92d00163e3adfee + - img "Logo Appwrite" [ref=e128] + - menubar [ref=e129]: + - text: / + - menuitem "Open organizations tab" [ref=e130] [cursor=pointer]: + - generic [ref=e131]: Personal projects + - generic [ref=e133]: Free + - img [ref=e135] + - generic [ref=e138]: + - generic [ref=e139]: + - link "Upgrade" [ref=e140] [cursor=pointer]: + - /url: /console/organization-69cbc92d00163e3adfee/change-plan + - button "Feedback" [ref=e142] [cursor=pointer] + - button "Support" [ref=e144] [cursor=pointer] + - note [ref=e146]: + - button "Toggle Command Center" [ref=e147] [cursor=pointer]: + - img [ref=e149] + - button [ref=e152] [cursor=pointer] + - generic [ref=e155]: + - img "Appwrite Logo" [ref=e156] + - generic [ref=e159]: + - heading "Create your project" [level=2] [ref=e160] + - generic [ref=e162]: + - generic [ref=e163]: + - generic [ref=e164]: + - generic [ref=e165]: Name + - textbox "Project name" [active] [ref=e167]: New Project + - button "Project ID" [ref=e169] [cursor=pointer]: + - img [ref=e172] + - text: Project ID + - generic [ref=e174]: + - generic [ref=e175]: + - generic [ref=e176]: Region + - textbox: fra + - combobox [ref=e177]: + - generic [ref=e178]: + - img "Region flag" [ref=e179] + - generic [ref=e180]: Frankfurt + - img [ref=e182] + - paragraph [ref=e184]: Region cannot be changed after creation + - button "Create" [ref=e186] [cursor=pointer] + - generic [ref=e49]: Create project - Appwrite + - img "Appwrite Logo" diff --git a/.playwright-cli/page-2026-03-31T13-16-41-593Z.yml b/.playwright-cli/page-2026-03-31T13-16-41-593Z.yml new file mode 100644 index 0000000000..552d0b44ef --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-16-41-593Z.yml @@ -0,0 +1,11 @@ +- generic [active] [ref=e8]: + - generic [ref=e9]: + - main [ref=e124]: + - generic [ref=e187]: + - generic [ref=e189]: + - img [ref=e191] + - heading "Creating your project" [level=2] [ref=e194] + - generic [ref=e195]: Database services are initializing + - heading "Welcome to Appwrite" [level=2] [ref=e197] + - generic [ref=e49]: Create project - Appwrite + - img "Appwrite Logo" diff --git a/.playwright-cli/page-2026-03-31T13-16-51-897Z.yml b/.playwright-cli/page-2026-03-31T13-16-51-897Z.yml new file mode 100644 index 0000000000..da9a91aef8 --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-16-51-897Z.yml @@ -0,0 +1,192 @@ +- generic [active] [ref=e8]: + - generic [ref=e9]: + - generic [ref=e237]: + - generic [ref=e238]:  + - generic [ref=e239]: + - heading "Realtime pricing enforcement starting April 30th" [level=6] [ref=e240] + - paragraph [ref=e241]: Starting April 30th, realtime usage (connections, messages, and bandwidth) will be charged based on your plan's rates. Review your usage to avoid unexpected charges. + - generic [ref=e242]: + - link "View usage" [ref=e243] [cursor=pointer]: + - /url: /console/organization-69cbc92d00163e3adfee/usage + - generic [ref=e244]: View usage + - button [ref=e245] [cursor=pointer]: + - img [ref=e248] + - main [ref=e124]: + - generic [ref=e250]: + - generic [ref=e251]: + - link "Logo Appwrite" [ref=e252] [cursor=pointer]: + - /url: /console/organization-69cbc92d00163e3adfee + - img "Logo Appwrite" [ref=e253] + - menubar [ref=e254]: + - text: / + - menuitem "Open organizations tab" [ref=e255] [cursor=pointer]: + - generic [ref=e256]: Personal projects + - generic [ref=e258]: Free + - img [ref=e260] + - text: / + - menuitem "Open projects tab" [ref=e262] [cursor=pointer]: + - generic [ref=e263]: New Project + - img [ref=e265] + - link "Connect" [ref=e268] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/get-started + - generic [ref=e270]: + - generic [ref=e271]: + - link "Upgrade" [ref=e272] [cursor=pointer]: + - /url: /console/organization-69cbc92d00163e3adfee/change-plan + - button "Feedback" [ref=e274] [cursor=pointer] + - button "Support" [ref=e276] [cursor=pointer] + - note [ref=e278]: + - button "Toggle Command Center" [ref=e279] [cursor=pointer]: + - img [ref=e281] + - button [ref=e284] [cursor=pointer] + - generic: + - generic: + - button [ref=e285]: + - img [ref=e289] + - navigation [ref=e291]: + - generic [ref=e293]: + - note [ref=e294]: + - link "Get started 33% complete" [ref=e295] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/get-started + - img [ref=e298] + - generic [ref=e301]: + - paragraph [ref=e302]: Get started + - paragraph [ref=e303]: 33% complete + - generic [ref=e304]: + - note [ref=e305]: + - link "Overview" [ref=e306] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/overview/platforms + - img [ref=e309] + - generic [ref=e311]: Overview + - generic [ref=e313]: Build + - note [ref=e314]: + - link "Auth" [ref=e315] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/auth + - img [ref=e318] + - generic [ref=e320]: Auth + - note [ref=e321]: + - link "Databases" [ref=e322] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/databases + - img [ref=e325] + - generic [ref=e329]: Databases + - note [ref=e330]: + - link "Functions" [ref=e331] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/functions + - img [ref=e334] + - generic [ref=e336]: Functions + - note [ref=e337]: + - link "Messaging" [ref=e338] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/messaging + - img [ref=e341] + - generic [ref=e343]: Messaging + - note [ref=e344]: + - link "Storage" [ref=e345] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/storage + - img [ref=e348] + - generic [ref=e350]: Storage + - generic [ref=e352]: Deploy + - note [ref=e353]: + - link "Sites" [ref=e354] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/sites + - img [ref=e357] + - generic [ref=e359]: Sites + - note [ref=e362]: + - link "Settings" [ref=e363] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/settings + - img [ref=e366] + - generic [ref=e368]: Settings + - generic [ref=e154]: + - generic [ref=e373]: + - generic [ref=e374]: + - heading "Welcome, Test User" [level=1] [ref=e375] + - paragraph [ref=e376]: Follow a few quick steps to get started with Appwrite + - button "Dismiss this page" [ref=e378] [cursor=pointer] + - generic [ref=e384]: + - generic [ref=e391]: + - generic [ref=e393]: Done + - heading "Create project" [level=4] [ref=e397] + - generic [ref=e398]: + - img [ref=e402] + - generic [ref=e411]: + - generic [ref=e413]: Now + - generic [ref=e414]: + - generic [ref=e416]: + - heading "Connect your platform" [level=4] [ref=e417] + - generic [ref=e418]: Start building with your preferred web, mobile, and native frameworks. + - generic [ref=e419]: + - generic [ref=e420]: + - button "Web" [ref=e421] [cursor=pointer]: + - generic [ref=e424]: + - heading "Web" [level=4] [ref=e425] + - img [ref=e428] + - button "React Native" [ref=e430] [cursor=pointer]: + - generic [ref=e433]: + - heading "React Native" [level=4] [ref=e434] + - img [ref=e437] + - generic [ref=e439]: + - button "Apple" [ref=e440] [cursor=pointer]: + - generic [ref=e442]: + - heading "Apple" [level=4] [ref=e443] + - img [ref=e446] + - button "Android" [ref=e448] [cursor=pointer]: + - generic [ref=e450]: + - heading "Android" [level=4] [ref=e451] + - img [ref=e454] + - button "Flutter" [ref=e456] [cursor=pointer]: + - generic [ref=e458]: + - heading "Flutter" [level=4] [ref=e459] + - img [ref=e462] + - generic [ref=e464]: or + - button "Create API key Connect your server or backend to Appwrite" [ref=e465] [cursor=pointer]: + - generic [ref=e468]: + - generic [ref=e469]: + - heading "Create API key" [level=4] [ref=e470] + - paragraph [ref=e471]: Connect your server or backend to Appwrite + - img [ref=e474] + - generic [ref=e482]: + - generic [ref=e484]: Next + - heading "Build your app" [level=4] [ref=e486] + - generic [ref=e488]: + - separator [ref=e489] + - generic [ref=e490]: + - generic [ref=e491]: + - generic [ref=e492]: ⓒ 2026 Appwrite. All rights reserved. + - separator [ref=e494] + - generic [ref=e495]: + - link "Appwrite on Github" [ref=e496] [cursor=pointer]: + - /url: https://github.com/appwrite/appwrite + - img [ref=e498] + - link "Appwrite on Discord" [ref=e500] [cursor=pointer]: + - /url: https://appwrite.io/discord + - img [ref=e502] + - generic [ref=e504]: + - generic [ref=e505]: Generally Available + - img [ref=e507] + - link "Docs" [ref=e509] [cursor=pointer]: + - /url: https://appwrite.io/docs + - generic [ref=e510]: Docs + - separator [ref=e512] + - link "Terms" [ref=e513] [cursor=pointer]: + - /url: https://appwrite.io/terms + - generic [ref=e514]: Terms + - separator [ref=e516] + - link "Privacy" [ref=e517] [cursor=pointer]: + - /url: https://appwrite.io/privacy + - generic [ref=e518]: Privacy + - separator [ref=e520] + - link "Cookies" [ref=e521] [cursor=pointer]: + - /url: https://appwrite.io/cookies + - generic [ref=e522]: Cookies + - article [ref=e525]: + - button "Close modal" [ref=e526] [cursor=pointer]: + - img [ref=e527] + - generic [ref=e529]: + - img "Imagine" [ref=e534] + - generic [ref=e535]: + - paragraph [ref=e536]: Introducing Imagine + - generic [ref=e537]: The most complete AI builder to date + - button "Try it now" [ref=e540] [cursor=pointer]: + - generic [ref=e541]: Try it now + - text:  + - generic [ref=e49]: Console - Appwrite + - img "Appwrite Logo" diff --git a/.playwright-cli/page-2026-03-31T13-16-57-435Z.yml b/.playwright-cli/page-2026-03-31T13-16-57-435Z.yml new file mode 100644 index 0000000000..f0b44d51bf --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-16-57-435Z.yml @@ -0,0 +1 @@ +- img "Appwrite Logo" [ref=e7] diff --git a/.playwright-cli/page-2026-03-31T13-23-16-579Z.yml b/.playwright-cli/page-2026-03-31T13-23-16-579Z.yml new file mode 100644 index 0000000000..f0b44d51bf --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-23-16-579Z.yml @@ -0,0 +1 @@ +- img "Appwrite Logo" [ref=e7] diff --git a/.playwright-cli/page-2026-03-31T13-23-25-692Z.yml b/.playwright-cli/page-2026-03-31T13-23-25-692Z.yml new file mode 100644 index 0000000000..e7da443b55 --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-23-25-692Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e8]: + - main [ref=e10]: + - generic [ref=e11]: + - link "Appwrite Logo" [ref=e13] [cursor=pointer]: + - /url: /console + - img "Appwrite Logo" [ref=e14] + - paragraph [ref=e17]: Build like a team of hundreds_ + - generic [ref=e20]: + - heading "Sign in" [level=3] [ref=e21] + - generic [ref=e24]: + - button "Sign in with GitHub" [ref=e26] [cursor=pointer]: + - generic [ref=e27]:  + - generic [ref=e28]: Sign in with GitHub + - generic [ref=e29]: or + - generic [ref=e30]: + - generic [ref=e31]: Email + - textbox "Email" [ref=e33] + - generic [ref=e34]: + - generic [ref=e35]: Password + - generic [ref=e36]: + - textbox "Password" [ref=e37] + - button [ref=e38] [cursor=pointer]: + - img [ref=e40] + - button "Sign in" [ref=e43] [cursor=pointer] + - list [ref=e44]: + - listitem [ref=e45]: + - link "Forgot password?" [ref=e46] [cursor=pointer]: + - /url: /console/recover + - listitem [ref=e47]: + - link "Sign up" [ref=e48] [cursor=pointer]: + - /url: /console/register?redirect=%2Fconsole%2F + - img "Appwrite Logo" diff --git a/.playwright-cli/page-2026-03-31T13-23-37-609Z.yml b/.playwright-cli/page-2026-03-31T13-23-37-609Z.yml new file mode 100644 index 0000000000..bd69cfed6c --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-23-37-609Z.yml @@ -0,0 +1,157 @@ +- generic [active] [ref=e8]: + - generic [ref=e9]: + - generic [ref=e51]: + - generic [ref=e52]:  + - generic [ref=e53]: + - heading "Realtime pricing enforcement starting April 30th" [level=6] [ref=e54] + - paragraph [ref=e55]: Starting April 30th, realtime usage (connections, messages, and bandwidth) will be charged based on your plan's rates. Review your usage to avoid unexpected charges. + - generic [ref=e56]: + - link "View usage" [ref=e57] [cursor=pointer]: + - /url: /console/organization-69cbc92d00163e3adfee/usage + - generic [ref=e58]: View usage + - button [ref=e59] [cursor=pointer]: + - img [ref=e62] + - main [ref=e64]: + - generic [ref=e65]: + - generic [ref=e66]: + - link "Logo Appwrite" [ref=e67] [cursor=pointer]: + - /url: /console/organization-69cbc92d00163e3adfee + - img "Logo Appwrite" [ref=e68] + - menubar [ref=e69]: + - text: / + - menuitem "Open organizations tab" [ref=e70] [cursor=pointer]: + - generic [ref=e71]: Personal projects + - generic [ref=e73]: Free + - img [ref=e75] + - generic [ref=e78]: + - generic [ref=e79]: + - link "Upgrade" [ref=e80] [cursor=pointer]: + - /url: /console/organization-69cbc92d00163e3adfee/change-plan + - button "Feedback" [ref=e82] [cursor=pointer] + - button "Support" [ref=e84] [cursor=pointer] + - note [ref=e86]: + - button "Toggle Command Center" [ref=e87] [cursor=pointer]: + - img [ref=e89] + - button [ref=e92] [cursor=pointer] + - generic [ref=e94]: + - generic [ref=e97]: + - generic [ref=e98]: + - generic [ref=e99]: + - heading "Personal projects" [level=1] [ref=e100] + - generic [ref=e101]: Free + - button [ref=e102] [cursor=pointer]: + - img [ref=e104] + - note [ref=e108]: + - generic [ref=e109]: + - button "Invite" [disabled]: + - generic: + - generic: + - img + - text: Invite + - tablist [ref=e111]: + - tab "Projects" [ref=e112] [cursor=pointer] + - tab "Domains" [ref=e113] [cursor=pointer] + - tab "Members" [ref=e114] [cursor=pointer] + - tab "Usage" [ref=e115] [cursor=pointer] + - tab "Billing" [ref=e116] [cursor=pointer] + - tab "Settings" [ref=e117] [cursor=pointer] + - generic [ref=e120]: + - generic [ref=e121]: + - generic [ref=e125]: + - img [ref=e127] + - textbox "Search by name, label, or ID" [ref=e129] + - button "Create project" [ref=e130] [cursor=pointer]: + - img [ref=e133] + - text: Create project + - article [ref=e135]: + - generic [ref=e136]: + - img [ref=e139] + - generic [ref=e142]: + - generic [ref=e143]: + - paragraph [ref=e146]: Your Free plan includes up to 2 projects and limited resources. Upgrade to unlock more capacity and features. + - link "Upgrade to Pro" [ref=e148] [cursor=pointer]: + - /url: /console/organization-69cbc92d00163e3adfee/change-plan + - button [ref=e149] [cursor=pointer]: + - img [ref=e152] + - list [ref=e154]: + - link "No apps New Project Frankfurt" [ref=e155] [cursor=pointer]: + - /url: /console/project-fra-69cbc93700226e89fffb/overview/platforms + - generic [ref=e156]: + - generic [ref=e158]: + - generic [ref=e159]: No apps + - heading "New Project" [level=4] [ref=e160] + - paragraph [ref=e165]: Frankfurt + - button "create" [ref=e166] [cursor=pointer]: + - generic [ref=e167]: + - img [ref=e170] + - paragraph [ref=e172]: + - paragraph [ref=e173]: Create a new project + - generic [ref=e174]: + - generic [ref=e175]: + - generic [ref=e176]: + - textbox: '6' + - combobox "6" [ref=e177]: + - generic [ref=e178]: + - generic [ref=e179]: '6' + - img [ref=e181] + - paragraph [ref=e183]: 'Projects per page. Total: 1' + - navigation [ref=e184]: + - link "Prev" [disabled]: + - /url: '' + - generic: + - generic: + - img + - generic: Prev + - link "1" [ref=e185] [cursor=pointer]: + - /url: http://localhost:3001/console/organization-69cbc92d00163e3adfee?page=1 + - generic [ref=e186]: '1' + - link "Next" [disabled]: + - /url: '' + - generic: Next + - generic: + - generic: + - img + - generic [ref=e187]: + - separator [ref=e188] + - generic [ref=e189]: + - generic [ref=e190]: + - generic [ref=e191]: ⓒ 2026 Appwrite. All rights reserved. + - separator [ref=e193] + - generic [ref=e194]: + - link "Appwrite on Github" [ref=e195] [cursor=pointer]: + - /url: https://github.com/appwrite/appwrite + - img [ref=e197] + - link "Appwrite on Discord" [ref=e199] [cursor=pointer]: + - /url: https://appwrite.io/discord + - img [ref=e201] + - generic [ref=e203]: + - generic [ref=e204]: Generally Available + - img [ref=e206] + - link "Docs" [ref=e208] [cursor=pointer]: + - /url: https://appwrite.io/docs + - generic [ref=e209]: Docs + - separator [ref=e211] + - link "Terms" [ref=e212] [cursor=pointer]: + - /url: https://appwrite.io/terms + - generic [ref=e213]: Terms + - separator [ref=e215] + - link "Privacy" [ref=e216] [cursor=pointer]: + - /url: https://appwrite.io/privacy + - generic [ref=e217]: Privacy + - separator [ref=e219] + - link "Cookies" [ref=e220] [cursor=pointer]: + - /url: https://appwrite.io/cookies + - generic [ref=e221]: Cookies + - article [ref=e224]: + - button "Close modal" [ref=e225] [cursor=pointer]: + - img [ref=e226] + - generic [ref=e228]: + - img "Imagine" [ref=e233] + - generic [ref=e234]: + - paragraph [ref=e235]: Introducing Imagine + - generic [ref=e236]: The most complete AI builder to date + - button "Try it now" [ref=e239] [cursor=pointer]: + - generic [ref=e240]: Try it now + - text:  + - generic [ref=e49]: Organizations - Appwrite + - img "Appwrite Logo" diff --git a/.playwright-cli/page-2026-03-31T13-23-41-966Z.yml b/.playwright-cli/page-2026-03-31T13-23-41-966Z.yml new file mode 100644 index 0000000000..f0b44d51bf --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-23-41-966Z.yml @@ -0,0 +1 @@ +- img "Appwrite Logo" [ref=e7] diff --git a/.playwright-cli/page-2026-03-31T13-26-10-463Z.yml b/.playwright-cli/page-2026-03-31T13-26-10-463Z.yml new file mode 100644 index 0000000000..f0b44d51bf --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-26-10-463Z.yml @@ -0,0 +1 @@ +- img "Appwrite Logo" [ref=e7] diff --git a/.playwright-cli/page-2026-03-31T13-28-58-146Z.yml b/.playwright-cli/page-2026-03-31T13-28-58-146Z.yml new file mode 100644 index 0000000000..f0b44d51bf --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-28-58-146Z.yml @@ -0,0 +1 @@ +- img "Appwrite Logo" [ref=e7] diff --git a/.playwright-cli/page-2026-03-31T13-30-03-762Z.yml b/.playwright-cli/page-2026-03-31T13-30-03-762Z.yml new file mode 100644 index 0000000000..f0b44d51bf --- /dev/null +++ b/.playwright-cli/page-2026-03-31T13-30-03-762Z.yml @@ -0,0 +1 @@ +- img "Appwrite Logo" [ref=e7] diff --git a/bun.lock b/bun.lock index 05bdf932dd..c06d534927 100644 --- a/bun.lock +++ b/bun.lock @@ -1,18 +1,20 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "@appwrite/console", "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@8f00f95", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console~feat-dedicated-db", "@appwrite.io/pink-icons": "0.25.0", "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3", "@appwrite.io/pink-legacy": "^1.0.3", "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8dcaa17", "@codemirror/autocomplete": "^6.19.0", "@codemirror/commands": "^6.9.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", "@codemirror/language": "^6.11.3", "@codemirror/lint": "^6.9.0", "@codemirror/search": "^6.5.11", @@ -59,6 +61,7 @@ "@testing-library/svelte": "^5.3.1", "@testing-library/user-event": "^14.6.1", "@types/deep-equal": "^1.0.4", + "@types/json-bigint": "^1.0.4", "@types/remarkable": "^2.0.8", "@types/three": "^0.182.0", "@typescript-eslint/eslint-plugin": "^8.57.2", @@ -100,7 +103,7 @@ "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uM7kpZB5l977lW7+2X1+klBUxIZQ78+1a9jHlaHFEzcOcmmslTl3sdP0QqfuuBcO0YBM2gwOiqVdp8i4TRQYcw=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.83", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LvlWujbSdEkTBXBLFtF7GS6riXdHhH0O+DpDrCaNQvXeHmSF2jKsOg7JWXiCgygAHM5cWFAO3JYmZp83DjiuBQ=="], "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], @@ -124,15 +127,15 @@ "@analytics/type-utils": ["@analytics/type-utils@0.6.4", "", {}, "sha512-Ou1gQxFakOWLcPnbFVsrPb8g1wLLUZYYJXDPjHkG07+5mustGs5yqACx42UAu4A6NszNN6Z5gGxhyH45zPWRxw=="], - "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@8f00f95", { "dependencies": { "json-bigint": "1.0.0" } }], + "@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console~feat-dedicated-db", { "dependencies": { "json-bigint": "1.0.0" } }, "sha512-GZM9CBLffwX0rDL5ty2m8yYkMb47Ih57f1ej20g9V/4CNr3HYjp6BNFtiulf5dOohgJQR3y+6OKrMGgWVx9BDw=="], "@appwrite.io/pink-icons": ["@appwrite.io/pink-icons@0.25.0", "", {}, "sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q=="], - "@appwrite.io/pink-icons-svelte": ["@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3", { "peerDependencies": { "svelte": "^4.0.0" } }], + "@appwrite.io/pink-icons-svelte": ["@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3", { "peerDependencies": { "svelte": "^4.0.0" } }, "sha512-2HYl/CC2OlfZIR7LzbLXuSPBn0iNkjbnxpaeGCkZ7UNZ/hFeSeeWjDJqTBMdZ8+X95uuZqHx62XPTiE/svuSXQ=="], "@appwrite.io/pink-legacy": ["@appwrite.io/pink-legacy@1.0.3", "", { "dependencies": { "@appwrite.io/pink-icons": "1.0.0", "the-new-css-reset": "^1.11.2" } }, "sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ=="], - "@appwrite.io/pink-svelte": ["@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8dcaa17", { "dependencies": { "@appwrite.io/pink-icons-svelte": "2.0.0-RC.1", "@floating-ui/dom": "^1.6.13", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.6", "@tanstack/svelte-virtual": "^3.13.10", "ansicolor": "^2.0.3", "d3": "^7.9.0", "fuse.js": "^7.1.0", "pretty-bytes": "^6.1.1", "shiki": "^1.18.0", "svelte-motion": "^0.12.2", "svelte-sonner": "^0.3.28" }, "peerDependencies": { "svelte": "^4.0.0" } }], + "@appwrite.io/pink-svelte": ["@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8dcaa17", { "dependencies": { "@appwrite.io/pink-icons-svelte": "2.0.0-RC.1", "@floating-ui/dom": "^1.6.13", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.6", "@tanstack/svelte-virtual": "^3.13.10", "ansicolor": "^2.0.3", "d3": "^7.9.0", "fuse.js": "^7.1.0", "pretty-bytes": "^6.1.1", "shiki": "^1.18.0", "svelte-motion": "^0.12.2", "svelte-sonner": "^0.3.28" }, "peerDependencies": { "svelte": "^4.0.0" } }, "sha512-rw3zXN7/cUciCnhj0FR8M0H5Db+LYYMaKtPxvOAIMxNTBmStzU8kTw6grqIvdtFu9vybIsjKtIwm9QLHpNDBjA=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], @@ -174,7 +177,11 @@ "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], - "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="], + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + + "@codemirror/language": ["@codemirror/language@6.12.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg=="], "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], @@ -182,7 +189,7 @@ "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], - "@codemirror/view": ["@codemirror/view@6.41.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="], + "@codemirror/view": ["@codemirror/view@6.40.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], @@ -250,11 +257,15 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], + "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], - "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], + "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + + "@lezer/lr": ["@lezer/lr@1.4.8", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA=="], "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], @@ -468,7 +479,7 @@ "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], - "@sveltejs/kit": ["@sveltejs/kit@2.57.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw=="], + "@sveltejs/kit": ["@sveltejs/kit@2.59.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], @@ -492,9 +503,9 @@ "@threejs-kit/instanced-sprite-mesh": ["@threejs-kit/instanced-sprite-mesh@2.5.1", "", { "dependencies": { "diet-sprite": "^0.0.1", "earcut": "^2.2.4", "maath": "^0.10.7", "three-instanced-uniforms-mesh": "^0.52.4", "troika-three-utils": "^0.52.4" }, "peerDependencies": { "three": ">=0.170.0" } }, "sha512-pmt1ALRhbHhCJQTj2FuthH6PeLIeaM4hOuS2JO3kWSwlnvx/9xuUkjFR3JOi/myMqsH7pSsLIROSaBxDfttjeA=="], - "@threlte/core": ["@threlte/core@8.5.2", "", { "dependencies": { "mitt": "^3.0.1" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-noxIsYlEYRFBo0U3T8Z4PWkfe23VCDxaHIlSzSWlOlBgd+mhKrhyM8lFmeznmZQS78z4obkWUJeYxx/jauD+rw=="], + "@threlte/core": ["@threlte/core@8.5.4", "", { "dependencies": { "mitt": "^3.0.1" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-hqFkD0/CHVUFh/FavLKCI5snYqwGL4InO9hpYgbf+XirKyx/atqiDoiCv/gVhPjQAojojyur8Frj+lNu8u/J5Q=="], - "@threlte/extras": ["@threlte/extras@9.13.0", "", { "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", "camera-controls": "^3.1.2", "three-mesh-bvh": "^0.9.1", "three-perf": "^1.0.11", "three-viewport-gizmo": "^2.2.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-fHt5VcOoXyBT+wuytRHlKa1SUxNEI15L/kpudVWE9Z0G+U5TWIf01mt0BdQBGJOqwVJYxwFzRJQxfS2/kLAl9Q=="], + "@threlte/extras": ["@threlte/extras@9.13.3", "", { "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", "camera-controls": "^3.1.2", "three-mesh-bvh": "^0.9.1", "three-perf": "^1.0.11", "three-viewport-gizmo": "^2.2.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-ElXna1kGuS9b9pCG5swChpwpbrDxoMVxchCG8mXjjcXk9HW8qZDicf2mq0SBll2fLP0Nb8iGlkskSJ04qqWCGg=="], "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], @@ -518,6 +529,8 @@ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/json-bigint": ["@types/json-bigint@1.0.4", "", {}, "sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -600,7 +613,7 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ai": ["ai@6.0.138", "", { "dependencies": { "@ai-sdk/gateway": "3.0.80", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-49OfPe0f5uxJ6jUdA5BBXjIinP6+ZdYfAtpF2aEH64GA5wPcxH2rf/TBUQQ0bbamBz/D+TLMV18xilZqOC+zaA=="], + "ai": ["ai@6.0.141", "", { "dependencies": { "@ai-sdk/gateway": "3.0.83", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+GomGQWaId3xN0wcugUW/H7xMMaFkID2PiS7K/Wugj45G3efv0BXhQ3psRZoQVoRbOpdNoUqcK/KTB+FR4h6qg=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], diff --git a/e2e/journeys/dedicated-databases.spec.ts b/e2e/journeys/dedicated-databases.spec.ts new file mode 100644 index 0000000000..bbdf79e5e6 --- /dev/null +++ b/e2e/journeys/dedicated-databases.spec.ts @@ -0,0 +1,1586 @@ +import { test, expect, type Page } from '@playwright/test'; + +const PROJECT_ID = '69c5061ee68ebce1a541'; +const REGION = 'fra'; + +const SESSION_COOKIE = { + name: 'a_session_console_legacy', + value: 'eyJpZCI6IjY5YzUwNjFlNjQ4ZTEzYjBiZDhkIiwic2VjcmV0IjoiMzYzMWNiOWY1YjJiYjU5MTUyNjU0ZGIxZGMzNTMxNmU5OWZkMmM5NDc0NzcyY2IzNmM4MGEwNzRlODMwMTRlYzJhZjFmOTQ4NDBkNmRjNzQzNDViOGExMzg2YzRjNzVhNTUwNzExMDczZDQ4OThkNTg4ZjYyN2UxNmUwN2VmNTYwNzhmMjQ2MThlMDk0ZmY5YWM1MzMwOTI2MzNkNGQwYTIzZGZkNTdmNjY0MGVjZjU3YTJhOWQ4NzA1OThjZDBlYmRmOWRiYTM5OTI1YWY4NDU3Yzc2MTczNjc4YTk0YTIyNWU0YmU1YWRkMGQ1ZWVmYmQwNmYwMWJhYmZhNGJlNiJ9', + domain: 'localhost', + path: '/' +}; + +const DATABASES_URL = `project-${REGION}-${PROJECT_ID}/databases`; +const CREATE_URL = `${DATABASES_URL}/create`; + +async function authenticate(page: Page) { + await page.context().addCookies([SESSION_COOKIE]); +} + +/** Wait for the create page to finish loading by checking for a known element. */ +async function waitForCreatePage(page: Page, marker: string = 'Details') { + await page.waitForSelector(`text=${marker}`, { timeout: 15_000 }); +} + +/** Change a Pink UI InputSelect by setting the hidden select value and dispatching change. */ +async function changeSelect(page: Page, id: string, value: string) { + await page.evaluate( + ({ id, value }) => { + const select = document.querySelector(`#${id}`) as HTMLSelectElement | null; + if (!select) throw new Error(`#${id} not found`); + select.value = value; + select.dispatchEvent(new Event('change', { bubbles: true })); + }, + { id, value } + ); + // Give Svelte time to react + await page.waitForTimeout(500); +} + +async function selectEngine(page: Page, value: string) { + await changeSelect(page, 'engine', value); +} + +async function selectTier(page: Page, value: string) { + await changeSelect(page, 'tier', value); +} + +/** Select a backup policy preset by clicking its Card.Selector radio. */ +async function selectBackupPreset(page: Page, id: string) { + // Card.Selector renders an input[type=radio] with the given id + await page.evaluate((id) => { + const input = document.getElementById(id) as HTMLInputElement; + if (!input) throw new Error(`#${id} not found`); + input.click(); + }, id); + await page.waitForTimeout(500); +} + +/** Submit the create form and wait for navigation or notification. */ +async function submitAndWaitForCreation(page: Page, name: string) { + // Listen for the API response before clicking + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/compute/databases') && resp.request().method() === 'POST', + { timeout: 180_000 } + ); + + await page.getByRole('button', { name: /Create/ }).click(); + + // Wait for the API response — skip if backend is unavailable, rejects, or times out + let response; + try { + response = await responsePromise; + } catch { + test.skip(true, 'Compute API timed out'); + return; + } + if (response.status() >= 400) { + test.skip(true, `Compute API returned ${response.status()}`); + return; + } + + // Wait for navigation or notification + await Promise.race([ + page.waitForURL(/databases\/database-/, { timeout: 30_000 }), + page.waitForSelector(`text=${name} has been created`, { timeout: 30_000 }) + ]).catch(() => { + // Creation succeeded (API returned OK) even if navigation didn't complete + }); +} + +/** Extract the database ID from the current URL after successful creation. */ +function extractDatabaseId(page: Page): string | null { + const match = page.url().match(/database-([^/]+)/); + return match ? match[1] : null; +} + +/** Navigate to a specific database by its ID. */ +function databaseUrl(databaseId: string): string { + return `${DATABASES_URL}/database-${databaseId}`; +} + +/** Navigate to the first database from the list page. Returns true if successful. */ +async function navigateToFirstDatabase(page: Page): Promise { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + // Try table row first, then grid card link + const dbLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await dbLink.isVisible().catch(() => false))) return false; + + await dbLink.click(); + await page.waitForURL(/databases\/database-/, { timeout: 15_000 }); + await page.waitForLoadState('networkidle'); + return true; +} + +test.describe('Dedicated databases', () => { + test.beforeEach(async ({ page }) => { + // @todo These tests require a pre-seeded project with dedicated database support. + // Skip in CI until a dedicated test environment is available. + test.skip(!!process.env.CI, 'Requires pre-seeded dedicated database environment'); + await authenticate(page); + }); + + test.describe('Create page - type selection', () => { + test('shows all four database type selectors', async ({ page }) => { + await page.goto(CREATE_URL); + await waitForCreatePage(page, 'Database type'); + + await expect(page.getByText('TablesDB')).toBeVisible(); + await expect(page.getByText('DocumentsDB')).toBeVisible(); + await expect(page.getByText('Shared (Free)')).toBeVisible(); + await expect(page.getByText('DedicatedDB')).toBeVisible(); + }); + + test('dedicated type reveals configuration section', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Configuration'); + + await expect(page.getByText('Configuration')).toBeVisible(); + await expect(page.getByText('Database Engine')).toBeVisible(); + await expect(page.getByText('Resource Tier')).toBeVisible(); + await expect(page.getByText('Enable High Availability', { exact: true })).toBeVisible(); + }); + + test('tablesdb type does not show configuration section', async ({ page }) => { + await page.goto(CREATE_URL); + await waitForCreatePage(page, 'Database type'); + + await page.getByText('TablesDB').click(); + await expect(page.getByText('Configuration')).not.toBeVisible(); + }); + + test('shared type shows free tier limits', async ({ page }) => { + await page.goto(CREATE_URL); + await waitForCreatePage(page, 'Database type'); + + await page.goto(`${CREATE_URL}?type=shared`); + await page.waitForSelector('text=Free tier limits', { timeout: 15_000 }); + + const limitsSection = page.locator('fieldset', { hasText: 'Free tier limits' }); + await expect(limitsSection.getByText('128 MB')).toBeVisible(); + await expect(limitsSection.getByText('0.125 vCPU')).toBeVisible(); + await expect(limitsSection.getByText('1 GB')).toBeVisible(); + }); + + test('documentsdb type does not show configuration section', async ({ page }) => { + await page.goto(CREATE_URL); + await waitForCreatePage(page, 'Database type'); + + await page.getByText('DocumentsDB').click(); + await expect(page.getByText('Configuration')).not.toBeVisible(); + }); + }); + + test.describe('Create page - URL params', () => { + test('?type=dedicated skips type selection and shows configuration', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Configuration'); + + await expect(page.getByText('Database type')).not.toBeVisible(); + await expect(page.getByText('Database Engine')).toBeVisible(); + await expect(page.getByText('Resource Tier')).toBeVisible(); + }); + + test('?type=shared skips type selection and shows free tier limits', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=shared`); + await waitForCreatePage(page); + + await expect(page.getByText('Database type')).not.toBeVisible(); + await expect(page.getByText('Free tier limits')).toBeVisible(); + }); + + test('?type=tablesdb skips type selection and shows tablesdb form', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=tablesdb`); + await waitForCreatePage(page); + + await expect(page.getByText('Database type')).not.toBeVisible(); + // TablesDB does not show Configuration section + await expect(page.getByText('Configuration')).not.toBeVisible(); + // But it shows the name field + await expect(page.locator('#name')).toBeVisible(); + }); + + test('URL params pre-populate engine, tier, and name', async ({ page }) => { + await page.goto( + `${CREATE_URL}?type=dedicated&engine=mysql&tier=s-1vcpu-1gb&name=TestDB` + ); + await waitForCreatePage(page, 'Configuration'); + + // Name should be pre-filled + await expect(page.locator('#name')).toHaveValue('TestDB'); + // Engine should be MySQL + const engineCombobox = page.locator('#engine').locator('..').getByRole('combobox'); + await expect(engineCombobox).toContainText('MySQL'); + // Tier should be Starter + const tierCombobox = page.locator('#tier').locator('..').getByRole('combobox'); + await expect(tierCombobox).toContainText('Starter'); + }); + + test('URL params pre-populate HA as enabled', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-1vcpu-1gb&ha=true`); + await waitForCreatePage(page, 'Configuration'); + + await expect(page.locator('#ha')).toBeChecked(); + }); + + test('free tier backup shows upgrade alert', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=free`); + await waitForCreatePage(page, 'Backups'); + + await expect(page.getByText('Backups unavailable on free tier')).toBeVisible(); + }); + }); + + test.describe('Create page - engine defaults and options', () => { + test('engine defaults to PostgreSQL', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Database Engine'); + + const engineCombobox = page.locator('#engine').locator('..').getByRole('combobox'); + await expect(engineCombobox).toContainText('PostgreSQL'); + }); + + test('engine options are present', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&engine=mysql`); + await waitForCreatePage(page, 'Database Engine'); + + // Verify engine pre-populated from URL param + await expect(page.getByText('Database Engine')).toBeVisible(); + }); + }); + + test.describe('Create page - tier and pricing', () => { + test('free tier shows $0.00/mo', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Estimated total'); + + await expect(page.getByRole('button', { name: /\$0\.00\/mo/ })).toBeVisible(); + }); + + test('starter tier shows $15.00/mo via URL param', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-1vcpu-1gb`); + await waitForCreatePage(page, 'Estimated total'); + + await expect(page.getByRole('button', { name: /\$15\.00\/mo/ })).toBeVisible(); + }); + + test('standard tier shows $30.00/mo via URL param', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-2vcpu-2gb`); + await waitForCreatePage(page, 'Estimated total'); + + await expect(page.getByRole('button', { name: /\$30\.00\/mo/ })).toBeVisible(); + }); + + test('standard plus tier shows $60.00/mo via URL param', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-2vcpu-4gb`); + await waitForCreatePage(page, 'Estimated total'); + + await expect(page.getByRole('button', { name: /\$60\.00\/mo/ })).toBeVisible(); + }); + + test('professional tier shows $100.00/mo via URL param', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-4vcpu-8gb`); + await waitForCreatePage(page, 'Estimated total'); + + await expect(page.getByRole('button', { name: /\$100\.00\/mo/ })).toBeVisible(); + }); + + test('HA doubles estimated cost for starter tier', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-1vcpu-1gb&ha=true`); + await waitForCreatePage(page, 'Estimated total'); + + await expect(page.getByText('High availability replica')).toBeVisible(); + await expect(page.getByRole('button', { name: /\$30\.00\/mo/ })).toBeVisible(); + }); + + test('HA doubles estimated cost for standard tier', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-2vcpu-2gb&ha=true`); + await waitForCreatePage(page, 'Estimated total'); + + await expect(page.getByRole('button', { name: /\$60\.00\/mo/ })).toBeVisible(); + }); + + test('HA is disabled on free tier', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Resource Tier'); + + // HA checkbox should be disabled on free tier + await expect(page.locator('#ha')).toBeDisabled(); + // Price should remain $0 + await expect(page.getByRole('button', { name: /\$0\.00\/mo/ })).toBeVisible(); + }); + + test('estimated cost section shows line items and total', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Estimated cost'); + + await expect(page.getByText('Estimated total')).toBeVisible(); + await expect(page.getByText("You'll be charged")).toBeVisible(); + }); + }); + + test.describe('Create page - backup options', () => { + const PAID_CREATE = `${CREATE_URL}?type=dedicated&tier=s-1vcpu-1gb`; + + test('paid tier shows backup presets: Daily, Hourly, No backup', async ({ page }) => { + await page.goto(PAID_CREATE); + await waitForCreatePage(page, 'Backups'); + + await expect(page.getByText('Daily', { exact: true })).toBeVisible(); + await expect(page.getByText('Hourly', { exact: true })).toBeVisible(); + await expect(page.getByText('No backup', { exact: true })).toBeVisible(); + }); + + test('daily backup is selected by default on paid tier', async ({ page }) => { + await page.goto(PAID_CREATE); + await waitForCreatePage(page, 'Backups'); + + await expect(page.getByText('Retention period')).toBeVisible(); + }); + + test('no-backup URL param hides retention and PITR options', async ({ page }) => { + await page.goto(`${PAID_CREATE}&backup=none`); + await waitForCreatePage(page, 'Backups'); + + await expect(page.getByText('Retention period')).not.toBeVisible(); + }); + + test('daily backup shows retention and PITR options', async ({ page }) => { + await page.goto(`${PAID_CREATE}&backup=daily`); + await waitForCreatePage(page, 'Backups'); + + await expect(page.getByText('Retention period')).toBeVisible(); + await expect(page.getByText('Enable Point-in-Time Recovery (PITR)')).toBeVisible(); + }); + + test('PITR URL param shows PITR retention window selector', async ({ page }) => { + await page.goto(`${PAID_CREATE}&pitr=true`); + await waitForCreatePage(page, 'Backups'); + + await expect(page.getByText('PITR retention window')).toBeVisible({ timeout: 10_000 }); + }); + + test('shared type shows no-backup alert', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=shared`); + await waitForCreatePage(page, 'Backups'); + + await expect(page.getByText('No backups on free tier')).toBeVisible(); + }); + }); + + test.describe.serial('Create and manage dedicated databases', () => { + test.setTimeout(240_000); // Provisioning can take 2+ minutes per database + const createdDatabases: { name: string; id: string; engine: string }[] = []; + + test.beforeEach(async ({ page }) => { + await authenticate(page); + }); + + test('create a free-tier PostgreSQL database', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Configuration'); + + const name = `e2e-pg-free-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'postgres' }); + }); + + test('create a free-tier MySQL database', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Configuration'); + + const name = `e2e-mysql-free-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + await selectEngine(page, 'mysql'); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'mysql' }); + }); + + test('create a free-tier MariaDB database', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Configuration'); + + const name = `e2e-mariadb-free-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + await selectEngine(page, 'mariadb'); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'mariadb' }); + }); + + test('create a free-tier MongoDB database', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated`); + await waitForCreatePage(page, 'Configuration'); + + const name = `e2e-mongo-free-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + await selectEngine(page, 'mongodb'); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'mongodb' }); + }); + + test('create a starter-tier PostgreSQL with HA enabled', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-1vcpu-1gb&ha=true`); + await waitForCreatePage(page, 'Estimated total'); + + const name = `e2e-pg-starter-ha-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + + // Verify cost doubled via URL params + await expect(page.getByRole('button', { name: /\$30\.00\/mo/ })).toBeVisible(); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'postgres' }); + }); + + test('create a dedicated database with daily backup preset', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-1vcpu-1gb`); + await waitForCreatePage(page, 'Configuration'); + + const name = `e2e-pg-daily-backup-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + // Daily is the default, so just verify it is selected + await expect(page.getByText('Retention period')).toBeVisible(); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'postgres' }); + }); + + test('create a dedicated database with hourly backup preset', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-1vcpu-1gb&backup=hourly`); + await waitForCreatePage(page, 'Configuration'); + + const name = `e2e-pg-hourly-backup-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'postgres' }); + }); + + test('create a dedicated database with no backup', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&backup=none`); + await waitForCreatePage(page, 'Configuration'); + + const name = `e2e-pg-no-backup-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'postgres' }); + }); + + test('create a dedicated database with PITR enabled', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=dedicated&tier=s-1vcpu-1gb&pitr=true`); + await waitForCreatePage(page, 'Configuration'); + + const name = `e2e-pg-pitr-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + await expect(page.getByText('PITR retention window')).toBeVisible(); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'postgres' }); + }); + + test('create a shared (free) database', async ({ page }) => { + await page.goto(`${CREATE_URL}?type=shared`); + await waitForCreatePage(page); + + const name = `e2e-shared-free-${Date.now()}`; + await page.getByRole('textbox', { name: 'Name' }).fill(name); + + await submitAndWaitForCreation(page, name); + + const id = extractDatabaseId(page); + expect(id).toBeTruthy(); + createdDatabases.push({ name, id: id!, engine: 'postgres' }); + }); + + test('database list shows created databases', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + // The list page should have a create button + await expect(page.getByRole('button', { name: /Create database/ })).toBeVisible(); + + // At least one database link should be visible + const databaseLinks = page.locator('a[href*="/databases/database-"]'); + await expect(databaseLinks.first()).toBeVisible({ timeout: 10_000 }); + }); + }); + + test.describe('Database overview', () => { + test.beforeEach(async ({ page }) => { + await authenticate(page); + }); + + test('dedicated database overview shows status card', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + // Click the first database row to navigate + const databaseRow = page.locator('table').getByRole('row').nth(1); + if (!(await databaseRow.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseRow.click(); + await page.waitForURL(/databases\/database-/, { timeout: 15_000 }); + await page.waitForLoadState('networkidle'); + + // Check for either the dedicated overview or tables view + const statusCard = page.getByText('Status', { exact: true }); + const tablesView = page.getByText('Tables', { exact: true }); + + await expect(statusCard.or(tablesView).first()).toBeVisible({ timeout: 10_000 }); + }); + + test('dedicated overview renders status badge', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + // Navigate to the first database + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + // If it is a dedicated overview, check status-related elements + if ( + await page + .getByText('Status', { exact: true }) + .isVisible() + .catch(() => false) + ) { + // Status badge should be present (Ready, Provisioning, etc.) + const statusTexts = ['Ready', 'Provisioning', 'Active', 'Paused', 'Failed']; + const results = await Promise.all( + statusTexts.map((s) => + page + .getByText(s) + .isVisible() + .catch(() => false) + ) + ); + expect(results.some(Boolean)).toBeTruthy(); + } + }); + + test('dedicated overview shows resources section with engine info', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + // For dedicated databases, the overview shows Resources + const resources = page.getByText('Resources', { exact: true }); + if (await resources.isVisible().catch(() => false)) { + await expect(page.getByText('Engine', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('CPU', { exact: true })).toBeVisible(); + await expect(page.getByText('Memory', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('Storage', { exact: true }).first()).toBeVisible(); + } + }); + + test('dedicated overview shows refresh button', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + if ( + await page + .getByText('Status', { exact: true }) + .isVisible() + .catch(() => false) + ) { + const refreshButton = page.getByRole('button', { name: /Refresh/ }); + await expect(refreshButton).toBeVisible(); + } + }); + + test('dedicated overview shows connection section or provisioning state', async ({ + page + }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + if ( + await page + .getByText('Status', { exact: true }) + .isVisible() + .catch(() => false) + ) { + // Should show either connection details or a provisioning message + const connectionTitle = page.getByText('Connection', { exact: true }); + const provisioningMessage = page.getByText('Provisioning in progress'); + const credentialsProvisioning = page.getByText('Credentials provisioning'); + + const visibilities = await Promise.all([ + connectionTitle.isVisible().catch(() => false), + provisioningMessage.isVisible().catch(() => false), + credentialsProvisioning.isVisible().catch(() => false) + ]); + + expect(visibilities.some(Boolean)).toBeTruthy(); + } + }); + + test('dedicated overview shows high availability section', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + if ( + await page + .getByText('Status', { exact: true }) + .isVisible() + .catch(() => false) + ) { + await expect(page.getByText('High Availability')).toBeVisible(); + } + }); + + test('dedicated overview shows network section', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + if ( + await page + .getByText('Status', { exact: true }) + .isVisible() + .catch(() => false) + ) { + await expect(page.getByText('Network', { exact: true })).toBeVisible(); + } + }); + + test('dedicated overview shows backups section', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + if ( + await page + .getByText('Status', { exact: true }) + .isVisible() + .catch(() => false) + ) { + await expect(page.getByText('Backups', { exact: true }).first()).toBeVisible(); + } + }); + + test('dedicated overview does NOT show security card', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + if ( + await page + .getByText('Status', { exact: true }) + .isVisible() + .catch(() => false) + ) { + // Security card was removed (encryption at rest is infra-level) + await expect(page.getByText('Encryption at Rest')).not.toBeVisible(); + await expect(page.getByText('Key Management')).not.toBeVisible(); + } + }); + + test('dedicated overview network uses correct labels', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + if ( + await page + .getByText('Network', { exact: true }) + .isVisible() + .catch(() => false) + ) { + await expect(page.getByText('Connection Timeout')).toBeVisible(); + // "Sleep After Idle" was renamed to "Scale-to-Zero After" + await expect(page.getByText('Sleep After Idle')).not.toBeVisible(); + } + }); + }); + + test.describe('Navigation tabs', () => { + test.beforeEach(async ({ page }) => { + await authenticate(page); + }); + + test('dedicated database header shows Overview tab instead of Tables', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + // For dedicated databases, the first tab should be "Overview" + const overviewTab = page.getByRole('link', { name: 'Overview' }); + if (await overviewTab.isVisible().catch(() => false)) { + await expect(overviewTab).toBeVisible(); + } + }); + + test('dedicated database has Backups tab', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + const backupsTab = page.getByRole('link', { name: 'Backups' }); + await expect(backupsTab).toBeVisible(); + }); + + test('dedicated database has Auth tab in header', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + // Auth tab only visible for dedicated databases — scope to header tabs area + const tabsArea = page.locator('[class*="tabs"], nav').filter({ hasText: 'Backups' }); + const authTab = tabsArea.getByRole('link', { name: 'Auth' }); + if ( + await page + .getByRole('link', { name: 'Overview' }) + .first() + .isVisible() + .catch(() => false) + ) { + await expect(authTab).toBeVisible({ timeout: 10_000 }); + } + }); + + test('clicking Auth tab navigates to auth page', async ({ page }) => { + if (!(await navigateToFirstDatabase(page))) { + test.skip(); + return; + } + + const authTab = page.getByRole('link', { name: 'Auth' }); + if (!(await authTab.isVisible().catch(() => false))) { + test.skip(); + return; + } + + await authTab.click(); + await page.waitForURL(/\/auth/, { timeout: 15_000 }); + expect(page.url()).toContain('/auth'); + }); + + test('dedicated database has Monitoring tab', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + // Monitoring tab only visible for dedicated databases + const monitoringTab = page.getByRole('link', { name: 'Monitoring' }); + if (await monitoringTab.isVisible().catch(() => false)) { + await expect(monitoringTab).toBeVisible(); + } + }); + + test('dedicated database has Usage tab', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + await expect(page.getByRole('link', { name: 'Usage' })).toBeVisible(); + }); + + test('dedicated database has Settings tab', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + await expect(page.getByRole('link', { name: 'Settings' }).last()).toBeVisible(); + }); + + test('clicking Backups tab navigates to backups page', async ({ page }) => { + if (!(await navigateToFirstDatabase(page))) { + test.skip(); + return; + } + + const backupsTab = page.getByRole('tab', { name: 'Backups' }); + await backupsTab.click(); + await page.waitForURL(/\/backups/, { timeout: 15_000 }); + + expect(page.url()).toContain('/backups'); + }); + + test('clicking Settings tab navigates to settings page', async ({ page }) => { + if (!(await navigateToFirstDatabase(page))) { + test.skip(); + return; + } + + const settingsTab = page.getByRole('tab', { name: 'Settings' }); + await settingsTab.click(); + await page.waitForURL(/\/settings/, { timeout: 15_000 }); + + expect(page.url()).toContain('/settings'); + }); + + test('clicking Usage tab navigates to usage page', async ({ page }) => { + if (!(await navigateToFirstDatabase(page))) { + test.skip(); + return; + } + + const usageTab = page.getByRole('tab', { name: 'Usage' }); + await usageTab.click(); + await page.waitForURL(/\/usage/, { timeout: 15_000 }); + + expect(page.url()).toContain('/usage'); + }); + }); + + test.describe('Sidebar sub-navigation', () => { + test.beforeEach(async ({ page }) => { + await authenticate(page); + }); + + test('dedicated database sidebar shows Backups link', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + const sidebarBackups = page + .locator('a[href*="/backups"]') + .filter({ hasText: 'Backups' }); + if ( + await sidebarBackups + .first() + .isVisible() + .catch(() => false) + ) { + await expect(sidebarBackups.first()).toBeVisible(); + } + }); + + test('dedicated database sidebar shows Auth link', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + const sidebarAuth = page.locator('a[href*="/auth"]').filter({ hasText: 'Auth' }); + if ( + await sidebarAuth + .first() + .isVisible() + .catch(() => false) + ) { + await expect(sidebarAuth.first()).toBeVisible(); + } + }); + + test('dedicated database sidebar shows Monitoring link', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + const sidebarMonitoring = page + .locator('a[href*="/monitoring"]') + .filter({ hasText: 'Monitoring' }); + if ( + await sidebarMonitoring + .first() + .isVisible() + .catch(() => false) + ) { + await expect(sidebarMonitoring.first()).toBeVisible(); + } + }); + + test('dedicated database sidebar shows Settings link', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + const sidebarSettings = page + .locator('a[href*="/settings"]') + .filter({ hasText: 'Settings' }); + if ( + await sidebarSettings + .first() + .isVisible() + .catch(() => false) + ) { + await expect(sidebarSettings.first()).toBeVisible(); + } + }); + + test('dedicated database sidebar shows Usage link', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) { + test.skip(); + return; + } + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + const sidebarUsage = page.locator('a[href*="/usage"]').filter({ hasText: 'Usage' }); + if ( + await sidebarUsage + .first() + .isVisible() + .catch(() => false) + ) { + await expect(sidebarUsage.first()).toBeVisible(); + } + }); + }); + + test.describe('Settings page', () => { + test.beforeEach(async ({ page }) => { + await authenticate(page); + }); + + /** Navigate to the first database's settings page. Returns true if successful. */ + async function navigateToSettings(page: Page): Promise { + if (!(await navigateToFirstDatabase(page))) return false; + + const settingsTab = page.getByRole('tab', { name: 'Settings' }); + if (!(await settingsTab.isVisible().catch(() => false))) return false; + + await settingsTab.click(); + await page.waitForURL(/\/settings/, { timeout: 15_000 }); + return true; + } + + test('delete button uses danger styling', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + // The dedicated dangerZone component uses a Button with `danger` prop + const deleteButton = page.getByRole('button', { name: 'Delete' }); + if (await deleteButton.isVisible().catch(() => false)) { + // The `danger` prop adds the `is-danger` class + await expect(deleteButton).toHaveClass(/danger/); + } + }); + + test('settings page shows name section', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + // UpdateName component is rendered for all dedicated database types + const nameInput = page.locator('#name'); + if (await nameInput.isVisible().catch(() => false)) { + await expect(nameInput).toBeVisible(); + } + }); + + test('settings page shows high availability section', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const haTitle = page.getByText('High availability', { exact: true }); + if (await haTitle.isVisible().catch(() => false)) { + await expect(haTitle).toBeVisible(); + } + }); + + test('HA section has right-aligned action buttons', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const haSection = page.getByText('High availability', { exact: true }); + if (await haSection.isVisible().catch(() => false)) { + // The HA actions slot has justifyContent="flex-end" + const updateButton = page.getByRole('button', { name: 'Update' }).first(); + if (await updateButton.isVisible().catch(() => false)) { + await expect(updateButton).toBeVisible(); + } + } + }); + + test('version upgrade section uses dropdown for target version', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const versionTitle = page.getByText('Version', { exact: true }); + if (await versionTitle.isVisible().catch(() => false)) { + // The upgradeVersion component uses InputSelect with id="targetVersion" + const versionSelect = page.locator('#targetVersion'); + if (await versionSelect.isVisible().catch(() => false)) { + await expect(versionSelect).toBeVisible(); + // It should be a combobox (InputSelect), not a text input + const combobox = page + .locator('#targetVersion') + .locator('..') + .getByRole('combobox'); + await expect(combobox).toBeVisible(); + } + } + }); + + test('version upgrade text mentions zero downtime', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const versionTitle = page.getByText('Version', { exact: true }); + if (await versionTitle.isVisible().catch(() => false)) { + await expect(page.getByText('zero downtime')).toBeVisible(); + } + }); + + test('extensions section renders for PostgreSQL databases', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + // Extensions section is conditional on isPostgres + const extensionsTitle = page.getByText('Extensions', { exact: true }); + if (await extensionsTitle.isVisible().catch(() => false)) { + await expect(extensionsTitle).toBeVisible(); + await expect(page.getByText('Manage PostgreSQL extensions')).toBeVisible(); + } + }); + + test('network settings section renders', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + // UpdateNetwork component renders for all dedicated types + const networkTitle = page.getByText('Network', { exact: true }); + if (await networkTitle.isVisible().catch(() => false)) { + await expect(networkTitle).toBeVisible(); + } + }); + + test('backup settings section renders', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + // UpdateBackups component renders for all types + const backupTitle = page + .getByText('Backup', { exact: false }) + .filter({ hasText: /Backup/ }); + if ( + await backupTitle + .first() + .isVisible() + .catch(() => false) + ) { + await expect(backupTitle.first()).toBeVisible(); + } + }); + + test('delete database section renders', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + await expect(page.getByText('Delete database')).toBeVisible(); + await expect(page.getByText('permanently deleted')).toBeVisible(); + }); + + test('security section is NOT rendered in settings', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + // Security settings were removed (encryption at rest is infra-level) + await expect(page.getByText('Encryption at rest')).not.toBeVisible(); + }); + + test('connection pooler section renders for PostgreSQL', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + // UpdatePooler is only rendered for postgres + const poolerTitle = page.getByText('Connection pooler', { exact: false }); + if ( + await poolerTitle + .first() + .isVisible() + .catch(() => false) + ) { + await expect(poolerTitle.first()).toBeVisible(); + } + }); + + test('SQL API section renders', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const sqlApiTitle = page.getByText('SQL API', { exact: true }); + if (await sqlApiTitle.isVisible().catch(() => false)) { + await expect(sqlApiTitle).toBeVisible(); + } + }); + + test('read replicas section renders for dedicated type', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const replicasTitle = page.getByText('Read replicas', { exact: false }); + if ( + await replicasTitle + .first() + .isVisible() + .catch(() => false) + ) { + await expect(replicasTitle.first()).toBeVisible(); + } + }); + + test('cross-region failover section renders for dedicated type', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const crossRegion = page.getByText('Cross-region', { exact: false }); + if ( + await crossRegion + .first() + .isVisible() + .catch(() => false) + ) { + await expect(crossRegion.first()).toBeVisible(); + } + }); + + test('storage section renders', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const storageTitle = page.getByText('Storage', { exact: true }); + if ( + await storageTitle + .first() + .isVisible() + .catch(() => false) + ) { + await expect(storageTitle.first()).toBeVisible(); + } + }); + + test('maintenance window section renders', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const maintenanceTitle = page.getByText('Maintenance', { exact: false }); + if ( + await maintenanceTitle + .first() + .isVisible() + .catch(() => false) + ) { + await expect(maintenanceTitle.first()).toBeVisible(); + } + }); + + test('autoscaling section renders', async ({ page }) => { + if (!(await navigateToSettings(page))) { + test.skip(); + return; + } + + const autoscalingTitle = page.getByText('Autoscaling', { exact: false }); + if ( + await autoscalingTitle + .first() + .isVisible() + .catch(() => false) + ) { + await expect(autoscalingTitle.first()).toBeVisible(); + } + }); + }); + + test.describe('Auth tab', () => { + test.beforeEach(async ({ page }) => { + await authenticate(page); + }); + + /** Navigate to a dedicated database's auth page. Returns true if successful. */ + async function navigateToAuth(page: Page): Promise { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + // Navigate to first database and look for Auth tab + if (!(await navigateToFirstDatabase(page))) return false; + + // Auth link in the header tabs (not sidebar) + const authLink = page.locator('a[href*="/databases/database-"][href*="/auth"]').first(); + if (!(await authLink.isVisible({ timeout: 5_000 }).catch(() => false))) return false; + + await authLink.click(); + await page.waitForURL(/\/auth/, { timeout: 10_000 }); + return true; + } + + test('auth tab navigates to auth page', async ({ page }) => { + if (!(await navigateToAuth(page))) { + test.skip(); + return; + } + + expect(page.url()).toContain('/auth'); + // Auth page loaded — content depends on compute API availability + await page.waitForLoadState('networkidle'); + }); + + test('auth tab shows credential rotation section', async ({ page }) => { + if (!(await navigateToAuth(page))) { + test.skip(); + return; + } + + const rotateTitle = page.getByText('Credential rotation'); + await expect(rotateTitle).toBeVisible({ timeout: 10_000 }); + }); + + test('auth tab has username input and role selector', async ({ page }) => { + if (!(await navigateToAuth(page))) { + test.skip(); + return; + } + + await page.waitForLoadState('networkidle'); + // UpdateConnections renders after async load + const username = page.locator('#connectionUsername'); + if (!(await username.isVisible({ timeout: 15_000 }).catch(() => false))) { + test.skip(); // Component didn't render (API error) + return; + } + await expect(username).toBeVisible(); + await expect(page.locator('#connectionRole')).toBeVisible(); + }); + + test('auth tab shows rotate credentials button', async ({ page }) => { + if (!(await navigateToAuth(page))) { + test.skip(); + return; + } + + await page.waitForLoadState('networkidle'); + const rotateButton = page.getByRole('button', { name: /Rotate/ }); + await expect(rotateButton).toBeVisible({ timeout: 15_000 }); + }); + + test('auth tab has create user button', async ({ page }) => { + if (!(await navigateToAuth(page))) { + test.skip(); + return; + } + + await page.waitForLoadState('networkidle'); + const createButton = page.getByRole('button', { name: /Create user/ }); + if (!(await createButton.isVisible({ timeout: 15_000 }).catch(() => false))) { + test.skip(); // Component didn't render + return; + } + await expect(createButton).toBeVisible(); + }); + }); + + test.describe('Backups tab', () => { + test.beforeEach(async ({ page }) => { + await authenticate(page); + }); + + /** Navigate to the first database's backups page. Returns true if successful. */ + async function navigateToBackups(page: Page): Promise { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseLink = page.locator('a[href*="/databases/database-"]').first(); + if (!(await databaseLink.isVisible().catch(() => false))) return false; + + await databaseLink.click(); + await page.waitForLoadState('networkidle'); + + const backupsTab = page.getByRole('link', { name: 'Backups' }); + if (!(await backupsTab.isVisible().catch(() => false))) return false; + + await backupsTab.click(); + await page.waitForLoadState('networkidle'); + return true; + } + + test('backups tab loads', async ({ page }) => { + if (!(await navigateToBackups(page))) { + test.skip(); + return; + } + + expect(page.url()).toContain('/backups'); + }); + + test('backups tab shows content for dedicated databases', async ({ page }) => { + if (!(await navigateToBackups(page))) { + test.skip(); + return; + } + + // For dedicated databases, the DedicatedBackups component is rendered. + // For legacy databases, the policies/backups view is shown. + // Either way, the page should have loaded successfully. + const contentVisibilities = await Promise.all([ + page + .getByText('Policies', { exact: true }) + .isVisible() + .catch(() => false), + page + .getByText('Backups', { exact: true }) + .first() + .isVisible() + .catch(() => false), + page + .getByText('Backup', { exact: false }) + .first() + .isVisible() + .catch(() => false) + ]); + const hasContent = contentVisibilities.some(Boolean); + + expect(hasContent).toBeTruthy(); + }); + }); + + test.describe('Database list', () => { + test.beforeEach(async ({ page }) => { + await authenticate(page); + }); + + test('shows create database button', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + await expect(page.getByRole('button', { name: /Create database/ })).toBeVisible(); + }); + + test('database list renders database type indicators', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + // The list table has a "Type" column that shows database type labels + const typeColumn = page.getByText('Type', { exact: true }); + if (await typeColumn.isVisible().catch(() => false)) { + await expect(typeColumn).toBeVisible(); + } + }); + + test('database list shows database IDs', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const idColumn = page.getByText('Database ID', { exact: true }); + if (await idColumn.isVisible().catch(() => false)) { + await expect(idColumn).toBeVisible(); + } + }); + + test('clicking a database navigates to its overview', async ({ page }) => { + await page.goto(DATABASES_URL); + await page.waitForLoadState('networkidle'); + + const databaseRow = page.locator('table').getByRole('row').nth(1); + if (!(await databaseRow.isVisible().catch(() => false))) { + test.skip(); + return; + } + + await databaseRow.click(); + await page.waitForURL(/databases\/database-/, { timeout: 15_000 }); + + expect(page.url()).toContain('/databases/database-'); + }); + }); +}); diff --git a/package.json b/package.json index 0eb4abad81..a60a8cdbae 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "dev": "vite dev", "build": "bun run build.js", "preview": "vite preview", + "postinstall": "(tsc --project node_modules/@appwrite.io/console/tsconfig.json --declaration --emitDeclarationOnly --outDir node_modules/@appwrite.io/console/types --skipLibCheck && tsc --project node_modules/@appwrite.io/console/tsconfig.json --module esnext --moduleResolution bundler --outDir node_modules/@appwrite.io/console/dist/esm --declaration false --skipLibCheck && cp node_modules/@appwrite.io/console/dist/esm/index.js node_modules/@appwrite.io/console/dist/esm/sdk.js) || echo ''", "prepare": "svelte-kit sync || echo ''", "clean": "rm -rf node_modules && rm -rf .svelte-kit && bun install", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -20,13 +21,15 @@ }, "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@8f00f95", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console~feat-dedicated-db", "@appwrite.io/pink-icons": "0.25.0", "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3", "@appwrite.io/pink-legacy": "^1.0.3", "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8dcaa17", "@codemirror/autocomplete": "^6.19.0", "@codemirror/commands": "^6.9.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", "@codemirror/language": "^6.11.3", "@codemirror/lint": "^6.9.0", "@codemirror/search": "^6.5.11", @@ -73,6 +76,7 @@ "@testing-library/svelte": "^5.3.1", "@testing-library/user-event": "^14.6.1", "@types/deep-equal": "^1.0.4", + "@types/json-bigint": "^1.0.4", "@types/remarkable": "^2.0.8", "@types/three": "^0.182.0", "@typescript-eslint/eslint-plugin": "^8.57.2", diff --git a/playwright.config.ts b/playwright.config.ts index c7fdc85350..b36fb1b6dd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -20,7 +20,8 @@ const config: PlaywrightTestConfig = { 'pk_test_51LT5nsGYD1ySxNCyd7b304wPD8Y1XKKWR6hqo6cu3GIRwgvcVNzoZv4vKt5DfYXL1gRGw4JOqE19afwkJYJq1g3K004eVfpdWn' }, command: 'bun run build && bun run preview', - port: 4173 + port: 4173, + reuseExistingServer: true } }; diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index c806945d0f..7901b7be75 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -75,13 +75,12 @@ export function trackEvent(name: string, data: object = null): void { } } -export function trackError(exception: Error, event: Submit): void { - if (exception instanceof AppwriteException && exception.type && event) { - trackEvent(Submit.Error, { - type: exception.type, - form: event - }); - } +export function trackError(exception: Error, event?: Submit): void { + if (!(exception instanceof AppwriteException) || !exception.type) return; + + const data: Record = { type: exception.type }; + if (event) data.form = event; + trackEvent(Submit.Error, data); } export function trackPageView(path: string) { @@ -153,10 +152,15 @@ export enum Click { DatabaseIndexDelete = 'click_index_delete', DatabaseTableDelete = 'click_table_delete', DatabaseRowDelete = 'click_row_delete', + DatabaseColdStart = 'click_database_cold_start', DatabaseDatabaseDelete = 'click_database_delete', DatabaseImportCsv = 'click_database_import_csv', DatabaseExportCsv = 'click_database_export_csv', DatabaseImportJson = 'click_database_import_json', + DatabasePause = 'click_database_pause', + DatabaseResume = 'click_database_resume', + DatabaseSpinDown = 'click_database_spin_down', + DedicatedMonitoringRefresh = 'click_dedicated_monitoring_refresh', DomainCreateClick = 'click_domain_create', DomainDeleteClick = 'click_domain_delete', DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification', @@ -288,6 +292,38 @@ export enum Submit { DatabaseImportJSON = 'submit_database_import_json', DatabaseBackupDelete = 'submit_database_backup_delete', DatabaseBackupPolicyCreate = 'submit_database_backup_policy_create', + DatabaseUpdateTier = 'submit_database_update_tier', + DatabaseResizeStorage = 'submit_database_resize_storage', + DatabaseUpdateNetwork = 'submit_database_update_network', + DatabaseUpdateMaintenance = 'submit_database_update_maintenance', + DatabaseUpdateBackups = 'submit_database_update_backups', + DatabaseUpdateAutoscaling = 'submit_database_update_autoscaling', + DatabaseUpdatePooler = 'submit_database_update_pooler', + DatabaseRotateCredentials = 'submit_database_rotate_credentials', + DatabaseUpgradeVersion = 'submit_database_upgrade_version', + DedicatedBackupCreate = 'submit_dedicated_backup_create', + DedicatedBackupDelete = 'submit_dedicated_backup_delete', + DedicatedBackupRestore = 'submit_dedicated_backup_restore', + DedicatedBackupVerify = 'submit_dedicated_backup_verify', + DedicatedBranchCreate = 'submit_dedicated_branch_create', + DedicatedBranchDelete = 'submit_dedicated_branch_delete', + DedicatedDatabaseMigrate = 'submit_dedicated_database_migrate', + DedicatedPitrRestore = 'submit_dedicated_pitr_restore', + DatabaseInstallExtension = 'submit_database_install_extension', + DatabaseUninstallExtension = 'submit_database_uninstall_extension', + DatabaseCreateConnection = 'submit_database_create_connection', + DatabaseDeleteConnection = 'submit_database_delete_connection', + DatabaseCreateReadReplica = 'submit_database_create_read_replica', + DatabaseDeleteReadReplica = 'submit_database_delete_read_replica', + DatabaseEnableCrossRegion = 'submit_database_enable_cross_region', + DatabaseDisableCrossRegion = 'submit_database_disable_cross_region', + DatabaseTriggerCrossRegionFailover = 'submit_database_trigger_cross_region_failover', + DatabaseUpdateHA = 'submit_database_update_ha', + DatabaseManualFailover = 'submit_database_manual_failover', + DatabaseConfigureBackupStorage = 'submit_database_configure_backup_storage', + DatabaseDeleteBackupStorage = 'submit_database_delete_backup_storage', + DatabaseUpdateSecurity = 'submit_database_update_security', + DatabaseUpdateSqlApi = 'submit_database_update_sql_api', ColumnCreate = 'submit_column_create', ColumnUpdate = 'submit_column_update', diff --git a/src/lib/components/domains/viewLogsModal.svelte b/src/lib/components/domains/viewLogsModal.svelte index 3f558c71a0..9d53b088a5 100644 --- a/src/lib/components/domains/viewLogsModal.svelte +++ b/src/lib/components/domains/viewLogsModal.svelte @@ -36,8 +36,8 @@ domainId: domain.$id }); } - } catch { - // Ignore error + } catch (e) { + console.warn('[viewLogsModal] Failed to update nameservers:', e?.message ?? e); } try { diff --git a/src/lib/elements/forms/inputNumber.svelte b/src/lib/elements/forms/inputNumber.svelte index 77bb59937d..47f76b859f 100644 --- a/src/lib/elements/forms/inputNumber.svelte +++ b/src/lib/elements/forms/inputNumber.svelte @@ -63,8 +63,8 @@ {readonly} {disabled} {required} - {min} - {max} + min={min != null ? Number(min) : null} + max={max != null ? Number(max) : null} {label} {step} {nullable} diff --git a/src/lib/helpers/faker.ts b/src/lib/helpers/faker.ts index 83b390bebe..ccd9d89695 100644 --- a/src/lib/helpers/faker.ts +++ b/src/lib/helpers/faker.ts @@ -76,9 +76,14 @@ export async function generateFields( ]); } - case 'documentsdb': /* doesn't need any fields */ - case 'vectorsdb': /* vector embeddings + metadata defined at collection creation */ { - /* no individual field creation needed */ + /** + * Schema-less database types that don't require individual field creation: + * - documentsdb: Flexible document structure without predefined schema + * - vectorsdb: Vector embeddings and metadata are defined at collection creation + * @returns Empty array since no individual field creation is needed + */ + case 'documentsdb': + case 'vectorsdb': { return []; } } diff --git a/src/lib/helpers/object.ts b/src/lib/helpers/object.ts index ce28b5a6e3..2e26e49bf7 100644 --- a/src/lib/helpers/object.ts +++ b/src/lib/helpers/object.ts @@ -68,7 +68,7 @@ export function parseIfString(value: unknown): unknown { export function areObjectsSame( objectOne: T, objectTwo: T, - method: 'recursive' | 'stringify' = 'stringify' + method: 'recursive' | 'stringify' = 'recursive' ): boolean { if (method === 'recursive') { return deepEqual(objectOne, objectTwo); diff --git a/src/lib/helpers/search.ts b/src/lib/helpers/search.ts index 66f662bf31..2d19291e85 100644 --- a/src/lib/helpers/search.ts +++ b/src/lib/helpers/search.ts @@ -1,5 +1,10 @@ import type { Models } from '@appwrite.io/console'; +/** Minimum documents needed for reliable key frequency analysis */ +const MIN_SAMPLE_SIZE = 5; +/** Maximum documents to sample for performance */ +const DOC_SAMPLE_LIMIT = 5; + type FuzzySearchOptions = { limit?: number; minOccurrences?: number | null; @@ -12,17 +17,20 @@ export function fuzzySearchKeys( documents: Models.Document[], options: FuzzySearchOptions = {} ): string[] | null { - if (!documents || documents.length < 5) { + if (!documents || documents.length < MIN_SAMPLE_SIZE) { return null; } const { minOccurrences = 2, limit } = options; const attributeCount = new Map(); - const threshold = minOccurrences === null ? 5 : Math.max(2, Math.min(minOccurrences, 5)); + const threshold = + minOccurrences === null + ? MIN_SAMPLE_SIZE + : Math.max(2, Math.min(minOccurrences, MIN_SAMPLE_SIZE)); - // Process only first 5 documents - const docLimit = Math.min(5, documents.length); + // Process only first DOC_SAMPLE_LIMIT documents + const docLimit = Math.min(DOC_SAMPLE_LIMIT, documents.length); for (let docIndex = 0; docIndex < docLimit; docIndex++) { const document = documents[docIndex]; diff --git a/src/lib/stores/auth-methods.ts b/src/lib/stores/auth-methods.ts index 4e2c0cf78a..4be9911c49 100644 --- a/src/lib/stores/auth-methods.ts +++ b/src/lib/stores/auth-methods.ts @@ -1,5 +1,5 @@ import { writable } from 'svelte/store'; -import { type Models, MethodId } from '@appwrite.io/console'; +import { type Models, AuthMethod as MethodId } from '@appwrite.io/console'; export type AuthMethod = { label: string; diff --git a/src/lib/stores/preferences.ts b/src/lib/stores/preferences.ts index 0c56fd9b50..311e4bd440 100644 --- a/src/lib/stores/preferences.ts +++ b/src/lib/stores/preferences.ts @@ -218,14 +218,11 @@ function createPreferences() { }), // `databaseType` fallback for legacy cases. - deleteEntityDetails: async ( - orgId: string, - entityId: string, - databaseType: string = 'tables' - ) => { + deleteEntityDetails: async (orgId: string, entityId: string, databaseType?: string) => { + const dbType = databaseType ?? 'tables'; // remove from account preferences const removeCustomTableColumns = updateAndSync((n) => { - n = ensureObjectProperty(n, databaseType); + n = ensureObjectProperty(n, dbType); delete n.tables[entityId]; return n; }); @@ -235,9 +232,9 @@ function createPreferences() { delete teamPreferences?.columnWidths?.[entityId + '#columns']; delete teamPreferences?.columnWidths?.[entityId + '#indexes']; - if (teamPreferences.displayNames?.[databaseType]?.[entityId]) { + if (teamPreferences.displayNames?.[dbType]?.[entityId]) { // new structure - delete teamPreferences?.displayNames?.[databaseType]?.[entityId]; + delete teamPreferences?.displayNames?.[dbType]?.[entityId]; } else { // legacy structure delete teamPreferences?.displayNames?.[entityId]; @@ -253,9 +250,9 @@ function createPreferences() { loadTeamPrefs: loadTeamPreferences, - getDisplayNames: (entityId: string, databaseType: string = null) => { + getDisplayNames: (entityId: string, databaseType?: string) => { let names = teamPreferences?.displayNames?.[entityId]; - if (databaseType) { + if (databaseType != null) { names = teamPreferences?.displayNames?.[databaseType]?.[entityId]; } @@ -266,10 +263,10 @@ function createPreferences() { orgId: string, entityId: string, displayNames: TeamPreferences['names'], - databaseType: string = null + databaseType?: string ) => { teamPreferences = ensureObjectProperty(teamPreferences, 'displayNames'); - if (!databaseType) { + if (databaseType == null) { // legacy! teamPreferences.displayNames[entityId] = displayNames; } else { diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index aa80494850..1b2391dc01 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -6,29 +6,29 @@ import { Avatars, Backups, Client, + Compute, Console, + Databases, + Domains, Functions, Health, Locale, Messaging, Migrations, + Organizations, Project, Project as ProjectApi, Projects, Proxy, + Realtime, + Sites, Storage, + TablesDB, Teams, + Tokens, Users, Vcs, - Sites, - Tokens, - TablesDB, - Domains, - DocumentsDB, - Webhooks, - Realtime, - Organizations, - VectorsDB + Webhooks } from '@appwrite.io/console'; import { buildRegionalV1Endpoint } from '$lib/helpers/apiEndpoint'; import { Sources } from '$lib/sdk/sources'; @@ -112,8 +112,9 @@ const sdkForProject = { migrations: new Migrations(clientProject), sites: new Sites(clientProject), tablesDB: new TablesDB(clientProject), - documentsDB: new DocumentsDB(clientProject), - vectorsDB: new VectorsDB(clientProject), + documentsDB: new Databases(clientProject), + compute: new Compute(clientProject), + vectorsDB: new Databases(clientProject), webhooks: new Webhooks(clientProject), console: new Console(clientProject) // for suggestions API }; diff --git a/src/routes/(console)/organization-[organization]/usage/[[invoice]]/+page.svelte b/src/routes/(console)/organization-[organization]/usage/[[invoice]]/+page.svelte index 874cc23ec1..f2517bd4be 100644 --- a/src/routes/(console)/organization-[organization]/usage/[[invoice]]/+page.svelte +++ b/src/routes/(console)/organization-[organization]/usage/[[invoice]]/+page.svelte @@ -23,7 +23,7 @@ import { IconChartSquareBar, IconInfo } from '@appwrite.io/pink-icons-svelte'; import { onMount } from 'svelte'; import type { UsageProjectInfo } from '../../store'; - import { BillingPlanGroup } from '@appwrite.io/console'; + import { type Models, BillingPlanGroup } from '@appwrite.io/console'; export let data; @@ -34,7 +34,7 @@ const plan = data?.plan ?? undefined; $: projects = data.organizationUsage.projects; - $: orgUsage = data.organizationUsage; + $: orgUsage = data.organizationUsage as Models.UsageOrganization; let usageProjects: Record = {}; diff --git a/src/routes/(console)/project-[region]-[project]/auth/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/auth/settings/+page.svelte index 3596e8d7ea..83b6c3e4ed 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/settings/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/settings/+page.svelte @@ -10,7 +10,7 @@ import { addNotification } from '$lib/stores/notifications'; import { oAuthProviders } from '$lib/stores/oauth-providers'; import { sdk } from '$lib/stores/sdk'; - import { MethodId, type Models } from '@appwrite.io/console'; + import { AuthMethod as MethodId, type Models } from '@appwrite.io/console'; import { base } from '$app/paths'; import { Avatar, diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-33-55-362Z.yml b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-33-55-362Z.yml new file mode 100644 index 0000000000..98d66f7c77 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-33-55-362Z.yml @@ -0,0 +1,33 @@ +- generic [active] [ref=e1]: + - main [ref=e3]: + - generic [ref=e4]: + - generic: + - link "Appwrite Logo": + - /url: /console + - img "Appwrite Logo" + - paragraph [ref=e7]: Build like a team of hundreds_ + - generic [ref=e10]: + - heading "Sign in" [level=3] [ref=e11] + - generic [ref=e14]: + - button "Sign in with GitHub" [ref=e16] [cursor=pointer]: + - generic [ref=e17]:  + - generic [ref=e18]: Sign in with GitHub + - generic [ref=e19]: or + - generic [ref=e20]: + - generic [ref=e21]: Email + - textbox "Email" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Password + - generic [ref=e26]: + - textbox "Password" [ref=e27] + - button [ref=e28] [cursor=pointer]: + - img [ref=e30] + - button "Sign in" [ref=e33] [cursor=pointer] + - list [ref=e34]: + - listitem [ref=e35]: + - link "Forgot password?" [ref=e36] [cursor=pointer]: + - /url: /console/recover + - listitem [ref=e37]: + - link "Sign up" [ref=e38] [cursor=pointer]: + - /url: /console/register + - img "Appwrite Logo" diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-34-00-778Z.yml b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-34-00-778Z.yml new file mode 100644 index 0000000000..8a2e839a42 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-34-00-778Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e1]: + - main [ref=e3]: + - generic [ref=e4]: + - link "Appwrite Logo" [ref=e41] [cursor=pointer]: + - /url: /console + - img "Appwrite Logo" [ref=e42] + - paragraph [ref=e7]: Build like a team of hundreds_ + - generic [ref=e10]: + - heading "Sign in" [level=3] [ref=e11] + - generic [ref=e14]: + - button "Sign in with GitHub" [ref=e16] [cursor=pointer]: + - generic [ref=e17]:  + - generic [ref=e18]: Sign in with GitHub + - generic [ref=e19]: or + - generic [ref=e20]: + - generic [ref=e21]: Email + - textbox "Email" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Password + - generic [ref=e26]: + - textbox "Password" [ref=e27] + - button [ref=e28] [cursor=pointer]: + - img [ref=e30] + - button "Sign in" [ref=e33] [cursor=pointer] + - list [ref=e34]: + - listitem [ref=e35]: + - link "Forgot password?" [ref=e36] [cursor=pointer]: + - /url: /console/recover + - listitem [ref=e37]: + - link "Sign up" [ref=e38] [cursor=pointer]: + - /url: /console/register + - img "Appwrite Logo" diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-34-15-388Z.yml b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-34-15-388Z.yml new file mode 100644 index 0000000000..8a2e839a42 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-34-15-388Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e1]: + - main [ref=e3]: + - generic [ref=e4]: + - link "Appwrite Logo" [ref=e41] [cursor=pointer]: + - /url: /console + - img "Appwrite Logo" [ref=e42] + - paragraph [ref=e7]: Build like a team of hundreds_ + - generic [ref=e10]: + - heading "Sign in" [level=3] [ref=e11] + - generic [ref=e14]: + - button "Sign in with GitHub" [ref=e16] [cursor=pointer]: + - generic [ref=e17]:  + - generic [ref=e18]: Sign in with GitHub + - generic [ref=e19]: or + - generic [ref=e20]: + - generic [ref=e21]: Email + - textbox "Email" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Password + - generic [ref=e26]: + - textbox "Password" [ref=e27] + - button [ref=e28] [cursor=pointer]: + - img [ref=e30] + - button "Sign in" [ref=e33] [cursor=pointer] + - list [ref=e34]: + - listitem [ref=e35]: + - link "Forgot password?" [ref=e36] [cursor=pointer]: + - /url: /console/recover + - listitem [ref=e37]: + - link "Sign up" [ref=e38] [cursor=pointer]: + - /url: /console/register + - img "Appwrite Logo" diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-40-18-401Z.yml b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-40-18-401Z.yml new file mode 100644 index 0000000000..98d66f7c77 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-40-18-401Z.yml @@ -0,0 +1,33 @@ +- generic [active] [ref=e1]: + - main [ref=e3]: + - generic [ref=e4]: + - generic: + - link "Appwrite Logo": + - /url: /console + - img "Appwrite Logo" + - paragraph [ref=e7]: Build like a team of hundreds_ + - generic [ref=e10]: + - heading "Sign in" [level=3] [ref=e11] + - generic [ref=e14]: + - button "Sign in with GitHub" [ref=e16] [cursor=pointer]: + - generic [ref=e17]:  + - generic [ref=e18]: Sign in with GitHub + - generic [ref=e19]: or + - generic [ref=e20]: + - generic [ref=e21]: Email + - textbox "Email" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Password + - generic [ref=e26]: + - textbox "Password" [ref=e27] + - button [ref=e28] [cursor=pointer]: + - img [ref=e30] + - button "Sign in" [ref=e33] [cursor=pointer] + - list [ref=e34]: + - listitem [ref=e35]: + - link "Forgot password?" [ref=e36] [cursor=pointer]: + - /url: /console/recover + - listitem [ref=e37]: + - link "Sign up" [ref=e38] [cursor=pointer]: + - /url: /console/register + - img "Appwrite Logo" diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-45-03-558Z.yml b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-45-03-558Z.yml new file mode 100644 index 0000000000..98d66f7c77 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/.playwright-cli/page-2026-03-31T13-45-03-558Z.yml @@ -0,0 +1,33 @@ +- generic [active] [ref=e1]: + - main [ref=e3]: + - generic [ref=e4]: + - generic: + - link "Appwrite Logo": + - /url: /console + - img "Appwrite Logo" + - paragraph [ref=e7]: Build like a team of hundreds_ + - generic [ref=e10]: + - heading "Sign in" [level=3] [ref=e11] + - generic [ref=e14]: + - button "Sign in with GitHub" [ref=e16] [cursor=pointer]: + - generic [ref=e17]:  + - generic [ref=e18]: Sign in with GitHub + - generic [ref=e19]: or + - generic [ref=e20]: + - generic [ref=e21]: Email + - textbox "Email" [ref=e23] + - generic [ref=e24]: + - generic [ref=e25]: Password + - generic [ref=e26]: + - textbox "Password" [ref=e27] + - button [ref=e28] [cursor=pointer]: + - img [ref=e30] + - button "Sign in" [ref=e33] [cursor=pointer] + - list [ref=e34]: + - listitem [ref=e35]: + - link "Forgot password?" [ref=e36] [cursor=pointer]: + - /url: /console/recover + - listitem [ref=e37]: + - link "Sign up" [ref=e38] [cursor=pointer]: + - /url: /console/register + - img "Appwrite Logo" diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/dedicated-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/dedicated-db.svg new file mode 100644 index 0000000000..5cb216920c --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dark/dedicated-db.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + +db-prod-primary +4 vCPU · 16 GB + +Active + + + + + + + +db-prod-replica +4 vCPU · 16 GB + +Active + + + + + + + +db-staging +2 vCPU · 8 GB + + + + +Performance +Last 24 hours + + + + + + + + + + + + +CPU + + +Memory + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/(assets)/dedicated-db.svg b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dedicated-db.svg new file mode 100644 index 0000000000..e8ab1f6d11 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/(assets)/dedicated-db.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + +db-prod-primary +4 vCPU · 16 GB + +Active + + + + + + + +db-prod-replica +4 vCPU · 16 GB + +Active + + + + + + + +db-staging +2 vCPU · 8 GB + + + + +Performance +Last 24 hours + + + + + + + + + + + + +CPU + + +Memory + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/+page.svelte index 6b416106fa..1046574484 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/+page.svelte @@ -8,13 +8,12 @@ import Create from './create.svelte'; import Grid from './grid.svelte'; - import { columns } from './store'; import Table from './table.svelte'; import type { PageProps } from './$types'; - import { Icon, Tooltip } from '@appwrite.io/pink-svelte'; import { registerCommands } from '$lib/commandCenter'; import { canWriteDatabases } from '$lib/stores/roles'; import { IconPlus } from '@appwrite.io/pink-icons-svelte'; + import { Icon, Tooltip } from '@appwrite.io/pink-svelte'; import EmptySearch from '$lib/components/emptySearch.svelte'; import { isServiceLimited } from '$lib/stores/billing'; import { organization } from '$lib/stores/organization'; @@ -25,6 +24,7 @@ import { resolveRoute, withPath } from '$lib/stores/navigation'; import EmptyDatabaseCloud from './empty.svelte'; + import { columns } from './store'; import { flags } from '$lib/flags'; import { user } from '$lib/stores/user'; import { project } from '../store'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/create/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/create/+page.svelte index 2e27083e26..e43956fca3 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/create/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/create/+page.svelte @@ -1,80 +1,181 @@ - - - - - +{#if data.isDedicatedType && data.dedicatedDatabase} + +{:else} + + + + + - - + + - {#if $canWriteTables} - - {/if} + {#if $canWriteTables} + + {/if} + - - {#if data.entities.total} - {#if data.view === 'grid'} - - {:else} - - {/if} + {#if data.entities.total} + {#if data.view === 'grid'} + + {:else} +
+ {/if} - - {:else if data.search} - - - - {:else} - -
- - {emptyPageText} + + {:else if data.search} + + + + {:else} + +
+ + {emptyPageText} - - + + - {#if $canWriteTables} - - {/if} - - -
-
- {/if} - + {#if $canWriteTables} + + {/if} + +
+
+
+ {/if} + +{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/policyPresets.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/policyPresets.svelte new file mode 100644 index 0000000000..39171ab629 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/policyPresets.svelte @@ -0,0 +1,32 @@ + + + + {#each policies as policy} + + {policy.description} + + {/each} + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/branches/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/branches/+page.svelte new file mode 100644 index 0000000000..6263760859 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/branches/+page.svelte @@ -0,0 +1,240 @@ + + +{#if !database} + + + Branches are only available for dedicated databases. + + +{:else} + + + Database Branches + Ephemeral copies of your database for testing schema migrations or running experiments without + affecting production data. Branches expire automatically after the configured TTL. + + + {#if isLoading} + + {#each Array(3) as _} + + {/each} + + {:else if branches.branches.length === 0} +
+ No branches yet. Create one to start testing. +
+ {:else} + + + Name + ID + Namespace + Expires + + + {#each branches.branches as branch} + + + + {branch.branchName || branch.branchId} + + + + + {branch.branchId} + + + + + {branch.namespace} + + + + {formatExpiration(branch.expiresAt)} + + + + + + + { + toggle(e); + selectedBranch = branch; + showDeleteConfirm = true; + }}> + Delete + + + + + + + {/each} + + {/if} +
+
+ + + + + + +
+
+{/if} + + + + Are you sure you want to delete this branch? This removes the branch namespace, its storage, + and the associated snapshot. This action is irreversible. + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/branches/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/branches/+page.ts new file mode 100644 index 0000000000..9511c54243 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/branches/+page.ts @@ -0,0 +1,13 @@ +import type { PageLoad } from './$types'; +import { Dependencies } from '$lib/constants'; + +export const load: PageLoad = async ({ depends, parent }) => { + depends(Dependencies.DATABASE); + + const { database, dedicatedDatabase } = await parent(); + + return { + database, + dedicatedDatabase + }; +}; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/embeddingModal.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/embeddingModal.svelte index 51d427291b..e6980803a4 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/embeddingModal.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/embeddingModal.svelte @@ -25,9 +25,9 @@ generating = true; try { - const response = await sdk - .forProject(page.params.region, page.params.project) - .vectorsDB.createTextEmbeddings({ texts: [content.trim()] }); + const vectorsSdk = sdk.forProject(page.params.region, page.params.project).vectorsDB; + // @ts-expect-error createTextEmbeddings not yet in SDK type + const response = await vectorsSdk.createTextEmbeddings({ texts: [content.trim()] }); const embedding = response?.embeddings?.[0]?.embedding; if (embedding?.length) { diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/duplicates.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/duplicates.ts index 996b00b0d6..70e770ba3a 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/duplicates.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/extensions/duplicates.ts @@ -10,7 +10,9 @@ type Options = { maxDocLength?: number; }; +/** Time budget for duplicate detection in milliseconds. 200ms balances responsiveness with thoroughness. */ const DEFAULT_TIME_BUDGET_MS = 200; +/** Check time budget every N nodes. 200 balances accuracy with overhead of time checks. */ const CHECK_BUDGET_EVERY = 200; function normalizeKey(raw: string): string { diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/view.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/view.svelte index 6c3432a643..8bcc6cc2dc 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/view.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/view.svelte @@ -986,10 +986,36 @@ suggestedObject[attr] = suggestedDefaults?.[attr] ?? ''; } - // Merge with existing data (keeping system fields) + // System fields that should not be overwritten via spread + const SYSTEM_FIELDS = [ + '$id', + '$createdAt', + '$updatedAt', + '$permissions', + '$collectionId', + '$databaseId' + ]; + + // Sanitize data to prevent system field injection + const existingData = ( + typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {} + ) as JsonObject; + const sanitizedData = Object.fromEntries( + Object.entries(existingData).filter(([key]) => !SYSTEM_FIELDS.includes(key)) + ); + + // Merge sanitized data with suggested attributes, then restore system fields const updatedData = { - ...(typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}), - ...suggestedObject + ...sanitizedData, + ...suggestedObject, + // Restore original system fields + ...(existingData['$id'] !== undefined && { $id: existingData['$id'] }), + ...(existingData['$createdAt'] !== undefined && { + $createdAt: existingData['$createdAt'] + }), + ...(existingData['$updatedAt'] !== undefined && { + $updatedAt: existingData['$updatedAt'] + }) }; // Update the data diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.svelte index abae1c2562..8fcff6fd95 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.svelte @@ -53,6 +53,7 @@ RecordActivity, useDatabaseSdk, DEFAULT_VECTOR_DIMENSION, + type DatabaseType, type Field, type Index } from '$database/(entity)'; @@ -212,7 +213,7 @@ const databaseSdk = useDatabaseSdk( page.params.region, page.params.project, - data.database.type + data.database.type as DatabaseType ); await databaseSdk.createIndex({ diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.ts index c1f38e5bab..fefafdd8c9 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.ts @@ -1,13 +1,17 @@ import Header from './header.svelte'; import type { LayoutLoad } from './$types'; import { Dependencies } from '$lib/constants'; -import { Breadcrumbs, useDatabaseSdk } from '$database/(entity)'; +import { Breadcrumbs, useDatabaseSdk, type DatabaseType } from '$database/(entity)'; export const load: LayoutLoad = async ({ params, depends, parent }) => { const { database } = await parent(); depends(Dependencies.COLLECTION); - const databaseSdk = useDatabaseSdk(params.region, params.project, database.type); + const databaseSdk = useDatabaseSdk( + params.region, + params.project, + database.type as DatabaseType + ); const collection = await databaseSdk.getEntity({ databaseId: params.database, diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.svelte index 1df280dcef..387e85f5f3 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.svelte @@ -30,7 +30,7 @@ import { invalidate } from '$app/navigation'; import { hash } from '$lib/helpers/string'; import { Dependencies } from '$lib/constants'; - import { EmptySheet, EmptySheetCards } from '$database/(entity)'; + import { EmptySheet, EmptySheetCards, type DatabaseType } from '$database/(entity)'; import { isCollectionsJsonImportInProgress, noSqlDocument, @@ -78,14 +78,21 @@ $isCollectionsJsonImportInProgress = true; try { - await sdk - .forProject(page.params.region, page.params.project) - .migrations.createJSONImport({ - bucketId: file.bucketId, - fileId: file.$id, - resourceId: `${page.params.database}:${page.params.collection}`, - internalFile: localFile - }); + await ( + sdk.forProject(page.params.region, page.params.project).migrations as unknown as { + createJSONImport: (params: { + bucketId: string; + fileId: string; + resourceId: string; + internalFile: boolean; + }) => Promise; + } + ).createJSONImport({ + bucketId: file.bucketId, + fileId: file.$id, + resourceId: `${page.params.database}:${page.params.collection}`, + internalFile: localFile + }); addNotification({ type: 'success', @@ -304,7 +311,10 @@ {/snippet} {:else} - + {#snippet actions()} { const { collection, database } = await parent(); @@ -20,7 +20,11 @@ export const load: PageLoad = async ({ params, depends, url, route, parent }) => queries.set(parsedQueries); const currentSort = extractSortFromQueries(parsedQueries); - const collectionSdk = getCollectionService(params.region, params.project, database.type); + const collectionSdk = getCollectionService( + params.region, + params.project, + database.type as DatabaseType + ); return { offset, diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/export/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/export/+page.svelte index a4a2b7e9aa..7b5c468a23 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/export/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/export/+page.svelte @@ -53,15 +53,23 @@ async function handleExport() { try { - await sdk - .forProject(page.params.region, page.params.project) - .migrations.createJSONExport({ - resourceId: `${page.params.database}:${page.params.collection}`, - filename: filename, - columns: [], - queries: exportWithFilters ? Array.from(localQueries.values()) : [], - notify: true - }); + await ( + sdk.forProject(page.params.region, page.params.project).migrations as unknown as { + createJSONExport: (params: { + resourceId: string; + filename: string; + columns: string[]; + queries: string[]; + notify: boolean; + }) => Promise; + } + ).createJSONExport({ + resourceId: `${page.params.database}:${page.params.collection}`, + filename: filename, + columns: [], + queries: exportWithFilters ? Array.from(localQueries.values()) : [], + notify: true + }); addNotification({ type: 'success', diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/indexes/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/indexes/+page.svelte index ce5f1aaad6..f5680ceac3 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/indexes/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/indexes/+page.svelte @@ -3,6 +3,7 @@ import type { PageProps } from './$types'; import { type CreateIndexesCallbackType, + type DatabaseType, Indexes, EmptySheet, EmptySheetCards, @@ -15,7 +16,11 @@ let createIndexRef: CreateIndexForm; - const databaseSdk = useDatabaseSdk(page.params.region, page.params.project, data.database.type); + const databaseSdk = useDatabaseSdk( + page.params.region, + page.params.project, + data.database.type as DatabaseType + ); async function onCreateIndex(index: CreateIndexesCallbackType) { await databaseSdk.createIndex({ @@ -53,7 +58,7 @@ {/snippet} {#snippet emptyIndexesSheetView(toggle)} - + {#snippet actions()} + import { Modal, CopyInput, Code } from '$lib/components'; + import { Button } from '$lib/elements/forms'; + import { Layout, Typography, Alert, Icon } from '@appwrite.io/pink-svelte'; + import { IconDuplicate } from '@appwrite.io/pink-icons-svelte'; + import { copy } from '$lib/helpers/copy'; + import { addNotification } from '$lib/stores/notifications'; + import type { Models } from '@appwrite.io/console'; + import { getEngineDisplayName } from './dedicated'; + + let { + show = $bindable(false), + database, + connectionCommand + }: { + show: boolean; + database: Models.DedicatedDatabase; + connectionCommand: string; + } = $props(); + + function getEngineCliName(engine: string): string { + switch (engine) { + case 'postgres': + return 'psql'; + case 'mysql': + case 'mariadb': + return 'mysql'; + default: + return engine; + } + } + + async function copyConnectionString() { + if (!database.connectionString) return; + const success = await copy(database.connectionString); + if (success) { + addNotification({ + type: 'success', + message: 'Connection string copied to clipboard' + }); + } else { + addNotification({ + type: 'error', + message: 'Failed to copy to clipboard' + }); + } + } + + async function copyCommand() { + const success = await copy(connectionCommand); + if (success) { + addNotification({ + type: 'success', + message: 'Command copied to clipboard' + }); + } else { + addNotification({ + type: 'error', + message: 'Failed to copy to clipboard' + }); + } + } + + + + + + Choose how you want to connect to your {getEngineDisplayName(database.engine)} database. + + + + Connection String + + Use this URI to connect from your application or database client. + + {#if database.connectionString} + + {/if} + + + + Terminal Command + + Run this command in your terminal to connect using {getEngineCliName( + database.engine + )}. + + +
+ +
+
+ + + Quick Reference +
+
+ Host + {database.hostname || '-'} +
+
+ Port + {database.connectionPort || '-'} +
+ {#if database.engine === 'postgres'} +
+ Database + postgres +
+ {/if} +
+ Username + {database.connectionUser || '-'} +
+
+
+
+ + + + + + + + + + +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/dedicated.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/dedicated.ts new file mode 100644 index 0000000000..34b56c6f04 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/dedicated.ts @@ -0,0 +1,14 @@ +export function getEngineDisplayName(engine: string): string { + switch (engine) { + case 'postgres': + return 'PostgreSQL'; + case 'mysql': + return 'MySQL'; + case 'mariadb': + return 'MariaDB'; + case 'mongodb': + return 'MongoDB'; + default: + return engine; + } +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/dedicatedOverview.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/dedicatedOverview.svelte new file mode 100644 index 0000000000..4540537ff1 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/dedicatedOverview.svelte @@ -0,0 +1,882 @@ + + + + + + Status + + + + + {capitalize(database.status)} + + + {#if database.containerStatus} + + Container: {capitalize(database.containerStatus)} + + {/if} + + + {database.region.toUpperCase()} + + + + {#if database.error} + + {database.error} + + {/if} + + + + Created {toLocaleDateTime(database.$createdAt)} + + + Updated {toLocaleDateTime(database.$updatedAt)} + + + + + + {#if database.containerStatus === 'inactive'} + + {/if} + {#if isDedicated && isActive} + + {/if} + {#if isPaused} + + {/if} + + + + + + {#if isActive && hasConnectionDetails && hasCredentials} + + Connection + Use these credentials to connect to your database. + + +
+ + (connectionTab = 'direct')} + active={connectionTab === 'direct'}> + Direct Connection + + (connectionTab = 'string')} + active={connectionTab === 'string'}> + Connection String + + + +
+ + {#if connectionTab === 'direct'} + + + + +
+ +
+ + + +
+
+
+ {#if defaultDatabaseName} + + {/if} + {:else} + + + + + Terminal Command + + + + + {/if} +
+
+
+ {:else if isActive && hasConnectionDetails && !hasCredentials} + + Connection + + + + Your database is available but credentials are still being provisioned. + Connection credentials will appear here shortly. + + + + + + + + + {:else if database.status === 'provisioning'} + + Connection + + + + Your database is being set up. Connection details will be available once + provisioning is complete. + + + + + + + + + + {/if} + + + + Resources + Your database configuration and allocated resources. + + + + + Engine + + + {getEngineDisplayName(database.engine)} + {database.version} + + + + + Tier + + + {capitalize(database.tier)} + + + + + Backend + + + {capitalize(database.backend)} + + + + + CPU + + {cpuDisplay} + + + + Memory + + {memoryDisplay} + + + + Storage + + {storageDisplay} + + {#if database.storageClass} + + + Storage Class + + + {formatStorageClass(database.storageClass)} + + + {/if} + + + + + + High Availability + Configure replicas and failover settings for your database. + + + + + Status + + + + {#if database.highAvailability} + + + Replicas + + + {database.highAvailabilityReplicaCount} + + + {#if database.highAvailabilitySyncMode} + + + Sync Mode + + + {capitalize(database.highAvailabilitySyncMode)} + + + {/if} + {/if} + + + + + + Network + Connection limits and network configuration. + + + + + + Max Connections + + + {database.networkMaxConnections}{#if tierMaxConnections} + + / {tierMaxConnections.toLocaleString()} (tier limit) + + {/if} + + + + + Connection Timeout + + + {database.networkIdleTimeoutSeconds}s + + + {#if database.idleTimeoutMinutes} + + + Scale-to-Zero After + + + {database.idleTimeoutMinutes} min + + + {/if} + + + {#if database.networkIPAllowlist?.length > 0} + + + IP Allowlist + + + {#each database.networkIPAllowlist as ip} + + {/each} + + + {/if} + + + + + + + Backups + Automatic backup and point-in-time recovery settings. + + + + + Automatic Backups + + + + {#if database.backupEnabled} + + + Point-in-Time Recovery + + + + {#if database.backupPitr && database.pitrRetentionDays} + + ({database.pitrRetentionDays} day window) + + {/if} + + + + + Schedule + + {database.backupCron} + + + + Retention + + + {database.backupRetentionDays} days + + + {/if} + + + + + + + Storage Autoscaling + Automatically expand storage when usage reaches the configured threshold. + + + + + Status + + + + {#if database.storageAutoscaling} + + + Threshold + + + {database.storageAutoscalingThresholdPercent}% + + + + + Max Storage + + + {formatStorage(database.storageAutoscalingMaxGb)} + + + {/if} + + + + + + + Maintenance Window + Scheduled maintenance window and upgrade policy for your database. + + + + + Day + + + {formatMaintenanceDay(database.maintenanceWindowDay)} + + + + + Time + + + {formatHourUtc(database.maintenanceWindowHourUtc)} + + + + + Duration + + + {database.maintenanceWindowDurationMinutes} minutes + + + + + Upgrade Policy + + + {formatUpgradePolicy(database.maintenanceUpgradePolicy)} + + + + + + + + + SQL API + Execute SQL statements directly through the Appwrite API. + + + + + + Status + + + + {#if database.sqlApiEnabled} + + + Max Response Size + + + {formatBytes(database.sqlApiMaxBytes)} + + + + + Max Rows + + + {database.sqlApiMaxRows.toLocaleString()} + + + + + Timeout + + + {database.sqlApiTimeoutSeconds}s + + + {/if} + + + {#if database.sqlApiEnabled && database.sqlApiAllowedStatements?.length > 0} + + + Allowed Statements + + + {#each database.sqlApiAllowedStatements as statement} + + {/each} + + + {/if} + + + + + + {#if database.metricsEnabled} + + Monitoring + Performance monitoring and slow query detection settings. + + + + + Slow Query Threshold + + + {database.metricsSlowQueryLogThresholdMs.toLocaleString()} ms + + + + + Trace Sample Rate + + + {(database.metricsTraceSampleRate * 100).toFixed(0)}% + + + + + + {/if} +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/header.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/header.svelte index 1d3ded6c0d..ed66868e66 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/header.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/header.svelte @@ -5,7 +5,8 @@ import { isTabSelected } from '$lib/helpers/load'; import { canWriteDatabases } from '$lib/stores/roles'; import { resolveRoute, withPath } from '$lib/stores/navigation'; - import { useTerminology } from '$database/(entity)'; + import { useTerminology, type DatabaseType } from '$database/(entity)'; + import { isCloud } from '$lib/system'; import { isSmallViewport } from '$lib/stores/viewport'; const terminology = useTerminology(page); @@ -20,13 +21,15 @@ page.params ); + const isDedicatedType = $derived((database?.type as DatabaseType) === 'dedicateddb'); + const tabs = $derived( [ { href: baseDatabasePath, - title: terminology.entity.title.plural, - event: terminology.entity.lower.plural, - hasChildren: true + title: isDedicatedType ? 'Overview' : terminology.entity.title.plural, + event: isDedicatedType ? 'overview' : terminology.entity.lower.plural, + hasChildren: !isDedicatedType }, { href: withPath(baseDatabasePath, '/backups'), @@ -34,6 +37,18 @@ event: 'backups', hasChildren: true }, + { + href: withPath(baseDatabasePath, '/auth'), + title: 'Auth', + event: 'auth', + disabled: !isDedicatedType || !isCloud + }, + { + href: withPath(baseDatabasePath, '/monitoring'), + title: 'Monitoring', + event: 'monitoring', + disabled: !isDedicatedType || !isCloud + }, { href: withPath(baseDatabasePath, '/usage'), title: 'Usage', diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.svelte new file mode 100644 index 0000000000..703e1e8c67 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.svelte @@ -0,0 +1,1107 @@ + + +{#if !database} + + + Monitoring is only available for dedicated databases. + + +{:else} + + + + + (activeSection = 'metrics')} + active={activeSection === 'metrics'}> + Metrics + + (activeSection = 'slowQueries')} + active={activeSection === 'slowQueries'}> + Slow Queries + + (activeSection = 'insights')} + active={activeSection === 'insights'}> + Performance + + (activeSection = 'auditLogs')} + active={activeSection === 'auditLogs'}> + Audit Logs + + (activeSection = 'schema')} + active={activeSection === 'schema'}> + Schema + + (activeSection = 'schemaPreview')} + active={activeSection === 'schemaPreview'}> + Schema Preview + + (activeSection = 'explain')} + active={activeSection === 'explain'}> + Explain + + (activeSection = 'tuning')} + active={activeSection === 'tuning'}> + Tuning + + (activeSection = 'indexes')} + active={activeSection === 'indexes'}> + Indexes + + + + {#if activeSection === 'metrics' || activeSection === 'slowQueries' || activeSection === 'insights' || activeSection === 'auditLogs'} + + {/if} + + + + {#if activeSection === 'metrics'} + + + (metricsPeriod = '1h')} + active={metricsPeriod === '1h'}> + 1 Hour + + (metricsPeriod = '24h')} + active={metricsPeriod === '24h'}> + 24 Hours + + (metricsPeriod = '7d')} + active={metricsPeriod === '7d'}> + 7 Days + + (metricsPeriod = '30d')} + active={metricsPeriod === '30d'}> + 30 Days + + + + {#if isLoadingMetrics} + + {#each Array(6) as _} + + + + + {/each} + + {:else if metrics} + + Resource Utilization + CPU, memory, and storage usage for the selected period. + + + + + CPU Usage + + + {formatPercent(metrics.cpuPercent as number)} + + + + + Memory Usage + + + {formatPercent(metrics.memoryPercent as number)} + + {#if metrics.memoryUsedBytes && metrics.memoryMaxBytes} + + {calculateSize(metrics.memoryUsedBytes as number)} / + {calculateSize(metrics.memoryMaxBytes as number)} + + {/if} + + + + Storage Used + + + {metrics.storageUsedBytes + ? calculateSize(metrics.storageUsedBytes as number) + : '-'} + + + + + + + + Database Activity + Connection count, IOPS, and queries per second. + + + + + Active Connections + + + {formatNumber(metrics.connectionsActive as number)} + {#if metrics.connectionsMax} + + / {formatNumber( + metrics.connectionsMax as number + )} + + {/if} + + + + + IOPS (Read) + + + {formatNumber(metrics.iopsRead as number)} + + + + + IOPS (Write) + + + {formatNumber(metrics.iopsWrite as number)} + + + + + Queries per Second + + + {formatNumber(metrics.qps as number)} + + + + + + {:else} + + Metrics data is not available for this database. Ensure metrics + collection is enabled in the database settings. + + {/if} + + {/if} + + + {#if activeSection === 'slowQueries'} + + + Queries that exceeded the slow query threshold ({database.metricsSlowQueryLogThresholdMs}ms). + + + {#if isLoadingSlowQueries} + + {#each Array(3) as _} + + {/each} + + {:else if slowQueries.total === 0} +
+ No slow queries recorded. +
+ {:else} + + + Query + Duration + Calls + User + Database + + {#each slowQueries.slowQueries as sq, i} + + + + {truncateQuery(sq.query)} + + + + {formatDurationMs(sq.durationMs)} + + + {formatNumber(sq.calls)} + + + {sq.user} + + + {sq.database} + + + {/each} + + {/if} +
+ {/if} + + + {#if activeSection === 'insights'} + + {#if isLoadingInsights} + + {#each Array(3) as _} + + {/each} + + {:else if performanceInsights} + + Query Summary + Aggregated query performance statistics. + + + + + Total Calls + + + {formatNumber(performanceInsights.totalCalls)} + + + + + Total Time + + + {formatDurationMs(performanceInsights.totalTimeMs)} + + + + + Average Time + + + {formatDurationMs(performanceInsights.avgTimeMs)} + + + + + + + {#if performanceInsights.topQueries.length > 0} + + + Top Queries by Execution Time + + + + Query + Calls + Total Time + Mean Time + Rows + + {#each performanceInsights.topQueries as tq, i} + + + + {truncateQuery(tq.query)} + + + + {formatNumber(tq.calls)} + + + {formatDurationMs(tq.totalTimeMs)} + + + {formatDurationMs(tq.meanTimeMs)} + + + {formatNumber(tq.rows)} + + + {/each} + + + {/if} + + {#if performanceInsights.waitEvents.length > 0} + + + Wait Events Analysis + + + + Event + Type + Count + Total Wait + + {#each performanceInsights.waitEvents as we, i} + + + {we.event} + + + {we.type} + + + {formatNumber(we.count)} + + + {formatDurationMs(we.totalWaitMs)} + + + {/each} + + + {/if} + {:else} + + Performance insights data is not available. Ensure metrics collection is + enabled and the database has been active. + + {/if} + + {/if} + + + {#if activeSection === 'auditLogs'} + + Database audit log entries. + + {#if isLoadingAuditLogs} + + {#each Array(3) as _} + + {/each} + + {:else if auditLogs.total === 0} +
+ No audit log entries recorded. +
+ {:else} + + + Timestamp + User + Action + Object + Statement + Client + + {#each auditLogs.auditLogs as log, i} + + + {log.timestamp ? toLocaleDateTime(log.timestamp) : '-'} + + + {log.user} + + + + + + {log.object || '-'} + + + + {truncateQuery(log.statement, 80)} + + + + {log.clientAddress || '-'} + + + {/each} + + {/if} +
+ {/if} + + + {#if activeSection === 'schema'} + + + + Current database schema (tables, columns, types, constraints, and + indexes). + + + + + {#if isLoadingSchema} + + {#each Array(5) as _} + + {/each} + + {:else if schema} + + {:else} + + Schema data is not available for this database. + + {/if} + + {/if} + + + {#if activeSection === 'schemaPreview'} + + + Preview the impact of a SQL schema change without applying it. + + + + This performs a dry-run analysis only. No changes will be applied to your + database. + + + + + + + {#if previewResult} + + {/if} + + + {#if previewResult} + + {/if} + + {/if} + + + {#if activeSection === 'explain'} + + + Run EXPLAIN on a SQL query to view its execution plan. + + + + + + + {#if explainAnalyze} + + EXPLAIN ANALYZE will execute the query against your database. Use + with caution on write operations. + + {/if} + + + + + {#if explainResult} + + {/if} + + + {#if explainResult} + + {/if} + + {/if} + + + {#if activeSection === 'tuning'} + + + + Configuration tuning recommendations based on workload analysis. + + + + + {#if isLoadingTuning} + + {#each Array(4) as _} + + {/each} + + {:else if tuningResult} + {@const entries = renderEntries(tuningResult)} + {#if entries.length === 0} + + No tuning recommendations at this time. Your configuration looks + good. + + {:else} + + + Parameter + Recommendation + + {#each entries as entry, i} + + + {entry.key} + + + {entry.value} + + + {/each} + + {/if} + {:else} + + Tuning recommendations are not available. Ensure the database has been + active with sufficient workload data. + + {/if} + + {/if} + + + {#if activeSection === 'indexes'} + + + + Index suggestions based on query patterns and table statistics. + + + + + {#if isLoadingIndexSuggestions} + + {#each Array(3) as _} + + {/each} + + {:else if indexSuggestions} + {@const entries = renderEntries(indexSuggestions)} + {#if entries.length === 0} + + No index suggestions at this time. Your indexes appear to be + well-optimized. + + {:else} + + {/if} + {:else} + + Index suggestions are not available. Ensure the database has been active + with sufficient query history. + + {/if} + + {/if} +
+
+{/if} + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.ts new file mode 100644 index 0000000000..9511c54243 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/monitoring/+page.ts @@ -0,0 +1,13 @@ +import type { PageLoad } from './$types'; +import { Dependencies } from '$lib/constants'; + +export const load: PageLoad = async ({ depends, parent }) => { + depends(Dependencies.DATABASE); + + const { database, dedicatedDatabase } = await parent(); + + return { + database, + dedicatedDatabase + }; +}; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/+page.svelte index 5a9dab551a..272eeb5913 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/+page.svelte @@ -13,16 +13,40 @@ import Delete from '../delete.svelte'; import { Query } from '@appwrite.io/console'; import { Layout, Skeleton } from '@appwrite.io/pink-svelte'; - import type { PageProps } from './$types'; + import type { Models } from '@appwrite.io/console'; import { getTerminologies } from '$database/(entity)'; - - const { data }: PageProps = $props(); + import UpdateName from './updateName.svelte'; + import UpdateTier from './updateTier.svelte'; + import UpdateStorage from './updateStorage.svelte'; + import UpdateNetwork from './updateNetwork.svelte'; + import UpdateMaintenance from './updateMaintenance.svelte'; + import UpdateBackups from './updateBackups.svelte'; + import UpdateAutoscaling from './updateAutoscaling.svelte'; + import UpdatePooler from './updatePooler.svelte'; + import UpdateExtensions from './updateExtensions.svelte'; + import UpgradeVersion from './upgradeVersion.svelte'; + import UpdateReadReplicas from './updateReadReplicas.svelte'; + import UpdateCrossRegion from './updateCrossRegion.svelte'; + import UpdateHAStatus from './updateHAStatus.svelte'; + import UpdateBackupStorage from './updateBackupStorage.svelte'; + import UpdateSqlApi from './updateSqlApi.svelte'; + import MigrateDatabaseType from './migrateDatabaseType.svelte'; + import DangerZone from './dangerZone.svelte'; + + const data = page.data; const database = $derived(data.database); + const dedicatedDatabase = $derived(data.dedicatedDatabase as Models.DedicatedDatabase | null); + + const isDedicatedType = $derived(dedicatedDatabase !== null && database.type === 'dedicateddb'); + const isDedicated = $derived(isDedicatedType); + const isShared = $derived(false); + const isPostgres = $derived(dedicatedDatabase?.engine === 'postgres'); + + // Legacy database fallback state let showDelete = $state(false); let databaseName: string | null = $state(null); - let errorMessage: string = $state('Something went wrong'); let errorType: 'error' | 'warning' | 'success' = $state('error'); let showError: false | 'name' | 'email' | 'password' = $state(false); @@ -70,7 +94,92 @@ } -{#if database} +{#if isDedicatedType && dedicatedDatabase} + + + + {dedicatedDatabase.name} + +
+

Created: {toLocaleDateTime(dedicatedDatabase.$createdAt)}

+

Last updated: {toLocaleDateTime(dedicatedDatabase.$updatedAt)}

+
+
+
+ + + + + + {#if isDedicated} + + {/if} + + + {#if isDedicated || isShared} + + {/if} + + + + + + {#if isDedicated || isShared} + + {/if} + + + + + + {#if isDedicated || isShared} + + {/if} + + + {#if isPostgres} + + {/if} + + + {#if isPostgres} + + {/if} + + + {#if isDedicated || isShared} + + {/if} + + + {#if isDedicated} + + {/if} + + + {#if isDedicated} + + {/if} + + + + + + {#if isDedicated} + + {/if} + + + + + + + + + +
+{:else if database} + {database.name} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/dangerZone.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/dangerZone.svelte new file mode 100644 index 0000000000..d404cbc762 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/dangerZone.svelte @@ -0,0 +1,52 @@ + + + + Delete database + The database will be permanently deleted, including all data and backups. This action is irreversible. + + + + +
{database.name}
+ + + {getEngineDisplayName(database.engine)} + {database.version} + + +
+
+

Last updated: {toLocaleDateTime(database.$updatedAt)}

+
+
+ + + + +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/migrateDatabaseType.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/migrateDatabaseType.svelte new file mode 100644 index 0000000000..fb056a7c34 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/migrateDatabaseType.svelte @@ -0,0 +1,102 @@ + + + + Migrate Database Type + Migrate your database between shared and dedicated types. Data is preserved during migration. + + + + + + Current Type + + + + + + Target Type + + + + + + {#if database.type === 'dedicateddb'} + Migrating to shared converts your database to a serverless pod that scales to + zero when idle, reducing costs for low-traffic workloads. + {:else} + Migrating to dedicated creates an always-on StatefulSet with external access and + persistent resources for production workloads. + {/if} + + + + + + + + + + + + Are you sure you want to migrate this database from {currentType} to + {targetLabel}? + + + Your database will be temporarily unavailable during migration. Data will be preserved. + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/rotateCredentials.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/rotateCredentials.svelte new file mode 100644 index 0000000000..85cfd82d4b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/rotateCredentials.svelte @@ -0,0 +1,86 @@ + + + + Credential rotation + Generate new database credentials. Existing connections using the old credentials will be terminated. + + + Rotating credentials will invalidate the current username and password. All active + connections will be dropped. Make sure to update your application configuration + immediately after rotation. + + + + + + + + + +

+ Are you sure you want to rotate the credentials for {database.name}? This will + generate a new username and password, and all existing connections will be terminated. +

+ + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateAutoscaling.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateAutoscaling.svelte new file mode 100644 index 0000000000..f803824e2d --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateAutoscaling.svelte @@ -0,0 +1,89 @@ + + +
+ + Storage autoscaling + Automatically increase storage when disk usage reaches a threshold. Storage will never exceed + the configured maximum. + +
    + + {#if autoscaling} + + + {/if} +
+
+ + + + +
+ diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackupStorage.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackupStorage.svelte new file mode 100644 index 0000000000..5907905ab7 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackupStorage.svelte @@ -0,0 +1,279 @@ + + +{#if !isLoading} + {#if isConfigured && config} + + Backup storage + Your database backups are stored on an external storage provider for added durability and + disaster recovery. + +
    +
  • +
    + + + Provider: + + {config.provider === 's3' + ? 'Amazon S3' + : config.provider === 'gcs' + ? 'Google Cloud Storage' + : 'Azure Blob Storage'} + + + + Bucket: + {config.bucket} + + + Region: + {config.region} + + {#if config.prefix} + + Prefix: + {config.prefix} + + {/if} + {#if config.endpoint} + + Endpoint: + {config.endpoint} + + {/if} + +
    +
  • +
+
+ + + + +
+ {:else} +
+ + Backup storage + Configure off-cluster backup storage to store backups on an external cloud provider for + added durability and disaster recovery. + +
    + + + + + + + +
+
+ + + + +
+ + {/if} + + +

+ Are you sure you want to remove the off-cluster backup storage configuration for + {database.name}? Existing backups in the external storage will not be deleted, + but new backups will no longer be stored externally. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackups.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackups.svelte new file mode 100644 index 0000000000..71bc333a8a --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateBackups.svelte @@ -0,0 +1,93 @@ + + +
+ + Backups + Configure automatic backups and point-in-time recovery for your database. + +
    + + {#if backupEnabled} + + + + {/if} +
+
+ + + + +
+ diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateConnections.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateConnections.svelte new file mode 100644 index 0000000000..d6ceecb6d4 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateConnections.svelte @@ -0,0 +1,225 @@ + + +{#if !isLoading} +
+ + Database users + Create and manage database users with specific roles. Each user receives unique credentials + for connecting to the database. + +
    + {#if connections.length > 0} +
  • + + + {#each connections as connection} + + + +
    + {connection.username} +
    + + + {connection.database} + + + + + Created: {toLocaleDateTime( + connection.$createdAt + )} + +
    + +
    +
    + {/each} +
    +
  • + {:else} +
  • +

    No additional database users created.

    +
  • + {/if} + + + +
+
+ + + + +
+ + + +

+ Are you sure you want to delete the database user + {connectionToDelete?.username}? Any active connections using this user will be + terminated. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateCrossRegion.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateCrossRegion.svelte new file mode 100644 index 0000000000..4b88ab9857 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateCrossRegion.svelte @@ -0,0 +1,310 @@ + + +{#if !isLoading} + {#if isEnabled && crossRegionStatus} + + Cross-region failover + Your database has a standby replica in another region for disaster recovery. In the event + of a regional outage, you can trigger a failover to promote the standby to primary. + +
    +
  • + +
    + + + Standby status + + + + Primary: {crossRegionStatus.primaryRegion} + • Standby: {crossRegionStatus.standbyRegion} + + + Lag: {crossRegionStatus.lagSeconds}s • Last synced: {toLocaleDateTime( + crossRegionStatus.lastSyncedAt + )} + + +
    +
    +
  • +
+
+ + + + + + + +
+ {:else} +
+ + Cross-region failover + Enable cross-region failover to maintain a standby replica in a different region for disaster + recovery. + +
    + +
+
+ + + + +
+ + {/if} + + +

+ Are you sure you want to disable cross-region failover for {database.name}? The + standby replica will be removed and your database will no longer have disaster recovery + across regions. +

+ + + + +
+ + +

+ Are you sure you want to trigger a cross-region failover for {database.name}? + This will promote the standby replica in {crossRegionStatus?.standbyRegion} + to primary. The current primary in {crossRegionStatus?.primaryRegion} will become the + new standby. This operation may cause brief downtime. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateExtensions.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateExtensions.svelte new file mode 100644 index 0000000000..eb461fbc83 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateExtensions.svelte @@ -0,0 +1,185 @@ + + +{#if !isLoading && extensions} +
+ + Extensions + Manage PostgreSQL extensions for your database. Extensions add additional functionality such + as full-text search, geospatial queries, and more. + +
    + {#if extensions.installed.length > 0} +
  • + + + {#each extensions.installed as ext} + + {/each} + +
  • + {:else} +
  • +

    No extensions installed.

    +
  • + {/if} + + {#if availableOptions.length > 0} + + {/if} +
+
+ + + + +
+ + + +

+ Are you sure you want to uninstall the extension {extensionToUninstall} from + {database.name}? Any database objects that depend on this extension may stop + working. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateHAStatus.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateHAStatus.svelte new file mode 100644 index 0000000000..fd5d4c410a --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateHAStatus.svelte @@ -0,0 +1,244 @@ + + +{#if !isLoading} +
+ + High availability + High availability maintains replicas of your database that automatically take over if the + primary instance fails, minimizing downtime. + +
    + + {#if haEnabled} + + + {/if} + + {#if haStatus && haStatus.replicas.length > 0} +
  • + + + {#each haStatus.replicas as replica} +
    + + {replica.$id} + + + + Lag: {replica.lagSeconds}s + + +
    + {/each} +
    +
  • + {/if} +
+
+ + + + {#if haEnabled && haStatus?.enabled} + + {/if} + + + +
+ + + +

+ Are you sure you want to trigger a manual failover for {database.name}? This will + promote a replica to primary. The operation may cause brief downtime while the roles are + switched. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateMaintenance.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateMaintenance.svelte new file mode 100644 index 0000000000..7fe770ec4b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateMaintenance.svelte @@ -0,0 +1,105 @@ + + +
+ + Maintenance window + Schedule a preferred time window for automatic maintenance operations such as minor version upgrades + and patches. + +
    + + + +
+
+ + + + +
+ diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateName.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateName.svelte new file mode 100644 index 0000000000..90fe25e2d4 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateName.svelte @@ -0,0 +1,63 @@ + + +
+ + Name + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateNetwork.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateNetwork.svelte new file mode 100644 index 0000000000..ac08375bfc --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateNetwork.svelte @@ -0,0 +1,87 @@ + + +
+ + Network + Configure connection limits and network access controls for your database. + +
    + + + +
+
+ + + + +
+ diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updatePooler.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updatePooler.svelte new file mode 100644 index 0000000000..dbd5f6a408 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updatePooler.svelte @@ -0,0 +1,130 @@ + + +{#if !isLoading} +
+ + Connection pooler + A connection pooler sits between your application and the database, reusing connections to + reduce overhead. Transaction mode is recommended for serverless workloads. + +
    + + {#if poolerEnabled} + + + {/if} +
+
+ + + + +
+ +{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateReadReplicas.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateReadReplicas.svelte new file mode 100644 index 0000000000..d335aba742 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateReadReplicas.svelte @@ -0,0 +1,243 @@ + + +{#if !isLoading} +
+ + Read replicas + Deploy read-only replicas of your database to other regions to reduce read latency for geographically + distributed workloads. + +
    + {#if replicas.length > 0} +
  • + + + {#each replicas as replica} +
    + + + + {replica.$id} + + + + {replica.sourceRegion} → {replica.targetRegion} + • Lag: {replica.lagSeconds}s • {replica.hostname} + + + + +
    + {/each} +
    +
  • + {:else} +
  • +

    No read replicas configured.

    +
  • + {/if} + + {#if availableRegionOptions.length > 0} + + + {/if} +
+
+ + + + +
+ + + +

+ Are you sure you want to delete the read replica + {replicaToDelete?.$id} in region {replicaToDelete?.targetRegion}? This + action cannot be undone. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSecurity.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSecurity.svelte new file mode 100644 index 0000000000..65d1cbdb7b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSecurity.svelte @@ -0,0 +1,142 @@ + + +
+ + Security + Manage encryption, key management, data residency, and audit logging for your database. + +
    +
  • +
    + + + Encryption at rest: + {database.securityEncryptionAtRest + ? 'Enabled' + : 'Disabled'} + + + Key management: + {getKeyManagementLabel(database.securityKeyManagement)} + + + Data residency: + {getResidencyLabel(database.securityDataResidency)} + + +
    +
  • + + + {#if auditLogEnabled} + + {/if} +
+
+ + + + +
+ diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSqlApi.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSqlApi.svelte new file mode 100644 index 0000000000..cf9f4aeb4b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateSqlApi.svelte @@ -0,0 +1,146 @@ + + +
+ + SQL API + The SQL API allows direct SQL query execution against your database through the Appwrite API. + Configure which statements are permitted and set resource limits. + +
    + + {#if sqlApiEnabled} + + + +
  • + + {#each allStatements as statement} + toggleStatement(statement)} /> + {/each} +
  • + {/if} +
+
+ + + + +
+ diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateStorage.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateStorage.svelte new file mode 100644 index 0000000000..1aa0025f9c --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateStorage.svelte @@ -0,0 +1,67 @@ + + +
+ + Storage + Resize the storage allocated to your database. Storage can only be increased, not decreased. + + + {#if storageGb < database.storage} + Storage can only be increased, not decreased. + {/if} + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateTier.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateTier.svelte new file mode 100644 index 0000000000..5d3a672eb9 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/updateTier.svelte @@ -0,0 +1,90 @@ + + +
+ + Resource scaling + Change the compute resources allocated to your database. Scaling may cause a brief interruption + while the database restarts. + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/upgradeVersion.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/upgradeVersion.svelte new file mode 100644 index 0000000000..9d7bca10aa --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/settings/upgradeVersion.svelte @@ -0,0 +1,137 @@ + + +{#if !isLoading} + + Version + Upgrade your database engine to a newer version. This operation uses green/blue deployment with + zero downtime. + + + + + Current version + + + {currentVersion} + + + + + + + + + + + + +

+ Are you sure you want to upgrade {database.name} from version + {currentVersion} to {targetVersion}? This operation uses green/blue + deployment with zero downtime. +

+ + + + +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts index 22a14a6f59..f57750b08e 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts @@ -1,6 +1,13 @@ import { writable } from 'svelte/store'; import type { Column } from '$lib/helpers/types'; -import { IconChartBar, IconCloudUpload, IconCog } from '@appwrite.io/pink-icons-svelte'; +import { + IconChartBar, + IconChartSquareBar, + IconCloudUpload, + IconCog, + IconGitBranch, + IconKey +} from '@appwrite.io/pink-icons-svelte'; import { resolveRoute, withPath } from '$lib/stores/navigation'; import type { Page } from '@sveltejs/kit'; import { type Models, Query } from '@appwrite.io/console'; @@ -107,6 +114,15 @@ export const databaseSubNavigationItems = [ { title: 'Settings', href: 'settings', icon: IconCog } ]; +export const dedicatedDatabaseSubNavigationItems = [ + { title: 'Backups', href: 'backups', icon: IconCloudUpload }, + { title: 'Auth', href: 'auth', icon: IconKey }, + { title: 'Branches', href: 'branches', icon: IconGitBranch }, + { title: 'Monitoring', href: 'monitoring', icon: IconChartSquareBar }, + { title: 'Usage', href: 'usage', icon: IconChartBar }, + { title: 'Settings', href: 'settings', icon: IconCog } +]; + export const randomDataModalState = writable({ show: false, value: 25, // initial value! diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte index f4084a02a2..202fe11c89 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte @@ -3,7 +3,12 @@ import type { PageData } from './$types'; import { showSubNavigation } from '$lib/stores/layout'; import { bannerSpacing } from '$lib/layout/headerAlert.svelte'; - import { showCreateEntity, databaseSubNavigationItems, buildEntityRoute } from './store'; + import { + showCreateEntity, + databaseSubNavigationItems, + dedicatedDatabaseSubNavigationItems, + buildEntityRoute + } from './store'; import { Icon, @@ -42,6 +47,9 @@ const terminology = useTerminology(page); const databaseSdk = useDatabaseSdk(page, terminology); + // Check if this is a dedicated database type + const isDedicatedType = $derived(terminology.type === 'dedicateddb'); + const entityTypePlural = terminology.entity.lower.plural; const entityTypeSingular = terminology.entity.lower.singular; @@ -78,6 +86,12 @@ ); async function loadEntities() { + // Don't load entities for dedicated databases - they don't have tables/collections + if (isDedicatedType) { + loading = false; + return; + } + try { entities = await databaseSdk.listEntities({ databaseId: page.params.database, @@ -127,83 +141,86 @@ {data.database?.name} -
- {#if loading} -
    - {#each Array(2) as _} - -
  • -
    - -
    -
  • + + {#if !isDedicatedType} +
    + {#if loading} +
      + {#each Array(2) as _} + +
    • +
      + +
      +
    • +
      + {/each} +
    + {:else if entities?.total} +
      + {#each sortedEntities as entity, index} + {@const isFirst = index === 0} + {@const isSelected = entityId === entity.$id} + {@const isLast = index === sortedEntities.length - 1} + {@const href = withPath( + databaseBaseRoute, + `/${entityTypeSingular}-${entity.$id}` + )} + + +
    • + + + {entity.name} + +
    • +
      + {/each} +
    + {:else} +
    + +
    +
    + No {entityTypePlural} yet
    - {/each} -
- {:else if entities?.total} -
    - {#each sortedEntities as entity, index} - {@const isFirst = index === 0} - {@const isSelected = entityId === entity.$id} - {@const isLast = index === sortedEntities.length - 1} - {@const href = withPath( - databaseBaseRoute, - `/${entityTypeSingular}-${entity.$id}` - )} - - -
  • - - - {entity.name} - -
  • -
    - {/each} -
- {:else} -
- -
-
- No {entityTypePlural} yet -
-
- {/if} - - - - - -
+ + {/if} + + + + + + + {/if}
@@ -213,7 +230,7 @@
    - {#each databaseSubNavigationItems as action} + {#each isDedicatedType ? dedicatedDatabaseSubNavigationItems : databaseSubNavigationItems as action} {@const href = withPath(databaseBaseRoute, `/${action.href}`)} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte index e4ec54e109..61a4a7bfc6 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte @@ -162,8 +162,13 @@ label: 'Create row', keys: page.url.pathname.endsWith(table?.$id) ? ['r'] : ['r', 'd'], callback: () => { - if (table.fields) { + if (table.fields?.length > 0) { $showRowCreateSheet.show = true; + } else { + addNotification({ + type: 'warning', + message: 'Cannot create rows: table has no fields' + }); } }, icon: IconPlus, diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.ts index cd5d75e6db..16cddad019 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.ts @@ -1,7 +1,7 @@ import Header from './header.svelte'; import type { LayoutLoad } from './$types'; import { Dependencies } from '$lib/constants'; -import { Breadcrumbs, useDatabaseSdk } from '$database/(entity)'; +import { Breadcrumbs, useDatabaseSdk, type DatabaseType } from '$database/(entity)'; import { guardResourceBlock } from '$lib/helpers/project'; export const load: LayoutLoad = async ({ params, depends, parent }) => { @@ -9,7 +9,11 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => { depends(Dependencies.TABLE); guardResourceBlock(project, ['tables', 'collections'], params.table); - const databaseSdk = useDatabaseSdk(params.region, params.project, database.type); + const databaseSdk = useDatabaseSdk( + params.region, + params.project, + database.type as DatabaseType + ); const table = await databaseSdk.getEntity({ databaseId: params.database, diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte index 0e787e1363..37b9ec20f4 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte @@ -216,7 +216,6 @@ on:click={() => (showImportCSV = true)}> - Import CSV diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/edit.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/edit.svelte index dc2d1d1c21..f6bc8e6e6b 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/edit.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/edit.svelte @@ -8,12 +8,12 @@ import { Button, InputText } from '$lib/elements/forms'; import deepEqual from 'deep-equal'; import { addNotification } from '$lib/stores/notifications'; + import type { Columns } from '$database/store'; import { columnsOrder, databaseColumnSheetOptions } from '../store'; import { columnOptions, STRING_COLUMN_NAME, type Option } from './store'; import { onMount } from 'svelte'; import { Layout } from '@appwrite.io/pink-svelte'; import { preferences } from '$lib/stores/preferences'; - import type { Columns } from '$database/store'; export let isModal = true; export let showEdit = false; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte index d0a7eba1de..a6b80d9489 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte @@ -424,7 +424,8 @@ async function handleDelete() { showDelete = false; - let hadErrors = false; + let successCount = 0; + let failedCount = 0; try { if (selectedRowForDelete) { @@ -433,6 +434,7 @@ tableId, rowId: selectedRowForDelete }); + successCount = 1; } else { if (selectedRows.length) { const hasAnyRelationships = table.fields.some(isRelationship) ?? false; @@ -444,28 +446,23 @@ if (hasAnyRelationships) { for (const batch of chunks(selectedRows)) { - try { - await Promise.all( - batch.map((rowId) => - tablesSDK.deleteRow({ - databaseId, - tableId, - rowId - }) - ) - ); - } catch (e) { - hadErrors = true; - // ignore but keep proceeding! + const results = await Promise.allSettled( + batch.map((rowId) => + tablesSDK.deleteRow({ + databaseId, + tableId, + rowId + }) + ) + ); + for (const result of results) { + if (result.status === 'fulfilled') { + successCount++; + } else { + failedCount++; + } } } - - if (hadErrors) { - addNotification({ - type: 'error', - message: 'Some rows could not be deleted' - }); - } } else { for (const batch of chunks(selectedRows, 100)) { await tablesSDK.deleteRows({ @@ -473,6 +470,7 @@ tableId, queries: [Query.equal('$id', batch)] }); + successCount += batch.length; } } } @@ -481,11 +479,15 @@ await invalidate(Dependencies.ROWS); trackEvent(Click.DatabaseRowDelete); - if (!hadErrors) { - // error is already shown above! + if (failedCount > 0) { + addNotification({ + type: 'warning', + message: `${successCount} row${successCount !== 1 ? 's' : ''} deleted, ${failedCount} failed` + }); + } else if (successCount > 0) { addNotification({ type: 'success', - message: `${selectedRows.length ? selectedRows.length : 1} row${selectedRows.length > 1 ? 's' : ''} deleted` + message: `${successCount} row${successCount !== 1 ? 's' : ''} deleted` }); } @@ -833,24 +835,6 @@ previouslyFocusedElement = null; }); } - - function getSpreadsheetCellProps( - rowId: string | undefined, - columnId: string | undefined, - state - ) { - if (columnId !== '$id' || !rowId || state?.isHeader || state?.isEmptyRow) { - return undefined; - } - - return { - style: ` - --row-expand-opacity: ${state?.hovered ? '1' : '0'}; - --row-expand-pointer-events: ${state?.hovered ? 'auto' : 'none'}; - --row-expand-transform: ${state?.hovered ? 'translateX(0)' : 'translateX(4px)'}; - ` - }; - } @@ -862,11 +846,9 @@ allowSelection useVirtualizer keyboardNavigation - showScrollbars bind:selectedRows selection={rowSelection} bind:columns={$tableColumns} - getCellProps={getSpreadsheetCellProps} loading={$spreadsheetLoading} emptyCells={emptyCellsCount} rowCount={$paginatedRows.virtualLength} diff --git a/src/routes/(console)/project-[region]-[project]/databases/empty.svelte b/src/routes/(console)/project-[region]-[project]/databases/empty.svelte index eb4a25375a..048158df21 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/empty.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/empty.svelte @@ -5,9 +5,6 @@ import { IconArrowRight } from '@appwrite.io/pink-icons-svelte'; import { Layout, Typography, Icon, Divider } from '@appwrite.io/pink-svelte'; - /*import MongoDB from './(assets)/mongo-db.svg'; - import MongoDBDark from './(assets)/dark/mongo-db.svg';*/ - import TablesDB from './(assets)/tables-db.svg'; import TablesDBDark from './(assets)/dark/tables-db.svg'; @@ -17,8 +14,13 @@ import VectorsDB from './(assets)/vectors-db.svg'; import VectorsDBDark from './(assets)/dark/vectors-db.svg'; + import DedicatedDB from './(assets)/dedicated-db.svg'; + import DedicatedDBDark from './(assets)/dark/dedicated-db.svg'; + + import { isCloud } from '$lib/system'; import { isSmallViewport } from '$lib/stores/viewport'; import type { DatabaseType } from '$database/(entity)'; + import { databaseTypes } from './store'; import { flags } from '$lib/flags'; import { user } from '$lib/stores/user'; import { organization } from '$lib/stores/organization'; @@ -32,10 +34,13 @@ } = $props(); const isDark = $derived($app.themeInUse === 'dark'); - /*const mongoDbImage = $derived(isDark ? MongoDBDark : MongoDB);*/ - const tablesDbImage = $derived(isDark ? TablesDBDark : TablesDB); - const documentsDbImage = $derived(isDark ? DocumentsDBDark : DocumentsDB); - const vectorsDbImage = $derived(isDark ? VectorsDBDark : VectorsDB); + + const images: Record = $derived({ + tablesdb: isDark ? TablesDBDark : TablesDB, + documentsdb: isDark ? DocumentsDBDark : DocumentsDB, + vectorsdb: isDark ? VectorsDBDark : VectorsDB, + dedicateddb: isDark ? DedicatedDBDark : DedicatedDB + }); {#if $isSmallViewport} @@ -55,35 +60,21 @@ >Store, organize, and manage your app data - - - {@render databaseTypeCard({ - type: 'tablesdb', - title: 'TablesDB', - subtitle: - 'Structure your data in rows and columns. Best for relational data and advanced querying.', - image: tablesDbImage - })} - - {#if flags.multiDb({ account: $user, organization: $organization })} - - {@render databaseTypeCard({ - type: 'documentsdb', - title: 'DocumentsDB', - subtitle: - 'Store flexible data without a fixed schema. Best for unstructured data and simple querying.', - image: documentsDbImage - })} - - + {@const isMultiDb = flags.multiDb({ account: $user, organization: $organization })} + {@const visibleTypes = databaseTypes.filter((db) => { + if (db.type === 'dedicateddb') return isCloud; + if (db.type === 'documentsdb' || db.type === 'vectorsdb') return isMultiDb; + return true; + })} + + {#each visibleTypes as db} {@render databaseTypeCard({ - type: 'vectorsdb', - title: 'VectorsDB', - subtitle: - 'Store data as vectors to find similar results. Best for semantic search and recommendations.', - image: vectorsDbImage + type: db.type, + title: db.title, + subtitle: db.subtitle, + image: images[db.type] })} - {/if} + {/each} {/snippet} diff --git a/src/routes/(console)/project-[region]-[project]/databases/store.ts b/src/routes/(console)/project-[region]-[project]/databases/store.ts index 79d0aa31c2..bf4658f4c1 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/store.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/store.ts @@ -23,8 +23,49 @@ export const columns = writable( ] ); -export function getDatabaseTypeTitle(database: Models.Database) { +const allDatabaseTypes: Array<{ + type: DatabaseType; + title: string; + subtitle: string; + cloudOnly?: boolean; +}> = [ + { + type: 'dedicateddb', + title: 'DedicatedDB', + subtitle: + 'Always-on dedicated instances with high availability. Best for production workloads.', + cloudOnly: true + }, + { + type: 'tablesdb', + title: 'TablesDB', + subtitle: + 'Structure your data in rows and columns. Best for relational data and advanced querying.' + }, + { + type: 'documentsdb', + title: 'DocumentsDB', + subtitle: + 'Store flexible data without a fixed schema. Best for unstructured data and simple querying.' + }, + { + type: 'vectorsdb', + title: 'VectorsDB', + subtitle: + 'Store data as vectors to find similar results. Best for semantic search and recommendations.' + } +]; + +export const databaseTypes = allDatabaseTypes.filter((db) => !db.cloudOnly || isCloud); + +export function getDatabaseTypeTitle(database: Models.Database & { engine?: string }) { switch (database.type as DatabaseType) { + case 'dedicateddb': { + const engine = database.engine || 'postgres'; + const engineName = + engine === 'postgres' ? 'PostgreSQL' : engine === 'mysql' ? 'MySQL' : engine; + return `Dedicated ${engineName}`; + } default: case 'legacy': case 'tablesdb': diff --git a/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.svelte b/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.svelte index b6d5350784..225703131d 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/usage/[[invoice]]/+page.svelte @@ -19,41 +19,39 @@ import { Accordion, Icon, Layout, Link, Table, Typography } from '@appwrite.io/pink-svelte'; import { IconChartSquareBar } from '@appwrite.io/pink-icons-svelte'; import { page } from '$app/state'; - import { BillingPlanGroup } from '@appwrite.io/console'; + import { BillingPlanGroup, type Models } from '@appwrite.io/console'; export let data; $: baseRoute = `${base}/project-${page.params.region}-${page.params.project}`; - $: network = data.usage.network; - $: users = data.usage.users; - $: usersTotal = data.usage.usersTotal; - $: executions = data.usage.executions; - $: executionsTotal = data.usage.executionsTotal; - $: storage = - data.usage.filesStorageTotal + - data.usage.deploymentsStorageTotal + - data.usage.buildsStorageTotal; - $: imageTransformations = data.usage.imageTransformations; - $: imageTransformationsTotal = data.usage.imageTransformationsTotal; - $: screenshotsGenerated = data.usage.screenshotsGenerated; - $: screenshotsGeneratedTotal = data.usage.screenshotsGeneratedTotal; - $: realtimeConnections = data.usage.realtimeConnections; - $: realtimeConnectionsTotal = data.usage.realtimeConnectionsTotal; - $: realtimeMessages = data.usage.realtimeMessages; - $: realtimeMessagesTotal = data.usage.realtimeMessagesTotal; - $: realtimeBandwidth = data.usage.realtimeBandwidth; - $: realtimeBandwidthTotal = data.usage.realtimeBandwidthTotal; - $: dbReads = data.usage.databasesReads; - $: dbWrites = data.usage.databasesWrites; + $: usage = data.usage as Models.UsageProject; + $: network = usage.network; + $: users = usage.users; + $: usersTotal = usage.usersTotal; + $: executions = usage.executions; + $: executionsTotal = usage.executionsTotal; + $: storage = usage.filesStorageTotal + usage.deploymentsStorageTotal + usage.buildsStorageTotal; + $: imageTransformations = usage.imageTransformations; + $: imageTransformationsTotal = usage.imageTransformationsTotal; + $: screenshotsGenerated = usage.screenshotsGenerated; + $: screenshotsGeneratedTotal = usage.screenshotsGeneratedTotal; + $: realtimeConnections = usage.realtimeConnections; + $: realtimeConnectionsTotal = usage.realtimeConnectionsTotal; + $: realtimeMessages = usage.realtimeMessages; + $: realtimeMessagesTotal = usage.realtimeMessagesTotal; + $: realtimeBandwidth = usage.realtimeBandwidth; + $: realtimeBandwidthTotal = usage.realtimeBandwidthTotal; + $: dbReads = usage.databasesReads; + $: dbWrites = usage.databasesWrites; $: legendData = [ { name: 'Reads', - value: clampMin(data.usage.databasesReads.reduce((sum, item) => sum + item.value, 0)) + value: clampMin(usage.databasesReads.reduce((sum, item) => sum + item.value, 0)) }, { name: 'Writes', - value: clampMin(data.usage.databasesWrites.reduce((sum, item) => sum + item.value, 0)) + value: clampMin(usage.databasesWrites.reduce((sum, item) => sum + item.value, 0)) } ]; @@ -321,13 +319,13 @@ data: [...executions.map((e) => [e.date, e.value])] } ]} /> - {#if data.usage.executionsBreakdown.length > 0} + {#if usage.executionsBreakdown.length > 0} Function Usage - {#each data.usage.executionsBreakdown as func} + {#each usage.executionsBreakdown as func} @@ -359,27 +357,27 @@ {@const humanized = humanFileSize(storage)} {@const progressBarStorageDate = [ { - size: bytesToSize(data.usage.filesStorageTotal, 'MB'), + size: bytesToSize(usage.filesStorageTotal, 'MB'), color: '#85DBD8', tooltip: { title: 'File storage', - label: `${Math.round(bytesToSize(data.usage.filesStorageTotal, 'MB') * 100) / 100}MB` + label: `${Math.round(bytesToSize(usage.filesStorageTotal, 'MB') * 100) / 100}MB` } }, { - size: bytesToSize(data.usage.deploymentsStorageTotal, 'MB'), + size: bytesToSize(usage.deploymentsStorageTotal, 'MB'), color: '#7C67FE', tooltip: { title: 'Deployments storage', - label: `${Math.round(bytesToSize(data.usage.deploymentsStorageTotal, 'MB') * 100) / 100}MB` + label: `${Math.round(bytesToSize(usage.deploymentsStorageTotal, 'MB') * 100) / 100}MB` } }, { - size: bytesToSize(data.usage.buildsStorageTotal, 'MB'), + size: bytesToSize(usage.buildsStorageTotal, 'MB'), color: '#FE9567', tooltip: { title: 'Builds storage', - label: `${Math.round(bytesToSize(data.usage.buildsStorageTotal, 'MB') * 100) / 100}MB` + label: `${Math.round(bytesToSize(usage.buildsStorageTotal, 'MB') * 100) / 100}MB` } } ]} @@ -408,25 +406,25 @@ GB hours represent the memory usage (in gigabytes) of your function executions and builds, multiplied by the total execution time (in hours). - {#if data.usage.executionsMbSecondsTotal} + {#if usage.executionsMbSecondsTotal} {@const totalGbHours = mbSecondsToGBHours( - data.usage.executionsMbSecondsTotal + data.usage.buildsMbSecondsTotal + usage.executionsMbSecondsTotal + usage.buildsMbSecondsTotal )} {@const progressBarStorageDate = [ { - size: mbSecondsToGBHours(data.usage.executionsMbSecondsTotal), + size: mbSecondsToGBHours(usage.executionsMbSecondsTotal), color: '#85DBD8', tooltip: { title: 'Executions', - label: `${(Math.round(mbSecondsToGBHours(data.usage.executionsMbSecondsTotal) * 100) / 100).toLocaleString('en-US')} GB hours` + label: `${(Math.round(mbSecondsToGBHours(usage.executionsMbSecondsTotal) * 100) / 100).toLocaleString('en-US')} GB hours` } }, { - size: mbSecondsToGBHours(data.usage.buildsMbSecondsTotal), + size: mbSecondsToGBHours(usage.buildsMbSecondsTotal), color: '#FE9567', tooltip: { title: 'Deployments', - label: `${(Math.round(mbSecondsToGBHours(data.usage.buildsMbSecondsTotal) * 100) / 100).toLocaleString('en-US')} GB hours` + label: `${(Math.round(mbSecondsToGBHours(usage.buildsMbSecondsTotal) * 100) / 100).toLocaleString('en-US')} GB hours` } } ]} @@ -564,22 +562,22 @@ Calculated for all Phone OTP sent across your project. Resets at the start of each billing cycle.
    You will not be charged for Phone OTPs before February 10th. - {#if data.usage.authPhoneTotal} + {#if usage.authPhoneTotal}
    - {formatNumberWithCommas(data.usage.authPhoneTotal)} + {formatNumberWithCommas(usage.authPhoneTotal)} OTPs

    Estimated cost - {formatCurrency(data.usage.authPhoneEstimate)} + {formatCurrency(usage.authPhoneEstimate)}

    - {#if data.usage.authPhoneCountryBreakdown.length > 0} + {#if usage.authPhoneCountryBreakdown.length > 0} @@ -587,7 +585,7 @@ Amount Estimated cost - {#each data.usage.authPhoneCountryBreakdown as phone} + {#each usage.authPhoneCountryBreakdown as phone} {getCountryName(phone.name)}