Compare commits
131 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
563a37e58d | ||
|
|
bff23720ee | ||
|
|
02cbaeffd2 | ||
|
|
9eb814c79a | ||
|
|
ebc5913bb3 | ||
|
|
4fe4a16964 | ||
|
|
92c475b7a7 | ||
|
|
679b34b031 | ||
|
|
d3186aefbd | ||
|
|
fdecac9d69 | ||
|
|
5077283028 | ||
|
|
f5f662aad1 | ||
|
|
735b779af7 | ||
|
|
4418d6abcf | ||
|
|
049e315c30 | ||
|
|
764597538b | ||
|
|
c8aea785cc | ||
|
|
e0e44b2ff4 | ||
|
|
12280f7c87 | ||
|
|
732a4f40ca | ||
|
|
d67fd599e4 | ||
|
|
a41231927a | ||
|
|
42ec665950 | ||
|
|
7225712a30 | ||
|
|
6593fdd9bb | ||
|
|
40039fece5 | ||
|
|
e5fcfb3cd5 | ||
|
|
218ec314fb | ||
|
|
9367e91d45 | ||
|
|
06c640be2c | ||
|
|
ae45be9816 | ||
|
|
ec4be590d8 | ||
|
|
5c51653aa0 | ||
|
|
7348c15ad1 | ||
|
|
44831e32a7 | ||
|
|
ee0f837762 | ||
|
|
e040979e91 | ||
|
|
68100d63b9 | ||
|
|
9b800046d7 | ||
|
|
807d8574b6 | ||
|
|
77028e4eef | ||
|
|
e0d32aab33 | ||
|
|
75c4c8ae36 | ||
|
|
1d90639e46 | ||
|
|
765b398b21 | ||
|
|
30aae1557c | ||
|
|
a3efc1d131 | ||
|
|
612d754965 | ||
|
|
b2e5f30379 | ||
|
|
3b3e83a218 | ||
|
|
0d5231f1a1 | ||
|
|
1a8332a3ca | ||
|
|
7418105de2 | ||
|
|
425d485f85 | ||
|
|
d8d25b3ea0 | ||
|
|
320513f6f5 | ||
|
|
b37053376d | ||
|
|
c21ba4aebd | ||
|
|
58948c50d4 | ||
|
|
ae324f67fa | ||
|
|
acabf2b168 | ||
|
|
73cb65b9be | ||
|
|
5e7c8395c2 | ||
|
|
c2837209e6 | ||
|
|
638710ea29 | ||
|
|
a79fddbafb | ||
|
|
ab6a8f2add | ||
|
|
69a5661bcf | ||
|
|
0886118f9d | ||
|
|
34fc08ca7c | ||
|
|
153de22713 | ||
|
|
bf4a1f6c2a | ||
|
|
2a67d0f872 | ||
|
|
b156c7b72e | ||
|
|
b484b04ae2 | ||
|
|
2e55c7f46a | ||
|
|
2d983e6ab1 | ||
|
|
df5b4302c3 | ||
|
|
828aed2df9 | ||
|
|
310df10892 | ||
|
|
555fba4400 | ||
|
|
885d10620a | ||
|
|
a8370f5aaa | ||
|
|
bd07905846 | ||
|
|
47a2164549 | ||
|
|
a96c79de00 | ||
|
|
596d0666fc | ||
|
|
9aaa407d29 | ||
|
|
1767b2f105 | ||
|
|
c99c5c4191 | ||
|
|
d845da2503 | ||
|
|
9f1ba1686c | ||
|
|
48b09a58ff | ||
|
|
2169908883 | ||
|
|
ed16c8b5de | ||
|
|
c618503376 | ||
|
|
f306c3940c | ||
|
|
ef125216bb | ||
|
|
fb43fefb5c | ||
|
|
73744c90f0 | ||
|
|
9fbea9787e | ||
|
|
e5f54bc197 | ||
|
|
10a6ae4853 | ||
|
|
d6ca1c7cfd | ||
|
|
bb85a95eda | ||
|
|
e84acf4692 | ||
|
|
2f20a70a28 | ||
|
|
e622ca0d83 | ||
|
|
819e1e97dc | ||
|
|
7c1cca0a43 | ||
|
|
0f51f4e868 | ||
|
|
97a6fcead9 | ||
|
|
b7c799d62c | ||
|
|
18b14b27fd | ||
|
|
67a867c93a | ||
|
|
0a1fb12467 | ||
|
|
78467ee348 | ||
|
|
c24eef0db9 | ||
|
|
2159b8171e | ||
|
|
8903e6abd9 | ||
|
|
7290260990 | ||
|
|
06529a1ea4 | ||
|
|
607d89e2aa | ||
|
|
0cca72311c | ||
|
|
a6525b6467 | ||
|
|
387be37b6e | ||
|
|
c8fd8bbcc7 | ||
|
|
bfb34bad00 | ||
|
|
666a75a233 | ||
|
|
3b050217df | ||
|
|
0ed4481615 |
54
.github/workflows/changesets.yaml
vendored
@@ -42,6 +42,7 @@ jobs:
|
||||
commit: 'chore: update versions'
|
||||
title: 'chore: update versions'
|
||||
publish: pnpm run release
|
||||
createGithubReleases: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -62,12 +63,39 @@ jobs:
|
||||
uses: ./.github/workflows/dashboard.yaml
|
||||
secrets: inherit
|
||||
|
||||
publish-vercel:
|
||||
name: Publish to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
- name: Setup Vercel CLI
|
||||
run: pnpm add -g vercel
|
||||
- name: Trigger a Vercel deployment
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
|
||||
run: |
|
||||
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
|
||||
publish-docker:
|
||||
name: Publish to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
- version
|
||||
- publish-vercel
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
@@ -124,32 +152,6 @@ jobs:
|
||||
if: failure()
|
||||
run: git push --delete origin ${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
|
||||
|
||||
publish-vercel:
|
||||
name: Publish to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- publish-docker
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
- name: Setup Vercel CLI
|
||||
run: pnpm add -g vercel
|
||||
- name: Trigger a Vercel deployment
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
|
||||
run: |
|
||||
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
|
||||
bump-cli:
|
||||
name: Bump Dashboard version in the Nhost CLI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
56
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push: {}
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: '20 23 * * 3'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
@@ -1,5 +1,76 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.20.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.36
|
||||
- @nhost/nextjs@1.13.38
|
||||
|
||||
## 0.20.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 75c4c8ae3: feat(dashboard): make env value input multiline
|
||||
|
||||
## 0.20.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 425d485f8: fix(dashboard): make sure dedicated resources pricing follows total resources
|
||||
|
||||
## 0.20.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ae324f67f: fix(dashboard): remove unused graphql fields
|
||||
|
||||
## 0.20.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- df5b4302c: chore(dashboard): remove run feature flag
|
||||
- bf4a1f6c2: feat(dashboard): fetch auth, postgres, hasura and storage versions from dashboard
|
||||
- 34fc08ca7: fix(dashboard/run): show correct private registry in service details
|
||||
- 885d10620: chore(dashboard): change feedback to contact us
|
||||
|
||||
## 0.20.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ed16c8b5d: feat(run): add a confirmation dialog when deleting a run service
|
||||
- 216990888: fix(run): center loading indicator when selecting a project
|
||||
|
||||
## 0.20.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9fbea9787: feat: add node18 announcement
|
||||
|
||||
## 0.20.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e84acf469: fix(run): handle subdomain undefined error when creating a new service
|
||||
|
||||
## 0.20.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b7c799d62: feat(run): add dialog to copy registry and URLs
|
||||
|
||||
## 0.20.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8903e6abd: fix(dashboard): show correct egress limit in usage stats
|
||||
|
||||
## 0.20.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 666a75a23: feat(dashboard): add functions execution time and egress volume to usage stats
|
||||
|
||||
## 0.20.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -30,7 +30,7 @@ test('should show a sidebar with menu items', async () => {
|
||||
const navLocator = page.getByRole('navigation', { name: /main navigation/i });
|
||||
await expect(navLocator).toBeVisible();
|
||||
await expect(navLocator.getByRole('list').getByRole('listitem')).toHaveCount(
|
||||
11,
|
||||
12,
|
||||
);
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /overview/i }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.20.9",
|
||||
"version": "0.20.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
@@ -37,10 +37,10 @@ export interface AnnouncementContextProps {
|
||||
|
||||
// Note: You can define the active announcement here.
|
||||
const announcement: AnnouncementType = {
|
||||
id: 'nhost-run',
|
||||
href: 'https://discord.com/invite/9V7Qb2U',
|
||||
id: 'node-18',
|
||||
href: 'https://github.com/nhost/nhost/discussions/2239',
|
||||
content:
|
||||
'Now you can bring custom and third-party OSS services to run alongside your Nhost projects',
|
||||
"Starting October 1st, we're upgrading to Node.js 18 for improved performance, security, and stability. Learn more.",
|
||||
};
|
||||
|
||||
export const AnnouncementContext = createContext<AnnouncementContextProps>({});
|
||||
|
||||
70
dashboard/src/components/common/ContactUs/ContactUs.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ContactUsProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {}
|
||||
|
||||
export default function FeedbackForm({ className, ...props }: ContactUsProps) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'grid max-w-md grid-flow-row gap-2 py-4 px-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Text variant="h3" component="h2">
|
||||
Contact us
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
To report issues with Nhost, please open a GitHub issue in the{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/nhost/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
nhost/nhost
|
||||
</Link>{' '}
|
||||
repository.
|
||||
</Text>
|
||||
<Text>
|
||||
For issues related to the CLI, please visit the{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/cli/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
nhost/cli
|
||||
</Link>{' '}
|
||||
repository.
|
||||
</Text>
|
||||
<Text>
|
||||
If you need assistance or have any questions, feel free to join us on{' '}
|
||||
<Link
|
||||
href="https://discord.com/invite/9V7Qb2U"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
Discord
|
||||
</Link>
|
||||
. Alternatively, if you prefer, you can also open a{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/nhost/discussions/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
GitHub discussion
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text>We're here to help, so don't hesitate to reach out!</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
dashboard/src/components/common/ContactUs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ContactUs';
|
||||
export { default as ContactUs } from './ContactUs';
|
||||
@@ -1,148 +0,0 @@
|
||||
import { Avatar } from '@/components/ui/v2/Avatar';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useInsertFeedbackOneMutation } from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface FeedbackFormProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {}
|
||||
|
||||
// TODO: Use `react-hook-form` here instead of the custom form implementation
|
||||
export default function FeedbackForm({
|
||||
className,
|
||||
...props
|
||||
}: FeedbackFormProps) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [insertFeedback, { loading }] = useInsertFeedbackOneMutation();
|
||||
const user = useUserData();
|
||||
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [feedbackSent, setFeedbackSent] = useState(false);
|
||||
|
||||
function handleClose() {
|
||||
setTimeout(() => {
|
||||
setFeedbackSent(false);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
const feedbackWithProjectInfo = [
|
||||
currentProject && `Project ID: ${currentProject.id}`,
|
||||
typeof window !== 'undefined' && `URL: ${window.location.href}`,
|
||||
feedback,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
try {
|
||||
await insertFeedback({
|
||||
variables: {
|
||||
feedback: {
|
||||
feedback: feedbackWithProjectInfo,
|
||||
},
|
||||
},
|
||||
});
|
||||
setFeedbackSent(true);
|
||||
setFeedback('');
|
||||
} catch (error) {
|
||||
// TODO: Display error to user and use a logging solution
|
||||
}
|
||||
}
|
||||
|
||||
if (feedbackSent) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'grid max-w-md grid-flow-row justify-center gap-4 py-4 px-5 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Image
|
||||
src="/assets/FeedbackReceived.svg"
|
||||
alt="Light bulb with a checkmark"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Text variant="h3" component="h2" className="text-center">
|
||||
Feedback Received
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
Thanks for sending us your thoughts! Feel free to send more feedback
|
||||
as you explore the beta, and stay tuned for updates.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className="mt-2 text-sm+ font-normal"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'grid max-w-md grid-flow-row gap-2 py-4 px-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Text variant="h3" component="h2">
|
||||
Leave Feedback
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
Nhost is still in beta and not everything is in place yet, but we'd
|
||||
love to know what you think of it so far.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit} className="grid grid-flow-row gap-2">
|
||||
<div className="grid grid-flow-col place-content-between gap-2">
|
||||
<Text className="font-medium">
|
||||
What do you think we should improve?
|
||||
</Text>
|
||||
|
||||
<Avatar
|
||||
className="h-6 w-6 rounded-full"
|
||||
alt={user?.displayName}
|
||||
src={user?.avatarUrl}
|
||||
>
|
||||
{user?.displayName}
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
multiline
|
||||
value={feedback}
|
||||
onChange={(event) => setFeedback(event.target.value)}
|
||||
placeholder="Your feedback"
|
||||
rows={6}
|
||||
required
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={!feedback} loading={loading}>
|
||||
Send Feedback
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './FeedbackForm';
|
||||
export { default as FeedbackForm } from './FeedbackForm';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { AccountMenu } from '@/components/layout/AccountMenu';
|
||||
import { Breadcrumbs } from '@/components/layout/Breadcrumbs';
|
||||
@@ -75,14 +75,14 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
hideChevron
|
||||
className="rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
|
||||
>
|
||||
Feedback
|
||||
Contact us
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<FeedbackForm className="max-w-md" />
|
||||
<ContactUs className="max-w-md" />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { ThemeSwitcher } from '@/components/common/ThemeSwitcher';
|
||||
import { Nav } from '@/components/presentational/Nav';
|
||||
@@ -171,7 +171,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
className="w-full"
|
||||
role={undefined}
|
||||
>
|
||||
<ListItem.Text>Feedback</ListItem.Text>
|
||||
<ListItem.Text>Contact us</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
</Dropdown.Trigger>
|
||||
@@ -180,7 +180,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm className="max-w-md" />
|
||||
<ContactUs className="max-w-md" />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)}
|
||||
|
||||
@@ -7,14 +7,15 @@ import MaterialLinearProgress, {
|
||||
|
||||
export interface LinearProgressProps extends MaterialLinearProgressProps {}
|
||||
|
||||
const LinearProgress = styled(MaterialLinearProgress)(({ theme }) => ({
|
||||
const LinearProgress = styled(MaterialLinearProgress)(({ theme, value }) => ({
|
||||
height: 12,
|
||||
borderRadius: 1,
|
||||
[`&.${linearProgressClasses.colorPrimary}`]: {
|
||||
backgroundColor: theme.palette.grey[300],
|
||||
},
|
||||
[`& .${linearProgressClasses.bar}`]: {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
backgroundColor:
|
||||
value >= 100 ? theme.palette.error.dark : theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
@@ -28,15 +30,6 @@ export type AuthServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_AUTH_VERSIONS = [
|
||||
'0.21.2',
|
||||
'0.20.1',
|
||||
'0.20.0',
|
||||
'0.19.3',
|
||||
'0.19.2',
|
||||
'0.19.1',
|
||||
];
|
||||
|
||||
export default function AuthServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
@@ -49,9 +42,16 @@ export default function AuthServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: authVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Auth,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.auth || {};
|
||||
const versions = authVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_AUTH_VERSIONS).add(version),
|
||||
new Set(versions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -7,7 +7,9 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetPostgresSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetPostgresSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
@@ -30,15 +32,6 @@ export type DatabaseServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_POSTGRES_VERSIONS = [
|
||||
'14.6-20230705-1',
|
||||
'14.6-20230613-1',
|
||||
'14.6-20230525',
|
||||
'14.6-20230406-2',
|
||||
'14.6-20230406-1',
|
||||
'14.6-20230404',
|
||||
];
|
||||
|
||||
export default function DatabaseServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
@@ -51,9 +44,16 @@ export default function DatabaseServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: databaseVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.PostgreSql,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.postgres || {};
|
||||
const databaseVersions = databaseVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_POSTGRES_VERSIONS).add(version),
|
||||
new Set(databaseVersions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -7,7 +7,9 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetHasuraSettingsQuery,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
@@ -30,16 +32,6 @@ export type HasuraServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_HASURA_VERSIONS = [
|
||||
'v2.29.0-ce',
|
||||
'v2.28.2-ce',
|
||||
'v2.27.0-ce',
|
||||
'v2.25.1-ce',
|
||||
'v2.25.0-ce',
|
||||
'v2.24.1-ce',
|
||||
'v2.15.2',
|
||||
];
|
||||
|
||||
export default function HasuraServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
@@ -53,9 +45,16 @@ export default function HasuraServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: hasuraVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Hasura,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.hasura || {};
|
||||
const versions = hasuraVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_HASURA_VERSIONS).add(version),
|
||||
new Set(versions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
@@ -99,7 +99,7 @@ export default function AppLoader({
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm />
|
||||
<ContactUs />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
@@ -250,7 +250,7 @@ export default function ApplicationErrored() {
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm />
|
||||
<ContactUs />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeedbackForm } from '@/components/common/FeedbackForm';
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
@@ -65,7 +65,7 @@ export default function ApplicationUnknown() {
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<FeedbackForm />
|
||||
<ContactUs />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useDeleteRunServiceConfigMutation,
|
||||
useDeleteRunServiceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { type RunService } from 'pages/[workspaceSlug]/[appSlug]/services';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DeleteServiceModalProps {
|
||||
service: RunService;
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteServiceModal({
|
||||
service,
|
||||
onDelete,
|
||||
close,
|
||||
}: DeleteServiceModalProps) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [deleteRunService] = useDeleteRunServiceMutation();
|
||||
const [deleteRunServiceConfig] = useDeleteRunServiceConfigMutation();
|
||||
|
||||
const deleteServiceAndConfig = async () => {
|
||||
await deleteRunService({ variables: { serviceID: service.id } });
|
||||
await deleteRunServiceConfig({
|
||||
variables: { appID: currentProject.id, serviceID: service.id },
|
||||
});
|
||||
await onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
async function handleClick() {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteServiceAndConfig(),
|
||||
{
|
||||
loading: 'Deleting the service...',
|
||||
success: `The service has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting the service. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
Delete Service {service?.config?.name}
|
||||
</Text>
|
||||
|
||||
<Text variant="subtitle2">
|
||||
Are you sure you want to delete this service?
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
This cannot be undone.
|
||||
</Text>
|
||||
|
||||
<Box className="my-4">
|
||||
<Checkbox
|
||||
id="accept-1"
|
||||
label={`I'm sure I want to delete ${service?.config?.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Project #1"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete Service
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DeleteServiceModal';
|
||||
export { default as DeleteServiceModal } from './DeleteServiceModal';
|
||||
@@ -111,7 +111,6 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
awsName: null,
|
||||
domain: null,
|
||||
},
|
||||
isProvisioned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
desiredState: ApplicationStatus.Live,
|
||||
featureFlags: [],
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { SvgIconProps } from '@/components/ui/v2/icons/SvgIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useHypertune } from '@/hooks/useHypertune';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export interface ProjectRoute {
|
||||
@@ -58,26 +57,8 @@ export interface ProjectRoute {
|
||||
export default function useProjectRoutes() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { maintenanceActive } = useUI();
|
||||
const {
|
||||
currentWorkspace,
|
||||
currentProject,
|
||||
loading: currentProjectLoading,
|
||||
} = useCurrentWorkspaceAndProject();
|
||||
|
||||
const hypertune = useHypertune();
|
||||
|
||||
const enableServices =
|
||||
currentWorkspace &&
|
||||
hypertune
|
||||
.root({
|
||||
context: {
|
||||
workSpace: {
|
||||
id: currentWorkspace.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.enableServices({})
|
||||
.get(false);
|
||||
const { currentProject, loading: currentProjectLoading } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const nhostRoutes: ProjectRoute[] = [
|
||||
{
|
||||
@@ -118,7 +99,7 @@ export default function useProjectRoutes() {
|
||||
},
|
||||
];
|
||||
|
||||
let allRoutes: ProjectRoute[] = [
|
||||
const allRoutes: ProjectRoute[] = [
|
||||
{
|
||||
relativePath: '/',
|
||||
exact: true,
|
||||
@@ -156,18 +137,14 @@ export default function useProjectRoutes() {
|
||||
label: 'Storage',
|
||||
icon: <StorageIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
if (enableServices) {
|
||||
allRoutes.push({
|
||||
{
|
||||
relativePath: '/services',
|
||||
exact: false,
|
||||
label: 'Run',
|
||||
icon: <ServicesIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
allRoutes = [...allRoutes, ...nhostRoutes];
|
||||
},
|
||||
...nhostRoutes,
|
||||
];
|
||||
|
||||
return {
|
||||
nhostRoutes,
|
||||
|
||||
@@ -128,6 +128,8 @@ export default function BaseEnvironmentVariableForm({
|
||||
error={!!errors.value}
|
||||
helperText={errors?.value?.message}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'edit'}
|
||||
/>
|
||||
|
||||
@@ -41,11 +41,6 @@ export default function OverviewMetrics() {
|
||||
numberOfDecimals: 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Egress Volume',
|
||||
tooltip: 'Amount of data your services have sent to users',
|
||||
value: prettifySize(data?.egressVolume?.value || 0),
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
tooltip: 'Amount of logs stored',
|
||||
|
||||
@@ -96,7 +96,7 @@ export function OverviewUsageMetrics() {
|
||||
remoteAppMetricsData?.filesAggregate?.aggregate?.sum?.size || 0;
|
||||
const totalStorage = currentProject?.plan?.isFree
|
||||
? 1 * 1000 ** 3 // 1 GB
|
||||
: 10 * 1000 ** 3; // 10 GB
|
||||
: 50 * 1000 ** 3; // 10 GB
|
||||
|
||||
// metrics for users
|
||||
const usedUsers = remoteAppMetricsData?.usersAggregate?.aggregate?.count || 0;
|
||||
@@ -105,6 +105,16 @@ export function OverviewUsageMetrics() {
|
||||
// metrics for functions
|
||||
const usedFunctions = functionsInfoData?.app.metadataFunctions.length || 0;
|
||||
const totalFunctions = currentProject?.plan?.isFree ? 10 : 50;
|
||||
const usedFunctionsDuration = projectMetrics?.functionsDuration.value || 0;
|
||||
const totalFunctionsDuration = currentProject?.plan?.isFree
|
||||
? 3600 // 1 hour
|
||||
: 3600 * 10; // 10 hours
|
||||
|
||||
// metrics for egress
|
||||
const usedEgressVolume = projectMetrics?.egressVolume.value || 0;
|
||||
const totalEgressVolume = currentProject?.plan?.isFree
|
||||
? 5 * 1000 ** 3 // 5 GB
|
||||
: 50 * 1000 ** 3; // 50 GB
|
||||
|
||||
if (metricsLoading) {
|
||||
return (
|
||||
@@ -112,7 +122,9 @@ export function OverviewUsageMetrics() {
|
||||
<UsageProgress label="Database" percentage={0} />
|
||||
<UsageProgress label="Storage" percentage={0} />
|
||||
<UsageProgress label="Users" percentage={0} />
|
||||
<UsageProgress label="Functions" percentage={0} />
|
||||
<UsageProgress label="Number of Functions" percentage={0} />
|
||||
<UsageProgress label="Functions Execution Time" percentage={0} />
|
||||
<UsageProgress label="Egress Volume" percentage={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,6 +151,18 @@ export function OverviewUsageMetrics() {
|
||||
used={usedFunctions}
|
||||
percentage={100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Functions"
|
||||
used={usedFunctionsDuration}
|
||||
percentage={100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Egress"
|
||||
used={usedEgressVolume}
|
||||
percentage={100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,11 +191,25 @@ export function OverviewUsageMetrics() {
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Functions"
|
||||
label="Number of Functions"
|
||||
used={usedFunctions}
|
||||
total={totalFunctions}
|
||||
percentage={(usedFunctions / totalFunctions) * 100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Functions Execution Time"
|
||||
used={Math.trunc(usedFunctionsDuration)}
|
||||
total={`${totalFunctionsDuration} seconds`}
|
||||
percentage={(usedFunctionsDuration / totalFunctionsDuration) * 100}
|
||||
/>
|
||||
|
||||
<UsageProgress
|
||||
label="Egress Volume"
|
||||
used={prettifySize(usedEgressVolume)}
|
||||
total={prettifySize(totalEgressVolume)}
|
||||
percentage={(usedEgressVolume / totalEgressVolume) * 100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { Slider, sliderClasses } from '@/components/ui/v2/Slider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useProPlan } from '@/features/projects/common/hooks/useProPlan';
|
||||
import { calculateBillableResources } from '@/features/projects/resources/settings/utils/calculateBillableResources';
|
||||
import { getAllocatedResources } from '@/features/projects/resources/settings/utils/getAllocatedResources';
|
||||
import { prettifyMemory } from '@/features/projects/resources/settings/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/projects/resources/settings/utils/prettifyVCPU';
|
||||
@@ -63,34 +62,7 @@ export default function TotalResourcesFormFragment({
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: formValues.database?.replicas,
|
||||
vcpu: formValues.database?.vcpu,
|
||||
memory: formValues.database?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.hasura?.replicas,
|
||||
vcpu: formValues.hasura?.vcpu,
|
||||
memory: formValues.hasura?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.auth?.replicas,
|
||||
vcpu: formValues.auth?.vcpu,
|
||||
memory: formValues.auth?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.storage?.replicas,
|
||||
vcpu: formValues.storage?.vcpu,
|
||||
memory: formValues.storage?.memory,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPrice =
|
||||
Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price;
|
||||
const updatedPrice = priceForTotalAvailableVCPU + proPlan.price;
|
||||
|
||||
const { vcpu: allocatedVCPU, memory: allocatedMemory } =
|
||||
getAllocatedResources(formValues);
|
||||
|
||||
@@ -43,6 +43,7 @@ import { toast } from 'react-hot-toast';
|
||||
import { parse } from 'shell-quote';
|
||||
import * as Yup from 'yup';
|
||||
import { ServiceConfirmationDialog } from './components/ServiceConfirmationDialog';
|
||||
import { ServiceDetailsDialog } from './components/ServiceDetailsDialog';
|
||||
|
||||
export enum PortTypes {
|
||||
HTTP = 'http',
|
||||
@@ -94,7 +95,7 @@ export interface ServiceFormProps extends DialogFormProps {
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: ServiceFormValues;
|
||||
initialData?: ServiceFormValues & { subdomain?: string }; // subdomain is only set on the backend
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
@@ -119,6 +120,10 @@ export default function ServiceForm({
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation();
|
||||
const [detailsServiceId, setDetailsServiceId] = useState('');
|
||||
const [detailsServiceSubdomain, setDetailsServiceSubdomain] = useState(
|
||||
initialData?.subdomain,
|
||||
);
|
||||
|
||||
const [createServiceFormError, setCreateServiceFormError] =
|
||||
useState<Error | null>(null);
|
||||
@@ -196,11 +201,13 @@ export default function ServiceForm({
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
setDetailsServiceId(serviceID);
|
||||
} else {
|
||||
// Insert service config
|
||||
const {
|
||||
data: {
|
||||
insertRunService: { id: newServiceID },
|
||||
insertRunService: { id: newServiceID, subdomain },
|
||||
},
|
||||
} = await insertRunService({
|
||||
variables: {
|
||||
@@ -227,6 +234,9 @@ export default function ServiceForm({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setDetailsServiceId(newServiceID);
|
||||
setDetailsServiceSubdomain(subdomain);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,8 +264,6 @@ export default function ServiceForm({
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
// await refetchWorkspaceAndProject();
|
||||
// refestch the services
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
@@ -277,6 +285,28 @@ export default function ServiceForm({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (detailsServiceId) {
|
||||
openDialog({
|
||||
title: 'Service Details',
|
||||
component: (
|
||||
<ServiceDetailsDialog
|
||||
serviceID={detailsServiceId}
|
||||
subdomain={detailsServiceSubdomain}
|
||||
ports={formValues.ports}
|
||||
/>
|
||||
),
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [detailsServiceId, detailsServiceSubdomain, formValues, openDialog]);
|
||||
|
||||
const pricingExplanation = () => {
|
||||
const vCPUs = `${formValues.compute.cpu / RESOURCE_VCPU_MULTIPLIER} vCPUs`;
|
||||
const mem = `${formValues.compute.memory} MiB Mem`;
|
||||
|
||||
@@ -32,20 +32,20 @@ export default function PortsFormSection() {
|
||||
name: 'ports',
|
||||
});
|
||||
|
||||
const formValues = useWatch<ServiceFormValues>();
|
||||
const formValues = useWatch<ServiceFormValues & { subdomain: string }>();
|
||||
|
||||
const onChangePortType = (value: string | undefined, index: number) =>
|
||||
setValue(`ports.${index}.type`, value as PortTypes);
|
||||
|
||||
const showURL = (index: number) =>
|
||||
formValues.subdomain &&
|
||||
formValues.ports[index]?.type === PortTypes.HTTP &&
|
||||
formValues.ports[index]?.publish;
|
||||
|
||||
const getPortURL = (_port: string | number, _name: string) => {
|
||||
const getPortURL = (_port: string | number, subdomain: string) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
const name = _name && _name.length > 0 ? _name : '[name]';
|
||||
|
||||
return `https://${currentProject?.subdomain}-${name}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -144,7 +144,7 @@ export default function PortsFormSection() {
|
||||
title="URL"
|
||||
value={getPortURL(
|
||||
formValues.ports[index]?.port,
|
||||
formValues.name,
|
||||
formValues.subdomain,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import type { ConfigRunServicePort } from '@/utils/__generated__/graphql';
|
||||
|
||||
export interface ServiceDetailsDialogProps {
|
||||
/**
|
||||
* The id of the service
|
||||
*/
|
||||
serviceID: string;
|
||||
|
||||
/**
|
||||
* The subdomain of the service
|
||||
*/
|
||||
subdomain: string;
|
||||
|
||||
/**
|
||||
* The service ports
|
||||
* We use partial here because `port` is set as required in ConfigRunServicePort
|
||||
*/
|
||||
ports: Partial<ConfigRunServicePort>[];
|
||||
}
|
||||
|
||||
export default function ServiceDetailsDialog({
|
||||
serviceID,
|
||||
subdomain,
|
||||
ports,
|
||||
}: ServiceDetailsDialogProps) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { closeDialog } = useDialog();
|
||||
|
||||
const getPortURL = (_port: string | number) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-6 pb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text color="secondary">Private registry</Text>
|
||||
<InfoCard
|
||||
title=""
|
||||
value={`registry.${currentProject.region.awsName}.${currentProject.region.domain}/${serviceID}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ports?.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text color="secondary">Ports</Text>
|
||||
{ports
|
||||
.filter((port) => port.publish)
|
||||
.map((port) => (
|
||||
<InfoCard
|
||||
title={`${port.type}:${port.port}`}
|
||||
value={getPortURL(port.port)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
color="primary"
|
||||
onClick={() => closeDialog()}
|
||||
autoFocus
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ServiceDetailsDialog';
|
||||
export { default as ServiceDetailsDialog } from './ServiceDetailsDialog';
|
||||
@@ -10,21 +10,14 @@ import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { DeleteServiceModal } from '@/features/projects/common/components/DeleteServiceModal';
|
||||
import {
|
||||
ServiceForm,
|
||||
type PortTypes,
|
||||
} from '@/features/services/components/ServiceForm';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import {
|
||||
useDeleteRunServiceConfigMutation,
|
||||
useDeleteRunServiceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { RunService } from 'pages/[workspaceSlug]/[appSlug]/services';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ServicesListProps {
|
||||
/**
|
||||
@@ -51,16 +44,7 @@ export default function ServicesList({
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: ServicesListProps) {
|
||||
const { openDrawer } = useDialog();
|
||||
const [deleteRunService] = useDeleteRunServiceMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [deleteRunServiceConfig] = useDeleteRunServiceConfigMutation();
|
||||
|
||||
const deleteServiceAndConfig = async (appID: string, serviceID: string) => {
|
||||
await deleteRunService({ variables: { serviceID } });
|
||||
await deleteRunServiceConfig({ variables: { appID, serviceID } });
|
||||
await onDelete?.();
|
||||
};
|
||||
const { openDrawer, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const viewService = async (service: RunService) => {
|
||||
openDrawer({
|
||||
@@ -76,6 +60,7 @@ export default function ServicesList({
|
||||
initialData={{
|
||||
...service.config,
|
||||
image: service.config?.image?.image,
|
||||
subdomain: service.subdomain,
|
||||
command: service.config?.command?.join(' '),
|
||||
ports: service.config?.ports?.map((item) => ({
|
||||
port: item.port,
|
||||
@@ -95,28 +80,16 @@ export default function ServicesList({
|
||||
});
|
||||
};
|
||||
|
||||
const deleteService = async (serviceID: string) => {
|
||||
await toast.promise(
|
||||
deleteServiceAndConfig(currentProject.id, serviceID),
|
||||
{
|
||||
loading: 'Deleteing the service...',
|
||||
success: `The service has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting the service. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
const deleteService = async (service: RunService) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<DeleteServiceModal
|
||||
service={service}
|
||||
close={closeDialog}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -203,7 +176,7 @@ export default function ServicesList({
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() => deleteService(service.id)}
|
||||
onClick={() => deleteService(service)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
|
||||
@@ -7,6 +7,8 @@ import { filterOptions } from '@/components/ui/v2/Autocomplete';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetStorageSettingsDocument,
|
||||
Software_Type_Enum,
|
||||
useGetSoftwareVersionsQuery,
|
||||
useGetStorageSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
@@ -30,8 +32,6 @@ export type StorageServiceVersionFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
const AVAILABLE_STORAGE_VERSIONS = ['0.3.5', '0.3.4', '0.3.3', '0.3.2'];
|
||||
|
||||
export default function StorageServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
@@ -44,9 +44,16 @@ export default function StorageServiceVersionSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { data: storageVersionsData } = useGetSoftwareVersionsQuery({
|
||||
variables: {
|
||||
software: Software_Type_Enum.Storage,
|
||||
},
|
||||
});
|
||||
|
||||
const { version } = data?.config?.storage || {};
|
||||
const versions = storageVersionsData?.softwareVersions || [];
|
||||
const availableVersions = Array.from(
|
||||
new Set(AVAILABLE_STORAGE_VERSIONS).add(version),
|
||||
new Set(versions.map((el) => el.version)).add(version),
|
||||
)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
@@ -17,6 +17,9 @@ query GetProjectMetrics(
|
||||
) {
|
||||
value
|
||||
}
|
||||
functionsDuration: getFunctionsDuration(appID: $appId, from: $from, to: $to) {
|
||||
value
|
||||
}
|
||||
postgresVolumeCapacity: getPostgresVolumeCapacity(appID: $appId) {
|
||||
value
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ fragment Project on apps {
|
||||
name
|
||||
repositoryProductionBranch
|
||||
subdomain
|
||||
isProvisioned
|
||||
createdAt
|
||||
desiredState
|
||||
nhostBaseFolder
|
||||
|
||||
9
dashboard/src/gql/platform/getSoftwareVersions.gql
Normal file
@@ -0,0 +1,9 @@
|
||||
query getSoftwareVersions($software: software_type_enum!) {
|
||||
softwareVersions(
|
||||
where: { software: { _eq: $software } }
|
||||
order_by: { version: desc }
|
||||
) {
|
||||
version
|
||||
software
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
query getRunService($id: uuid!, $resolve: Boolean!) {
|
||||
runService(id: $id) {
|
||||
id
|
||||
subdomain
|
||||
config(resolve: $resolve) {
|
||||
name
|
||||
image {
|
||||
|
||||
@@ -9,6 +9,7 @@ query getRunServices(
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
subdomain
|
||||
config(resolve: $resolve) {
|
||||
name
|
||||
image {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mutation insertRunService($object: run_service_insert_input!) {
|
||||
insertRunService(object: $object) {
|
||||
id
|
||||
appID
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,10 +133,12 @@ export default function SelectWorkspaceAndProject() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading workspaces and projects..."
|
||||
/>
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading workspaces and projects..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ export const mockApplication: Project = {
|
||||
slug: 'test-application',
|
||||
appStates: [],
|
||||
subdomain: '',
|
||||
isProvisioned: true,
|
||||
region: {
|
||||
awsName: 'us-east-1',
|
||||
city: 'New York',
|
||||
|
||||
2128
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -1,5 +1,33 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4fe4a1696: return `refreshToken` immediately after signIn and signUp
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 612d75496: updated postgres and graphql documentation
|
||||
- 3b3e83a21: fix(docs): correct rendering of mermaid diagrams in dark mode
|
||||
- 765b398b2: added jit settings documentation
|
||||
- 30aae1557: minor fix to performance documentation
|
||||
- a3efc1d13: docs: added storage/antivirus documentation
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9aaa407d2: Fix messaging around Run's private beta
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 819e1e97d: update fqdn format for nhost run
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
159
docs/docs/database/extensions.mdx
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: 'Extensions'
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
## postgis
|
||||
|
||||
PostGIS extends the capabilities of the PostgreSQL relational database by adding support storing, indexing and querying geographic data.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION postgis;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION postgis;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [Official website](https://postgis.net)
|
||||
|
||||
## pgvector
|
||||
|
||||
Open-source vector similarity search for Postgres. Store your vectors with the rest of your data. Supports:
|
||||
|
||||
* exact and approximate nearest neighbor search
|
||||
* L2 distance, inner product, and cosine distance
|
||||
* any language with a Postgres client
|
||||
|
||||
Plus ACID compliance, point-in-time recovery, JOINs, and all of the other great features of Postgres
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION vector;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION vector;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/pgvector/pgvector)
|
||||
|
||||
## pg_cron
|
||||
|
||||
pg_cron is a simple cron-based job scheduler for PostgreSQL (10 or higher) that runs inside the database as an extension. It uses the same syntax as regular cron, but it allows you to schedule PostgreSQL commands directly from the database. You can also use '[1-59] seconds' to schedule a job based on an interval.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION pg_cron;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION pg_cron;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/citusdata/pg_cron)
|
||||
|
||||
## hypopg
|
||||
|
||||
HypoPG is a PostgreSQL extension adding support for hypothetical indexes.
|
||||
|
||||
An hypothetical -- or virtual -- index is an index that doesn't really exists, and thus doesn't cost CPU, disk or any resource to create. They're useful to know if specific indexes can increase performance for problematic queries, since you can know if PostgreSQL will use these indexes or not without having to spend resources to create them.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION hypopg;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION hypopg;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/HypoPG/hypopg)
|
||||
* [Documentation](https://hypopg.readthedocs.io)
|
||||
|
||||
## timescaledb
|
||||
|
||||
TimescaleDB is an open-source database designed to make SQL scalable for time-series data. It is engineered up from PostgreSQL and packaged as a PostgreSQL extension, providing automatic partitioning across time and space (partitioning key), as well as full SQL support.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION timescaledb;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION timescaledb;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/timescale/timescaledb)
|
||||
* [Documentation](https://docs.timescale.com)
|
||||
* [Website](https://www.timescale.com)
|
||||
|
||||
## pg_stat_statements
|
||||
|
||||
The pg_stat_statements module provides a means for tracking planning and execution statistics of all SQL statements executed by a server.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION pg_stat_statements;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION pg_stat_statements;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [Documentation](https://www.postgresql.org/docs/14/pgstatstatements.html)
|
||||
89
docs/docs/database/performance.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: 'Performance'
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
Ensuring a healthy and performant PostgreSQL service is crucial as it directly impacts the overall response time and stability of your backend. Since Postgres serves as the centerpiece of your backend, prioritize the optimization and maintenance of your Postgres service to achieve the desired performance and reliability.
|
||||
|
||||
In case your Postgres service is not meeting your performance expectations, you can explore the following options:
|
||||
|
||||
1. Consider upgrading your [dedicated compute](/platform/compute) resources to provide more processing power and memory to the Postgres server.
|
||||
|
||||
2. Fine-tune the configuration parameters of Postgres to optimize its performance. Adjust settings such as `shared_buffers`, `work_mem`, and `effective_cache_size` to better align with your workload and server resources.
|
||||
|
||||
3. Identify and analyze slow-performing queries using tools like query logs or query monitoring extensions. Optimize or rewrite these queries to improve their efficiency.
|
||||
|
||||
4. Evaluate the usage of indexes in your database. Identify queries that could benefit from additional indexes and strategically add them to improve query performance.
|
||||
|
||||
By implementing these steps, you can effectively address performance concerns and enhance the overall performance of your Postgres service.
|
||||
|
||||
## Upgrade to our latest postgres image
|
||||
|
||||
Before trying anything else, always upgrade to our latest postgres image first. You can find our availables images in the dashbhoard, under your database settings.
|
||||
|
||||
## Upgrading dedicated compute
|
||||
|
||||
Increasing CPU and memory is the simplest way to address performance issues. You can read more about compute resources [here](/platform/compute).
|
||||
|
||||
## Fine-tune configuration parameters
|
||||
|
||||
When optimizing your Postgres setup, you can consider adjusting various Postgres settings. You can find a list of these parameters [here](/database/settings). Keep in mind that the optimal values for these parameters will depend on factors such as available resources, workload, and data distribution.
|
||||
|
||||
To help you get started, you can use [pgtune](https://pgtune.leopard.in.ua) as a reference tool. Pgtune can generate recommended configuration settings based on your system specifications. By providing information about your system, it can suggest parameter values that may be a good starting point for optimization.
|
||||
|
||||
However, it's important to note that the generated settings from pgtune are not guaranteed to be the best for your specific environment. It's always recommended to review and customize the suggested settings based on your particular requirements, performance testing, and ongoing monitoring of your Postgres database.
|
||||
|
||||
## Identifying slow queries
|
||||
|
||||
Monitoring slow queries is a highly effective method for tackling performance issues. Several tools leverage [pg_stat_statements](https://www.postgresql.org/docs/14/pgstatstatements.html), a PostgreSQL extension, to provide constant monitoring. You can employ these tools to identify and address slow queries in real-time.
|
||||
|
||||
### pghero
|
||||
|
||||
[PgHero](https://github.com/ankane/pghero) is one of such tools you can use to idenfity and address slow queries. You can easily run pghero alongside your postgres with [Nhost Run](/run):
|
||||
|
||||
1. First, make sure the extension [pg_stat_statements](/database/extensions#pg_stat_statements) is enabled.
|
||||
|
||||
2. Click on this [one-click install link](https://app.nhost.io:/run-one-click-install?config=eyJuYW1lIjoicGdoZXJvIiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vYW5rYW5lL3BnaGVybzpsYXRlc3QifSwiY29tbWFuZCI6W10sInJlc291cmNlcyI6eyJjb21wdXRlIjp7ImNwdSI6MTI1LCJtZW1vcnkiOjI1Nn0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbeyJuYW1lIjoiREFUQUJBU0VfVVJMIiwidmFsdWUiOiJwb3N0Z3JlczovL3Bvc3RncmVzOltQQVNTV09SRF1AcG9zdGdyZXMtc2VydmljZTo1NDMyL1tTVUJET01BSU5dP3NzbG1vZGU9ZGlzYWJsZSJ9LHsibmFtZSI6IlBHSEVST19VU0VSTkFNRSIsInZhbHVlIjoiW1VTRVJdIn0seyJuYW1lIjoiUEdIRVJPX1BBU1NXT1JEIiwidmFsdWUiOiJbUEFTU1dPUkRdIn1dLCJwb3J0cyI6W3sicG9ydCI6ODA4MCwidHlwZSI6Imh0dHAiLCJwdWJsaXNoIjp0cnVlfV19)
|
||||
|
||||
3. Select your project:
|
||||

|
||||
|
||||
4. Replace the placeholders with your postgres password, subdomain and a user and password to protect your pghero service. Finally, click on create.
|
||||

|
||||
|
||||
5. After confirming the service, copy the URL:
|
||||

|
||||
|
||||
6. Finally, you can open the link you just copied to access pghero:
|
||||
|
||||

|
||||
|
||||
|
||||
:::info
|
||||
When you create a new service, it can take a few minutes for the DNS (Domain Name System) to propagate. If your browser displays an error stating that it couldn't find the server or website, simply wait for a couple of minutes and then try again.
|
||||
:::
|
||||
|
||||
After successfully setting up pghero, it will begin displaying slow queries, suggesting index proposals, and offering other valuable information. Utilize this data to enhance your service's performance.
|
||||
|
||||
## Adding indexes
|
||||
|
||||
Indexes can significantly enhance the speed of data retrieval. However, it's essential to be aware that they introduce additional overhead during mutations. Therefore, understanding your workload is crucial before opting to add an index.
|
||||
|
||||
There are tools you can use to help analyze your workload and detect missing indexes.
|
||||
|
||||
### pghero
|
||||
|
||||
[PgHero](https://github.com/ankane/pghero), in addition to help with slow queries, can also help finding missing and duplicate indexes. See previous section on how to deploy pghero with [Nhost Run](/run).
|
||||
|
||||
### dexter
|
||||
|
||||
[Dexter](https://github.com/ankane/dexter) can leverage both [pg_stat_statements](https://www.postgresql.org/docs/14/pgstatstatements.html) and [hypopg](https://hypopg.readthedocs.io/en/rel1_stable/) to find and evaluate indexes. You can run dexter directly from your machine:
|
||||
|
||||
1. Enable [hypopg](/database/extensions#hypopg)
|
||||
2. Execute the command `docker run --rm -it ankane/dexter [POSTGRES_CONN_STRING] --pg-stat-statements`
|
||||
|
||||
```
|
||||
$ docker run --rm -it ankane/dexter [POSTGRES_CONN_STRING] --pg-stat-statements
|
||||
Processing 1631 new query fingerprints
|
||||
No new indexes found
|
||||
```
|
||||
84
docs/docs/database/settings.mdx
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: 'Settings'
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Below you can find the official schema (cue) and an example to configure your postgres database:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="schema" label="schema">
|
||||
|
||||
```cue
|
||||
#Postgres: {
|
||||
version: string | *"14.6-20230705-1"
|
||||
|
||||
// Resources for the service, optional
|
||||
resources?: #Resources & {
|
||||
replicas: 1
|
||||
}
|
||||
|
||||
// postgres settings of the same name in camelCase, optional
|
||||
settings?: {
|
||||
jit: "off" | "on" | *"on"
|
||||
maxConnections: int32 | *100
|
||||
sharedBuffers: string | *"128MB"
|
||||
effectiveCacheSize: string | *"4GB"
|
||||
maintenanceWorkMem: string | *"64MB"
|
||||
checkpointCompletionTarget: number | *0.9
|
||||
walBuffers: int32 | *-1
|
||||
defaultStatisticsTarget: int32 | *100
|
||||
randomPageCost: number | *4.0
|
||||
effectiveIOConcurrency: int32 | *1
|
||||
workMem: string | *"4MB"
|
||||
hugePages: string | *"try"
|
||||
minWalSize: string | *"80MB"
|
||||
maxWalSize: string | *"1GB"
|
||||
maxWorkerProcesses: int32 | *8
|
||||
maxParallelWorkersPerGather: int32 | *2
|
||||
maxParallelWorkers: int32 | *8
|
||||
maxParallelMaintenanceWorkers: int32 | *2
|
||||
}
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml" default>
|
||||
|
||||
```toml
|
||||
[postgres]
|
||||
version = '14.6-20230925-1'
|
||||
|
||||
[postgres.resources.compute]
|
||||
cpu = 1000
|
||||
memory = 2048
|
||||
|
||||
[postgres.settings]
|
||||
jit = "off"
|
||||
maxConnections = 100
|
||||
sharedBuffers = '256MB'
|
||||
effectiveCacheSize = '768MB'
|
||||
maintenanceWorkMem = '64MB'
|
||||
checkpointCompletionTarget = 0.9
|
||||
walBuffers = -1
|
||||
defaultStatisticsTarget = 100
|
||||
randomPageCost = 1.1
|
||||
effectiveIOConcurrency = 200
|
||||
workMem = '1310kB'
|
||||
hugePages = 'off'
|
||||
minWalSize = '80MB'
|
||||
maxWalSize = '1GB'
|
||||
maxWorkerProcesses = 8
|
||||
maxParallelWorkersPerGather = 2
|
||||
maxParallelWorkers = 8
|
||||
maxParallelMaintenanceWorkers = 2
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
:::info
|
||||
At the time of writing this document postgres settings are only supported via the [configuration file](https://nhost.io/blog/config).
|
||||
:::
|
||||
@@ -144,6 +144,14 @@ One of the most common permission requirements is that authenticated users shoul
|
||||
1. Select the **columns** you want the user to be able to read. In our case, we'll allow the user to read all columns.
|
||||
1. Click **Save**.
|
||||
|
||||
## Known issues
|
||||
|
||||
### Permissions are slow
|
||||
|
||||
In certain situations, permission checks can cause significant delays. One way to identify this issue is by comparing the execution time of a GraphQL query when performed as an admin versus as a regular user. To resolve such cases, disabling the Just-in-Time (JIT) compilation in [Postgres](/database/settings) can be beneficial.
|
||||
|
||||
[Github issue](https://github.com/hasura/graphql-engine/issues/3672)
|
||||
|
||||
## Next Steps
|
||||
|
||||
Hasura has more in-depth documentation related to permissions that you can learn from:
|
||||
|
||||
174
docs/docs/graphql/settings.mdx
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: 'Settings'
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Below you can find the official schema (cue) and an example to configure your graphql service:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="schema" label="schema">
|
||||
|
||||
```cue
|
||||
// Configuration for hasura service
|
||||
#Hasura: {
|
||||
// Version of hasura, you can see available versions in the URL below:
|
||||
// https://hub.docker.com/r/hasura/graphql-engine/tags
|
||||
version: string | *"v2.33.4-ce"
|
||||
|
||||
// JWT Secrets configuration
|
||||
jwtSecrets: [#JWTSecret]
|
||||
|
||||
// Admin secret
|
||||
adminSecret: string
|
||||
|
||||
// Webhook secret
|
||||
webhookSecret: string
|
||||
|
||||
// Configuration for hasura services
|
||||
// Reference: https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/
|
||||
settings: {
|
||||
// HASURA_GRAPHQL_CORS_DOMAIN
|
||||
corsDomain: [...#Url] | *["*"]
|
||||
// HASURA_GRAPHQL_DEV_MODE
|
||||
devMode: bool | *true
|
||||
// HASURA_GRAPHQL_ENABLE_ALLOWLIST
|
||||
enableAllowList: bool | *false
|
||||
// HASURA_GRAPHQL_ENABLE_CONSOLE
|
||||
enableConsole: bool | *true
|
||||
// HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS
|
||||
enableRemoteSchemaPermissions: bool | *false
|
||||
// HASURA_GRAPHQL_ENABLED_APIS
|
||||
enabledAPIs: [...#HasuraAPIs] | *["metadata", "graphql", "pgdump", "config"]
|
||||
|
||||
// HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL
|
||||
liveQueriesMultiplexedRefetchInterval: uint32 | *1000
|
||||
}
|
||||
|
||||
logs: {
|
||||
// HASURA_GRAPHQL_LOG_LEVEL
|
||||
level: "debug" | "info" | "error" | *"warn"
|
||||
}
|
||||
|
||||
events: {
|
||||
// HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE
|
||||
httpPoolSize: uint32 & >=1 & <=100 | *100
|
||||
}
|
||||
|
||||
// Resources for the service
|
||||
resources?: #Resources
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml" default>
|
||||
|
||||
```toml
|
||||
[hasura]
|
||||
version = ''
|
||||
adminSecret = 'adminsecret'
|
||||
webhookSecret = 'webhooksecret'
|
||||
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = 'secret'
|
||||
|
||||
[hasura.settings]
|
||||
corsDomain = ['*']
|
||||
devMode = false
|
||||
enableAllowList = true
|
||||
enableConsole = true
|
||||
enableRemoteSchemaPermissions = true
|
||||
enabledAPIs = ['metadata']
|
||||
liveQueriesMultiplexedRefetchInterval = 1000
|
||||
|
||||
[hasura.logs]
|
||||
level = 'warn'
|
||||
|
||||
[hasura.events]
|
||||
httpPoolSize = 10
|
||||
|
||||
[hasura.resources]
|
||||
replicas = 1
|
||||
|
||||
[hasura.resources.compute]
|
||||
cpu = 500
|
||||
memory = 1024
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### JWT Secret
|
||||
|
||||
All formats supported by [hasura](https://hasura.io/docs/latest/auth/authentication/jwt/) should be supported:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="schema" label="schema" default>
|
||||
|
||||
```cue
|
||||
#JWTSecret:
|
||||
({
|
||||
type: "HS384" | "HS512" | "RS256" | "RS384" | "RS512" | "Ed25519" | *"HS256"
|
||||
key: string
|
||||
} |
|
||||
{
|
||||
jwk_url: #Url | *null
|
||||
}) &
|
||||
{
|
||||
claims_format?: "stringified_json" | *"json"
|
||||
audience?: string
|
||||
issuer?: string
|
||||
allowed_skew?: uint32
|
||||
header?: string
|
||||
} & {
|
||||
claims_map?: [...#ClaimMap]
|
||||
|
||||
} &
|
||||
({
|
||||
claims_namespace: string | *"https://hasura.io/jwt/claims"
|
||||
} |
|
||||
{
|
||||
claims_namespace_path: string
|
||||
} | *{})
|
||||
|
||||
#ClaimMap: {
|
||||
claim: string
|
||||
{
|
||||
value: string
|
||||
} | {
|
||||
path: string
|
||||
default?: string
|
||||
}
|
||||
} & {
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
```toml
|
||||
# example 1
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = 'secret'
|
||||
|
||||
# example 2
|
||||
[[hasura.jwtSecrets]]
|
||||
jwk_url = 'https:/....'
|
||||
|
||||
# example 3
|
||||
[[hasura.jwtSecrets]]
|
||||
jwk_url = "https://......"
|
||||
issuer = "https://my-auth-server.com"
|
||||
|
||||
[[hasura.jwtSecrets.claims_map]]
|
||||
claim = "x-some-claim"
|
||||
value = "some-value"
|
||||
|
||||
[[hasura.jwtSecrets.claims_map]]
|
||||
claim = "x-other-claim"
|
||||
path = "$.user.claim.id"
|
||||
default = "default-value"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -112,8 +112,8 @@ Currently, only services of type `http` can be exposed to the internet.
|
||||
|
||||
2. Once the service of type `http` is published, you can connect to it using a URL with the following format:
|
||||
|
||||
`https://<subdomain>-<svc_name>-<port>.svc.<region>.nhost.run`
|
||||
`https://<run_service_subdomain>-<port>.svc.<region>.nhost.run`
|
||||
|
||||
For example:
|
||||
|
||||
`https://zlbmqjfczuwqvsquujno-mysvc-3000.svc.eu-central-1.nhost.run`
|
||||
`https://zlbmqjfczuwqvsquujno-3000.svc.eu-central-1.nhost.run`
|
||||
|
||||
@@ -12,7 +12,7 @@ Nhost Run enables you to seamlessly incorporate your custom software within your
|
||||

|
||||
|
||||
:::info
|
||||
Currently Nhost Run is in private beta. If you are interested in using this service feel free to reach out to us via [email](mailto:support@nhost.io), [GitHub](https://github.com/nhost/nhost/issues), or [Discord](https://discord.com/invite/9V7Qb2U)
|
||||
Currently Nhost Run is in public beta. If you find any bugs or if you have any feedback and suggestions, please reach out to us via [email](mailto:support@nhost.io), [GitHub](https://github.com/nhost/nhost/issues), or [Discord](https://discord.com/invite/9V7Qb2U)
|
||||
:::
|
||||
|
||||
## Use Cases
|
||||
|
||||
4
docs/docs/storage/_category_.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Storage",
|
||||
"position": 7
|
||||
}
|
||||
44
docs/docs/storage/av.mdx
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Antivirus
|
||||
sidebar_label: Antivirus
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Integration with [clamav](https://www.clamav.net) antivirus relies on an external [clamd](https://docs.clamav.net/manual/Usage/Scanning.html#clamd) service. When a file is uploaded `hasura-storage` will create the file metadata first and then check if the file is clean with `clamd` via its TCP socket. If the file is clean the rest of the process will continue as usual. If a virus is found details about the virus will be added to the `virus` table and the rest of the process will be aborted.
|
||||
|
||||
``` mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
User ->> storage: upload file
|
||||
storage ->>clamav: check for virus
|
||||
alt virus found
|
||||
storage-->s3: abort upload
|
||||
storage->>graphql: insert row in virus table
|
||||
else virus not found
|
||||
storage->>s3: upload
|
||||
storage->>graphql: update metadata
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
To enable the antivirus you need to follow the next steps:
|
||||
|
||||
|
||||
1. Deploy using [Nhost Run](/run) a dedicated instance of `clamd` with this [one-click install link](https://app.nhost.io:/run-one-click-install?config=eyJuYW1lIjoiY2xhbWF2IiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vbmhvc3QvY2xhbWF2OjAuMS4xIn0sImNvbW1hbmQiOltdLCJyZXNvdXJjZXMiOnsiY29tcHV0ZSI6eyJjcHUiOjEwMDAsIm1lbW9yeSI6MjA0OH0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbXSwicG9ydHMiOlt7InBvcnQiOiIzMzEwIiwidHlwZSI6InRjcCIsInB1Ymxpc2giOmZhbHNlfV19).
|
||||
2. Select the project:
|
||||

|
||||
3. Click on "Create":
|
||||

|
||||
4. Make sure you are running **at least** storage version 0.4.0 and enable the antivirus:
|
||||

|
||||
5. Wait for the service to update and try to upload a sample virus file like [eicar](https://www.eicar.org/download-anti-malware-testfile/)
|
||||

|
||||
6. If the setup is working the upload should fail
|
||||

|
||||
7. You can also head to hasura and verify entries were added to the `virus` table:
|
||||

|
||||
|
||||
That entry should have useful information about like the filename, the virus found and the user session. In addition, the information on that table can be used a source for events.
|
||||
57
docs/docs/storage/example_crm.mdx
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: "Example: CRM System"
|
||||
sidebar_label: "Example: CRM System"
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
Let's say you want to build a CRM system and you want to store files for customers. This is one way how you could do that.
|
||||
|
||||
Start with, you would have two tables:
|
||||
|
||||
1. `customers` - Customer data.
|
||||
2. `customer_files` - What file belongs to what customer
|
||||
|
||||
```text
|
||||
- customers
|
||||
- id
|
||||
- name
|
||||
- address
|
||||
|
||||
customer_files
|
||||
- id
|
||||
- customer_id (Foreign Key to `customers.id`)
|
||||
- file_id (Foreign Key to `storage.files.id`)
|
||||
```
|
||||
|
||||
You would also create a [Hasura Relationship](https://hasura.io/docs/latest/graphql/core/databases/postgres/schema/table-relationships/index/) (GraphQL relationship) between between `customers` and `customer_files` and between `customer_files` and `storage.files`.
|
||||
|
||||
With the two tables and GraphQL relationships in place, you can query customers and the customer's files like this:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
customers {
|
||||
# customers table
|
||||
id
|
||||
name
|
||||
customer_files {
|
||||
# customer_files table
|
||||
id
|
||||
file {
|
||||
# storage.files table
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The file upload process would be as follows:
|
||||
|
||||
1. Upload a file.
|
||||
2. Get the returned file id.
|
||||
3. Insert (GraphQL Mutation) the file `id` and the customer's `id` into the `customer_files` table.
|
||||
|
||||
This would allow you to upload and download files belonging to specific customers in your CRM system.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 'Storage'
|
||||
sidebar_position: 7
|
||||
title: 'Overview'
|
||||
image: /img/og/storage.png
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
@@ -195,57 +195,3 @@ Image Transformation works on both public and pre-signed URLs.
|
||||
```text
|
||||
https://[subdomain].storage.[region].nhost.run/v1/files/08e6fa32-0880-4d0e-a832-278198acb363?w=500
|
||||
```
|
||||
|
||||
## Example: CRM System
|
||||
|
||||
Let's say you want to build a CRM system and you want to store files for customers. This is one way how you could do that.
|
||||
|
||||
Start with, you would have two tables:
|
||||
|
||||
1. `customers` - Customer data.
|
||||
2. `customer_files` - What file belongs to what customer
|
||||
|
||||
```text
|
||||
- customers
|
||||
- id
|
||||
- name
|
||||
- address
|
||||
|
||||
customer_files
|
||||
- id
|
||||
- customer_id (Foreign Key to `customers.id`)
|
||||
- file_id (Foreign Key to `storage.files.id`)
|
||||
```
|
||||
|
||||
You would also create a [Hasura Relationship](https://hasura.io/docs/latest/graphql/core/databases/postgres/schema/table-relationships/index/) (GraphQL relationship) between between `customers` and `customer_files` and between `customer_files` and `storage.files`.
|
||||
|
||||
With the two tables and GraphQL relationships in place, you can query customers and the customer's files like this:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
customers {
|
||||
# customers table
|
||||
id
|
||||
name
|
||||
customer_files {
|
||||
# customer_files table
|
||||
id
|
||||
file {
|
||||
# storage.files table
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The file upload process would be as follows:
|
||||
|
||||
1. Upload a file.
|
||||
2. Get the returned file id.
|
||||
3. Insert (GraphQL Mutation) the file `id` and the customer's `id` into the `customer_files` table.
|
||||
|
||||
This would allow you to upload and download files belonging to specific customers in your CRM system.
|
||||
@@ -26,6 +26,10 @@ const config = {
|
||||
favicon: 'img/favicon.png',
|
||||
organizationName: 'nhost',
|
||||
projectName: 'docs',
|
||||
markdown: {
|
||||
mermaid: true
|
||||
},
|
||||
themes: ['@docusaurus/theme-mermaid'],
|
||||
scripts: [
|
||||
{ src: 'https://plausible.io/js/script.js', defer: true, 'data-domain': 'docs.nhost.io' }
|
||||
],
|
||||
@@ -44,7 +48,6 @@ const config = {
|
||||
routeBasePath: '/',
|
||||
breadcrumbs: false,
|
||||
sidebarPath: require.resolve('./sidebars.js'),
|
||||
remarkPlugins: [require('mdx-mermaid')],
|
||||
editUrl: 'https://github.com/nhost/nhost/edit/main/docs/'
|
||||
},
|
||||
theme: {
|
||||
@@ -177,6 +180,7 @@ const config = {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
defaultLanguage: 'javascript',
|
||||
additionalLanguages: ['cue', 'toml'],
|
||||
magicComments: [
|
||||
{
|
||||
className: 'code-block-error-line',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
@@ -19,12 +19,13 @@
|
||||
"@docusaurus/core": "2.4.1",
|
||||
"@docusaurus/plugin-sitemap": "2.4.1",
|
||||
"@docusaurus/preset-classic": "2.4.1",
|
||||
"@docusaurus/theme-mermaid": "2.4.1",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-image-zoom": "^0.1.1",
|
||||
"mdx-mermaid": "^1.3.2",
|
||||
"mermaid": "^9.0.0",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"unist-util-visit": "^2.0.0"
|
||||
|
||||
@@ -44,7 +44,16 @@ const sidebars = {
|
||||
}
|
||||
]
|
||||
},
|
||||
'storage',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Storage',
|
||||
items: [
|
||||
{
|
||||
type: 'autogenerated',
|
||||
dirName: 'storage'
|
||||
}
|
||||
]
|
||||
},
|
||||
'serverless-functions',
|
||||
{
|
||||
type: 'category',
|
||||
|
||||
2
docs/src/theme/prism-include-languages.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import type * as PrismNamespace from 'prismjs';
|
||||
export default function prismIncludeLanguages(PrismObject: typeof PrismNamespace): void;
|
||||
20
docs/src/theme/prism-include-languages.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/*global globalThis*/
|
||||
import siteConfig from '@generated/docusaurus.config'
|
||||
export default function prismIncludeLanguages(PrismObject) {
|
||||
const {
|
||||
themeConfig: { prism }
|
||||
} = siteConfig
|
||||
const { additionalLanguages } = prism
|
||||
// Prism components work on the Prism instance on the window, while prism-
|
||||
// react-renderer uses its own Prism instance. We temporarily mount the
|
||||
// instance onto window, import components to enhance it, then remove it to
|
||||
// avoid polluting global namespace.
|
||||
// You can mutate PrismObject: registering plugins, deleting languages... As
|
||||
// long as you don't re-assign it
|
||||
globalThis.Prism = PrismObject
|
||||
additionalLanguages.forEach((lang) => {
|
||||
// eslint-disable-next-line global-require, import/no-dynamic-require
|
||||
require(`prismjs/components/prism-${lang}`)
|
||||
})
|
||||
delete globalThis.Prism
|
||||
}
|
||||
BIN
docs/static/img/database/performance/pghero_01.png
vendored
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/static/img/database/performance/pghero_02.png
vendored
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
docs/static/img/database/performance/pghero_03.png
vendored
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/static/img/database/performance/pghero_04.png
vendored
Normal file
|
After Width: | Height: | Size: 389 KiB |
BIN
docs/static/img/storage/av_01.png
vendored
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/static/img/storage/av_02.png
vendored
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/static/img/storage/av_03.png
vendored
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
docs/static/img/storage/av_04.png
vendored
Normal file
|
After Width: | Height: | Size: 539 KiB |
BIN
docs/static/img/storage/av_05.png
vendored
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
docs/static/img/storage/av_06.png
vendored
Normal file
|
After Width: | Height: | Size: 266 KiB |
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_NHOST_SUBDOMAIN=local
|
||||
NEXT_PUBLIC_NHOST_REGION=
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
extends: ['../../config/.eslintrc.js', 'plugin:@next/next/recommended'],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off'
|
||||
}
|
||||
}
|
||||
35
examples/quickstarts/nextjs-server-components/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
11
examples/quickstarts/nextjs-server-components/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# @nhost-examples/nextjs-server-components
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4fe4a1696: new quickstart project that demonstrates how to use the Nhost SDK with Next.js 13 server components
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/nhost-js@2.2.17
|
||||
70
examples/quickstarts/nextjs-server-components/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Nhost with Next.js Server Components
|
||||
|
||||
This quickstart showcases how to correctly add authentication to a Next.js 13 project using the new App Router and Server Components. The other parts of the SDK (Storage / GraphQL/ Functions) should work the same as before.
|
||||
|
||||
## Authentication
|
||||
|
||||
1. **Saving the auth session**
|
||||
|
||||
To enable authentication with Server Components we have to store the auth session in a cookie. This should be done right after any **signIn** or **signUp** operation. See example [here](https://github.com/nhost/nhost/blob/main/examples/quickstarts/nextjs-server-components/src/app/server-actions/auth/sign-in-email-password.ts).
|
||||
|
||||
2. **Oauth & refresh session middleware**
|
||||
|
||||
Create a middleware at the root of your project that calls the helper method `manageAuthSession`. Feel free to copy paste the the contents of the `/utils` folder to your project. The second argument for `manageAuthSession` is for handling the case where there's an error refreshing the current session with the `refreshToken` stored in the cookie.
|
||||
|
||||
```typescript
|
||||
import { manageAuthSession } from '@utils/nhost'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
return manageAuthSession(request, () =>
|
||||
NextResponse.redirect(new URL('/auth/sign-in', request.url))
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
3. **Protected routes**
|
||||
|
||||
To make sure only authenticated users access some Server Components, wrap them in the Higher Order Server Component `withAuthAsync`.
|
||||
|
||||
```typescript
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
|
||||
const MyProtectedServerComponent = async () => {
|
||||
return <h2>Protected</h2>
|
||||
}
|
||||
|
||||
export default withAuthAsync(MyProtectedServerComponent)
|
||||
```
|
||||
|
||||
## Get Started
|
||||
|
||||
1. Clone the repository
|
||||
|
||||
```sh
|
||||
git clone https://github.com/nhost/nhost
|
||||
cd nhost
|
||||
```
|
||||
|
||||
2. Install and build dependencies
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. Terminal 1: Start the Nhost Backend
|
||||
|
||||
> Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli).
|
||||
|
||||
```sh
|
||||
cd examples/quickstarts/nhost-backend
|
||||
nhost up
|
||||
```
|
||||
|
||||
4. Terminal 2: Start the Next.js application
|
||||
|
||||
```sh
|
||||
cd examples/quickstarts/nextjs-server-components
|
||||
pnpm dev
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverActions: true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
35
examples/quickstarts/nextjs-server-components/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@nhost-examples/nextjs-server-components",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.8.2",
|
||||
"@nhost/nhost-js": "workspace:^",
|
||||
"autoprefixer": "10.4.15",
|
||||
"cookies-next": "^3.0.0",
|
||||
"eslint": "8.48.0",
|
||||
"eslint-config-next": "13.4.19",
|
||||
"form-data": "^4.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "13.4.19",
|
||||
"postcss": "8.4.29",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tailwind-merge": "^1.8.0",
|
||||
"tailwindcss": "3.3.3",
|
||||
"typescript": "5.2.2",
|
||||
"xstate": "^4.38.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-cookie": "^3.0.2",
|
||||
"@types/node": "20.5.6",
|
||||
"@types/react": "18.2.21",
|
||||
"@types/react-dom": "18.2.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { signIn } from '@server-actions/auth'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function SignInWithEmailAndPassword() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSignIn(formData: FormData) {
|
||||
const response = await signIn(formData)
|
||||
|
||||
if (response?.error) {
|
||||
setError(response.error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign in with email and password</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" action={handleSignIn}>
|
||||
<Input label="Email" id="email" name="email" type="email" required />
|
||||
|
||||
<Input label="Password" id="password" name="password" type="password" required />
|
||||
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign in
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
const nhost = new NhostClient({
|
||||
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
|
||||
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||
})
|
||||
|
||||
export default function SignInMagickLink() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
||||
const handleSignIn = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const { error } = await nhost.auth.signIn({ email })
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else {
|
||||
setIsSuccess(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign in with a magick link</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
{isSuccess && (
|
||||
<p className="mt-3 font-semibold text-center text-green-500">
|
||||
Click the link in the email to finish the sign in process
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignIn}>
|
||||
<Input
|
||||
label="Email"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import { signInWithGoogle } from '@server-actions/auth'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function SignIn() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="container flex justify-center">
|
||||
<div className="w-full max-w-lg space-y-5">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign In</h1>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-in/email-password')}
|
||||
>
|
||||
with email/password
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-in/webauthn')}
|
||||
>
|
||||
with a security key
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-in/magick-link')}
|
||||
>
|
||||
with a magick link
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-in/pat')}
|
||||
>
|
||||
with a Personal Access Token
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-white w-full bg-[#4285F4] hover:bg-[#4285F4]/90 focus:ring-4 focus:outline-none focus:ring-[#4285F4]/50 font-medium rounded-lg px-5 py-2.5 text-center inline-flex items-center justify-between dark:focus:ring-[#4285F4]/55 mr-2 mb-2"
|
||||
onClick={() => signInWithGoogle()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 -ml-1"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-prefix="fab"
|
||||
data-icon="google"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 488 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||
/>
|
||||
</svg>
|
||||
with Google <span />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { signInWithPAT } from '@server-actions/auth'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function SignInWithPAT() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSignIn(formData: FormData) {
|
||||
const response = await signInWithPAT(formData)
|
||||
|
||||
if (response?.error) {
|
||||
setError(response.error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign In with Personal Access Token</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" action={handleSignIn}>
|
||||
<Input label="PAT" id="pat" name="pat" required />
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
const NHOST_SESSION_KEY = 'nhostSession'
|
||||
|
||||
const nhost = new NhostClient({
|
||||
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
|
||||
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||
})
|
||||
|
||||
export default function SignInWithSecurityKey() {
|
||||
const router = useRouter()
|
||||
const [error, setError] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const handleSignIn = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const { session, error } = await nhost.auth.signIn({
|
||||
email,
|
||||
securityKey: true
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
}
|
||||
|
||||
if (session) {
|
||||
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
|
||||
router.push('/protected/todos')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign In</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignIn}>
|
||||
<Input
|
||||
label="Email"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { signUp } from '@server-actions/auth'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function SignUpWithEmailAndPassword() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSignUp(formData: FormData) {
|
||||
const response = await signUp(formData)
|
||||
|
||||
if (response?.error) {
|
||||
setError(response.error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold text-center">Sign Up</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="space-y-5" action={handleSignUp}>
|
||||
<Input label="First Name" id="firstName" name="firstName" required />
|
||||
<Input label="Last Name" id="lastName" name="lastName" required />
|
||||
<Input label="Email" id="email" name="email" type="email" required />
|
||||
<Input label="Password" id="password" name="password" type="password" required />
|
||||
<SubmitButton type="submit">Sign Up</SubmitButton>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { signInWithGoogle } from '@server-actions/auth'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function SignUp() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="container flex justify-center">
|
||||
<div className="w-full max-w-lg space-y-5">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign Up</h1>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-up/email-password')}
|
||||
>
|
||||
with email/password
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-up/webauthn')}
|
||||
>
|
||||
with a security key
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-white w-full bg-[#4285F4] hover:bg-[#4285F4]/90 focus:ring-4 focus:outline-none focus:ring-[#4285F4]/50 font-medium rounded-lg px-5 py-2.5 text-center inline-flex items-center justify-between dark:focus:ring-[#4285F4]/55 mr-2 mb-2"
|
||||
onClick={() => signInWithGoogle()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 -ml-1"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-prefix="fab"
|
||||
data-icon="google"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 488 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||
/>
|
||||
</svg>
|
||||
with Google <span />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
const NHOST_SESSION_KEY = 'nhostSession'
|
||||
|
||||
const nhost = new NhostClient({
|
||||
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
|
||||
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||
})
|
||||
|
||||
export default function SignUpWebAuthn() {
|
||||
const router = useRouter()
|
||||
|
||||
const [error, setError] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const handleSignUp = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const { session, error } = await nhost.auth.signUp({
|
||||
email,
|
||||
securityKey: true
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
}
|
||||
|
||||
console.log({
|
||||
handleSignUpSession: session
|
||||
})
|
||||
|
||||
if (session) {
|
||||
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
|
||||
router.push('/protected/todos')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign Up with a security key</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignUp}>
|
||||
<Input
|
||||
label="Email"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,24 @@
|
||||
import Navigation from '@components/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app'
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<div className="app">
|
||||
<Navigation />
|
||||
<div className="container p-4 mx-auto mt-8 antialiased">{children}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Home() {
|
||||
return <h1 className="text-2xl text-center">Hi, login/register to get started</h1>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import withAuth from '@utils/auth-guard'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
|
||||
type EchoResponse = {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
const Echo = async () => {
|
||||
const nhost = await getNhost()
|
||||
const { res } = await nhost.functions.call<EchoResponse>('echo')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre className="overflow-auto">{JSON.stringify(res?.data.headers, null, 2)}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(Echo)
|
||||
@@ -0,0 +1,103 @@
|
||||
import { gql } from '@apollo/client'
|
||||
import PatItem, { type PAT } from '@components/pat-item'
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
|
||||
const PAT = async ({
|
||||
params
|
||||
}: {
|
||||
params: {
|
||||
[key: string]: string | string[] | undefined
|
||||
}
|
||||
}) => {
|
||||
const page = parseInt(params.pagination?.at(0) || '0')
|
||||
const nhost = await getNhost()
|
||||
|
||||
const {
|
||||
data: {
|
||||
authRefreshTokens,
|
||||
authRefreshTokensAggregate: {
|
||||
aggregate: { count }
|
||||
}
|
||||
}
|
||||
} = await nhost.graphql.request(
|
||||
gql`
|
||||
query getPersonalAccessTokens($offset: Int, $limit: Int) {
|
||||
authRefreshTokens(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
order_by: { createdAt: desc }
|
||||
where: { type: { _eq: pat } }
|
||||
) {
|
||||
id
|
||||
metadata
|
||||
type
|
||||
expiresAt
|
||||
}
|
||||
|
||||
authRefreshTokensAggregate(where: { type: { _eq: pat } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
offset: page * 10,
|
||||
limit: 10
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Head>
|
||||
<title>Personal Access Tokens</title>
|
||||
</Head>
|
||||
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-xl">Personal Access Tokens ({count})</h2>
|
||||
|
||||
<Link
|
||||
href={`/protected/pat/new`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Add a PAT
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{authRefreshTokens.map((token: PAT) => (
|
||||
<li key={token.id}>
|
||||
<PatItem pat={token} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{count > 10 && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
{page > 0 && (
|
||||
<Link
|
||||
href={`/protected/pat/${page - 1}`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{page + 1 < Math.ceil(count / 10) && (
|
||||
<Link
|
||||
href={`/protected/pat/${page + 1}`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuthAsync(PAT)
|
||||
@@ -0,0 +1,13 @@
|
||||
import PATForm from '@components/pat-form'
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
|
||||
const NewPat = async () => {
|
||||
return (
|
||||
<div className="flex flex-col max-w-3xl mx-auto space-y-4">
|
||||
<h2 className="text-xl">New Personal Access Token</h2>
|
||||
<PATForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuthAsync(NewPat)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { gql } from '@apollo/client'
|
||||
import TodoItem, { type Todo } from '@components/todo-item'
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
|
||||
const Todos = async ({ params }: { params: { [key: string]: string | string[] | undefined } }) => {
|
||||
const page = parseInt(params.pagination?.at(0) || '0')
|
||||
|
||||
const nhost = await getNhost()
|
||||
|
||||
const {
|
||||
data: {
|
||||
todos,
|
||||
todos_aggregate: {
|
||||
aggregate: { count }
|
||||
}
|
||||
}
|
||||
} = await nhost.graphql.request(
|
||||
gql`
|
||||
query getTodos($limit: Int, $offset: Int) {
|
||||
todos(limit: $limit, offset: $offset, order_by: { createdAt: desc }) {
|
||||
id
|
||||
title
|
||||
done
|
||||
attachment {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
todos_aggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
offset: page * 10,
|
||||
limit: 10
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Head>
|
||||
<title>Protected Page</title>
|
||||
</Head>
|
||||
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-xl">Todos ({count})</h2>
|
||||
|
||||
<Link
|
||||
href={`/protected/todos/new`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Add Todo
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{todos.map((todo: Todo) => (
|
||||
<li key={todo.id}>
|
||||
<TodoItem todo={todo} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{count > 10 && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
{page > 0 && (
|
||||
<Link
|
||||
href={`/protected/todos/${page - 1}`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{page + 1 < Math.ceil(count / 10) && (
|
||||
<Link
|
||||
href={`/protected/todos/${page + 1}`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuthAsync(Todos)
|
||||
@@ -0,0 +1,13 @@
|
||||
import TodoForm from '@components/todo-form'
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
|
||||
const NewTodo = async () => {
|
||||
return (
|
||||
<div className="flex flex-col max-w-3xl mx-auto space-y-4">
|
||||
<h2 className="text-xl">New Todo</h2>
|
||||
<TodoForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuthAsync(NewTodo)
|
||||
@@ -0,0 +1,6 @@
|
||||
export { signInWithApple } from './sign-in-apple'
|
||||
export { signIn } from './sign-in-email-password'
|
||||
export { signInWithGoogle } from './sign-in-google'
|
||||
export { signInWithPAT } from './sign-in-pat'
|
||||
export { signOut } from './sign-out'
|
||||
export { signUp } from './sign-up-email-password'
|
||||
@@ -0,0 +1,19 @@
|
||||
'use server'
|
||||
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const signInWithApple = async () => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const { providerUrl } = await nhost.auth.signIn({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
redirectTo: `/oauth`
|
||||
}
|
||||
})
|
||||
|
||||
if (providerUrl) {
|
||||
redirect(providerUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use server'
|
||||
|
||||
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const signIn = async (formData: FormData) => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const email = formData.get('email') as string
|
||||
const password = formData.get('password') as string
|
||||
|
||||
const { session, error } = await nhost.auth.signIn({ email, password })
|
||||
|
||||
if (session) {
|
||||
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
|
||||
redirect('/protected/todos')
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
error: error?.message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use server'
|
||||
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const signInWithGoogle = async () => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const { providerUrl } = await nhost.auth.signIn({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `/oauth`
|
||||
}
|
||||
})
|
||||
|
||||
if (providerUrl) {
|
||||
redirect(providerUrl)
|
||||
}
|
||||
}
|
||||