Compare commits
30 Commits
@nhost/das
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0e848d353 | ||
|
|
cea3ef5c8a | ||
|
|
a05db74bb6 | ||
|
|
73f3d69776 | ||
|
|
a99f034bd4 | ||
|
|
3b37af06a0 | ||
|
|
86ecf27b23 | ||
|
|
1b5dc5e7f5 | ||
|
|
21708be3d2 | ||
|
|
f16e2305c3 | ||
|
|
5d6c349350 | ||
|
|
245a1b44c4 | ||
|
|
ca75f731af | ||
|
|
c48be24d13 | ||
|
|
60b5bf20d7 | ||
|
|
8f94bc6332 | ||
|
|
75c73c4884 | ||
|
|
4c6a6bb6c1 | ||
|
|
60b685ab02 | ||
|
|
2e65bc6dc0 | ||
|
|
14e6100722 | ||
|
|
479dba102e | ||
|
|
c9b84c7658 | ||
|
|
c78a765941 | ||
|
|
72899a600f | ||
|
|
fe6e8e2d15 | ||
|
|
737945bd0b | ||
|
|
8f77914eb3 | ||
|
|
839ca68f74 | ||
|
|
10b0f7490e |
26
.github/workflows/changesets.yaml
vendored
26
.github/workflows/changesets.yaml
vendored
@@ -65,29 +65,13 @@ jobs:
|
||||
|
||||
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 }}
|
||||
uses: ./.github/workflows/deploy-dashboard.yaml
|
||||
with:
|
||||
git_ref: ${{ github.ref_name }}
|
||||
environment: production
|
||||
secrets: inherit
|
||||
|
||||
publish-docker:
|
||||
name: Publish to Docker Hub
|
||||
|
||||
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
@@ -18,7 +18,6 @@ env:
|
||||
TURBO_TEAM: nhost
|
||||
NEXT_PUBLIC_ENV: dev
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
|
||||
NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }}
|
||||
NHOST_TEST_WORKSPACE_NAME: ${{ vars.NHOST_TEST_WORKSPACE_NAME }}
|
||||
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
|
||||
|
||||
1
.github/workflows/dashboard.yaml
vendored
1
.github/workflows/dashboard.yaml
vendored
@@ -8,7 +8,6 @@ env:
|
||||
TURBO_TEAM: nhost
|
||||
NEXT_PUBLIC_ENV: dev
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
58
.github/workflows/deploy-dashboard.yaml
vendored
Normal file
58
.github/workflows/deploy-dashboard.yaml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: 'dashboard: release form'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
git_ref:
|
||||
type: string
|
||||
description: 'Branch, tag, or commit SHA'
|
||||
required: true
|
||||
|
||||
environment:
|
||||
type: choice
|
||||
description: 'Deployment environment'
|
||||
required: true
|
||||
default: staging
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
|
||||
workflow_call:
|
||||
inputs:
|
||||
git_ref:
|
||||
required: true
|
||||
type: string
|
||||
environment:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-vercel:
|
||||
name: Publish to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.git_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
- name: Setup Vercel CLI
|
||||
run: pnpm add -g vercel
|
||||
|
||||
- name: Trigger Vercel deployment
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.environment == 'production' && secrets.DASHBOARD_VERCEL_PROJECT_ID || secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
|
||||
run: |
|
||||
echo "Deploying to: ${{ inputs.environment }}..."
|
||||
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 }}
|
||||
1
.github/workflows/gen_ai_review.yaml
vendored
1
.github/workflows/gen_ai_review.yaml
vendored
@@ -12,7 +12,6 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
name: Run pr agent on every pull request, respond to user comments
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<a href="https://twitter.com/nhost">Twitter</a>
|
||||
<span> • </span>
|
||||
<a href="https://nhost.io/discord">Discord</a>
|
||||
<span> • </span>
|
||||
<a href="https://gurubase.io/g/nhost">Ask Nhost Guru (third party, unofficial)</a>
|
||||
<br />
|
||||
|
||||
<hr />
|
||||
@@ -148,4 +150,4 @@ Here are some ways of contributing to making Nhost better:
|
||||
<p align="center">
|
||||
<img width="720" src="https://contrib.rocks/image?repo=nhost/nhost" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
</a>
|
||||
17
changelog_summary.sh
Executable file
17
changelog_summary.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#/usr/bin/env bash
|
||||
PREV_MONTH=$(date -d "1 month ago" +%Y-%m)
|
||||
|
||||
echo "prev: $PREV_MONTH"
|
||||
|
||||
files=$(git log --since="$PREV_MONTH-01" --until="$PREV_MONTH-31" --name-only -- '**/CHANGELOG.md' | grep CHANGE | sort -u)
|
||||
|
||||
echo "files: $files"
|
||||
|
||||
echo "Below you can find the latest release for each individual package released during this month:"
|
||||
echo
|
||||
|
||||
for file in $files; do
|
||||
name=$(grep '^# ' $file | awk '{ print substr($0, 4) }')
|
||||
last_release=$(grep '^## ' $file | awk '{ print substr($0, 4) }' | head -n 1)
|
||||
echo "@$name: $last_release [CHANGELOG.md](https://github.com/nhost/nhost/blob/main/$file)"
|
||||
done
|
||||
@@ -42,7 +42,6 @@ module.exports = {
|
||||
env: (config) => ({
|
||||
...config,
|
||||
NEXT_PUBLIC_ENV: 'dev',
|
||||
NEXT_PUBLIC_NHOST_BACKEND_URL: 'http://localhost:1337',
|
||||
NEXT_PUBLIC_NHOST_PLATFORM: 'false',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 2.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- cea3ef5: Feat: add org and project placeholders
|
||||
|
||||
## 2.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 86ecf27: feat: add support for additional metrics in overview
|
||||
- 21708be: feat: dashboard: add support for storage buckets to AI assistants
|
||||
|
||||
## 1.30.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -100,7 +100,6 @@ pnpm storybook --port 6007
|
||||
|
||||
| Name | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.7.0",
|
||||
"version": "2.11.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
137
dashboard/src/components/common/SelectOrg/SelectOrg.tsx
Normal file
137
dashboard/src/components/common/SelectOrg/SelectOrg.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export default function SelectOrganizationAndProject() {
|
||||
const { orgs, loading } = useOrgs();
|
||||
const router = useRouter();
|
||||
|
||||
const organizations = orgs.map((org) => ({
|
||||
name: org.name,
|
||||
value: `/orgs/${org.slug}`,
|
||||
}));
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const handleFilterChange = useMemo(
|
||||
() =>
|
||||
debounce((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(event.target.value);
|
||||
}, 200),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||
|
||||
const goToOrgPage = async (org: {
|
||||
name: string;
|
||||
value: string;
|
||||
}) => {
|
||||
const { slug } = router.query;
|
||||
await router.push({
|
||||
pathname: `${org.value}/${
|
||||
(() => {
|
||||
if (!slug) {
|
||||
return '';
|
||||
}
|
||||
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||
})()
|
||||
}`,
|
||||
});
|
||||
};
|
||||
|
||||
const orgsToDisplay = filter
|
||||
? organizations.filter((org) =>
|
||||
org.name.toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: organizations;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading organizations..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
Select an Organization
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex w-full">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
onChange={handleFilterChange}
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<RetryableErrorBoundary>
|
||||
{orgsToDisplay.length === 0 ? (
|
||||
<Box className="h-import py-2">
|
||||
<Text variant="subtitle2">No results found.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<List className="h-import overflow-y-auto">
|
||||
{orgsToDisplay.map((org, index) => (
|
||||
<Fragment key={org.value}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="primary"
|
||||
onClick={() => goToOrgPage(org)}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListItem.Avatar>
|
||||
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</span>
|
||||
</ListItem.Avatar>
|
||||
<ListItem.Text
|
||||
primary={org.name}
|
||||
secondary={`${org.name} / ${org.name}`}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
{index < orgs.length - 1 && <Divider component="li" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</RetryableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/components/common/SelectOrg/index.ts
Normal file
1
dashboard/src/components/common/SelectOrg/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SelectOrg } from './SelectOrg';
|
||||
@@ -0,0 +1,141 @@
|
||||
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export default function SelectOrganizationAndProject() {
|
||||
const { orgs, loading } = useOrgs();
|
||||
const router = useRouter();
|
||||
|
||||
const projects = orgs.flatMap((org) =>
|
||||
org.apps.map((app) => ({
|
||||
organizationName: org.name,
|
||||
projectName: app.name,
|
||||
value: `/orgs/${org.slug}/projects/${app.subdomain}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const handleFilterChange = useMemo(
|
||||
() =>
|
||||
debounce((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(event.target.value);
|
||||
}, 200),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||
|
||||
const goToProjectPage = async (project: {
|
||||
organizationName: string;
|
||||
projectName: string;
|
||||
value: string;
|
||||
}) => {
|
||||
const { slug } = router.query;
|
||||
await router.push({
|
||||
pathname: `${project.value}/${
|
||||
(() => {
|
||||
if (!slug) {
|
||||
return '';
|
||||
}
|
||||
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||
})()
|
||||
}`,
|
||||
});
|
||||
};
|
||||
|
||||
const projectsToDisplay = filter
|
||||
? projects.filter((project) =>
|
||||
project.projectName.toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: projects;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading organizations and projects..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
Select a Project
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex w-full">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
onChange={handleFilterChange}
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<RetryableErrorBoundary>
|
||||
{projectsToDisplay.length === 0 ? (
|
||||
<Box className="h-import py-2">
|
||||
<Text variant="subtitle2">No results found.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<List className="h-import overflow-y-auto">
|
||||
{projectsToDisplay.map((project, index) => (
|
||||
<Fragment key={project.value}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="primary"
|
||||
onClick={() => goToProjectPage(project)}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListItem.Avatar>
|
||||
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</span>
|
||||
</ListItem.Avatar>
|
||||
<ListItem.Text
|
||||
primary={project.projectName}
|
||||
secondary={`${project.organizationName} / ${project.projectName}`}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
{index < projects.length - 1 && <Divider component="li" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</RetryableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export { default as SelectOrgAndProject } from './SelectOrgAndProject';
|
||||
@@ -20,8 +20,7 @@ interface AINavLinkProps extends ListItemButtonProps {
|
||||
*/
|
||||
href: string;
|
||||
/**
|
||||
* Determines whether or not the link should be active if it's href exactly
|
||||
* matches the current route.
|
||||
* Determines whether or not the link should be active if href matches the current route.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
@@ -87,7 +86,7 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
<>
|
||||
<Backdrop
|
||||
open={expanded}
|
||||
className="absolute top-0 left-0 bottom-0 right-0 z-[34] md:hidden"
|
||||
className="absolute bottom-0 left-0 right-0 top-0 z-[34] md:hidden"
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setExpanded(false)}
|
||||
@@ -104,7 +103,7 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
<Box
|
||||
component="aside"
|
||||
className={twMerge(
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pt-2 pb-17 motion-safe:transition-transform md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pb-17 pt-2 motion-safe:transition-transform md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
@@ -119,6 +118,7 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
>
|
||||
Auto-Embeddings
|
||||
</AINavLink>
|
||||
|
||||
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
||||
Assistants
|
||||
</AINavLink>
|
||||
|
||||
@@ -8,20 +8,15 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/v3/command';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/v3/hover-card';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { ProjectStatusIndicator } from '@/features/orgs/components/common/ProjectStatusIndicator';
|
||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { Box, Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -31,56 +26,6 @@ type Option = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
function ProjectStatusIndicator({ status }: { status: ApplicationStatus }) {
|
||||
const indicatorStyles: Record<
|
||||
number,
|
||||
{ className: string; description: string }
|
||||
> = {
|
||||
[ApplicationStatus.Errored]: {
|
||||
className: 'bg-destructive',
|
||||
description: 'Project errored',
|
||||
},
|
||||
[ApplicationStatus.Pausing]: {
|
||||
className: 'bg-primary-main animate-blinking',
|
||||
description: 'Project is pausing',
|
||||
},
|
||||
[ApplicationStatus.Restoring]: {
|
||||
className: 'bg-primary-main animate-blinking',
|
||||
description: 'Project is restoring',
|
||||
},
|
||||
[ApplicationStatus.Paused]: {
|
||||
className: 'bg-slate-400',
|
||||
description: 'Project is paused',
|
||||
},
|
||||
[ApplicationStatus.Unpausing]: {
|
||||
className: 'bg-primary-main animate-blinking',
|
||||
description: 'Project is unpausing',
|
||||
},
|
||||
[ApplicationStatus.Live]: {
|
||||
className: 'bg-primary-main',
|
||||
description: 'Project is live',
|
||||
},
|
||||
};
|
||||
const style = indicatorStyles[status];
|
||||
|
||||
if (style) {
|
||||
return (
|
||||
<HoverCard openDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span
|
||||
className={cn('mt-[1px] h-2 w-2 rounded-full', style.className)}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" className="h-fit w-fit py-2">
|
||||
{style.description}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ProjectsComboBox() {
|
||||
const {
|
||||
query: { appSubdomain },
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function FileStoresIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
aria-label="FileStores Icon"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 22v-9" />
|
||||
<path d="M15.17 2.21a1.67 1.67 0 0 1 1.63 0L21 4.57a1.93 1.93 0 0 1 0 3.36L8.82 14.79a1.655 1.655 0 0 1-1.64 0L3 12.43a1.93 1.93 0 0 1 0-3.36z" />
|
||||
<path d="M20 13v3.87a2.06 2.06 0 0 1-1.11 1.83l-6 3.08a1.93 1.93 0 0 1-1.78 0l-6-3.08A2.06 2.06 0 0 1 4 16.87V13" />
|
||||
<path d="M21 12.43a1.93 1.93 0 0 0 0-3.36L8.83 2.2a1.64 1.64 0 0 0-1.63 0L3 4.57a1.93 1.93 0 0 0 0 3.36l12.18 6.86a1.636 1.636 0 0 0 1.63 0z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
FileStoresIcon.displayName = 'FileStoresIcon';
|
||||
|
||||
export default FileStoresIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as FileStoresIcon } from './FileStoresIcon';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
@@ -10,14 +11,14 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { GraphqlDataSourcesFormSection } from '@/features/ai/AssistantForm/components/GraphqlDataSourcesFormSection';
|
||||
import { WebhooksDataSourcesFormSection } from '@/features/ai/AssistantForm/components/WebhooksDataSourcesFormSection';
|
||||
import { useAdminApolloClient } from '@/features/projects/common/hooks/useAdminApolloClient';
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient'
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertAssistantMutation,
|
||||
useUpdateAssistantMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -28,6 +29,7 @@ export const validationSchema = Yup.object({
|
||||
description: Yup.string(),
|
||||
instructions: Yup.string().required('The instructions are required'),
|
||||
model: Yup.string().required('The model is required'),
|
||||
fileStore: Yup.string().label('File Store'),
|
||||
graphql: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
@@ -64,14 +66,14 @@ export type AssistantFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export interface AssistantFormProps extends DialogFormProps {
|
||||
/**
|
||||
* To use in conjunction with initialData to allow for updating the autoEmbeddingsConfiguration
|
||||
* To use in conjunction with initialData to allow for updating the Assistant Configuration
|
||||
*/
|
||||
assistantId?: string;
|
||||
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: AssistantFormValues;
|
||||
initialData?: AssistantFormValues
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
@@ -114,26 +116,26 @@ export default function AssistantForm({
|
||||
} = form;
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateAutoEmbeddings = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
const createOrUpdateAssistant = async (
|
||||
values: DeepRequired<AssistantFormValues> & {
|
||||
assistantID: string;
|
||||
},
|
||||
) => {
|
||||
// remove any __typename from the form values
|
||||
const payload = removeTypename(values);
|
||||
|
||||
if (values.webhooks.length === 0) {
|
||||
if (values.webhooks?.length === 0) {
|
||||
delete payload.webhooks;
|
||||
}
|
||||
|
||||
if (values.graphql.length === 0) {
|
||||
if (values.graphql?.length === 0) {
|
||||
delete payload.graphql;
|
||||
}
|
||||
|
||||
// remove assistantId because the update mutation fails otherwise
|
||||
delete payload.assistantID;
|
||||
|
||||
// If the assistantId is set then we do an update
|
||||
@@ -158,11 +160,13 @@ export default function AssistantForm({
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
values: DeepRequired<AssistantFormValues> & {
|
||||
assistantID: string;
|
||||
},
|
||||
) => {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await createOrUpdateAutoEmbeddings(values);
|
||||
await createOrUpdateAssistant(values);
|
||||
onSubmit?.();
|
||||
},
|
||||
{
|
||||
@@ -282,6 +286,7 @@ export default function AssistantForm({
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<GraphqlDataSourcesFormSection />
|
||||
<WebhooksDataSourcesFormSection />
|
||||
</div>
|
||||
|
||||
@@ -15,12 +15,12 @@ import { type Assistant } from 'pages/[workspaceSlug]/[appSlug]/ai/assistants';
|
||||
|
||||
interface AssistantsListProps {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
* The list of assistants.
|
||||
*/
|
||||
assistants: Assistant[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating a service.
|
||||
* Function to be called after a submitting the form for either creating or updating an assistant.
|
||||
*
|
||||
* @example onDelete={() => refetch()}
|
||||
*/
|
||||
|
||||
@@ -14,9 +14,6 @@ export default function Estimate() {
|
||||
|
||||
const amountDue = useMemo(() => {
|
||||
const amount = data?.billingGetNextInvoice?.AmountDue;
|
||||
if (!amount) {
|
||||
return 'N/A';
|
||||
}
|
||||
if (typeof amount !== 'number') {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/v3/hover-card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
|
||||
export default function ProjectStatusIndicator({
|
||||
status,
|
||||
}: {
|
||||
status: ApplicationStatus;
|
||||
}) {
|
||||
const indicatorStyles: Record<
|
||||
number,
|
||||
{ className: string; description: string }
|
||||
> = {
|
||||
[ApplicationStatus.Errored]: {
|
||||
className: 'bg-destructive',
|
||||
description: 'Project errored',
|
||||
},
|
||||
[ApplicationStatus.Pausing]: {
|
||||
className: 'bg-primary-main animate-blinking',
|
||||
description: 'Project is pausing',
|
||||
},
|
||||
[ApplicationStatus.Restoring]: {
|
||||
className: 'bg-primary-main animate-blinking',
|
||||
description: 'Project is restoring',
|
||||
},
|
||||
[ApplicationStatus.Paused]: {
|
||||
className: 'bg-slate-400',
|
||||
description: 'Project is paused',
|
||||
},
|
||||
[ApplicationStatus.Unpausing]: {
|
||||
className: 'bg-primary-main animate-blinking',
|
||||
description: 'Project is unpausing',
|
||||
},
|
||||
[ApplicationStatus.Live]: {
|
||||
className: 'bg-primary-main',
|
||||
description: 'Project is live',
|
||||
},
|
||||
};
|
||||
const style = indicatorStyles[status];
|
||||
|
||||
if (style) {
|
||||
return (
|
||||
<HoverCard openDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-[2px] h-2 w-2 flex-shrink-0 rounded-full',
|
||||
style.className,
|
||||
)}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" className="h-fit w-fit py-2">
|
||||
{style.description}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProjectStatusIndicator } from './ProjectStatusIndicator';
|
||||
@@ -1,8 +1,9 @@
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { ProjectStatusIndicator } from '@/features/orgs/components/common/ProjectStatusIndicator';
|
||||
import { DeploymentStatusMessage } from '@/features/orgs/projects/deployments/components/DeploymentStatusMessage';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { DeploymentStatusMessage } from '@/features/projects/deployments/components/DeploymentStatusMessage';
|
||||
import {
|
||||
useGetProjectsQuery,
|
||||
type GetProjectsQuery,
|
||||
@@ -22,20 +23,21 @@ function ProjectCard({ project }: { project: Project }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/orgs/${org?.slug}/projects/${project.subdomain}`}
|
||||
className="flex cursor-pointer flex-col gap-4 rounded-lg border bg-background p-4 hover:shadow-sm"
|
||||
className="flex h-44 cursor-pointer flex-col gap-4 rounded-lg border bg-background p-4 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex w-full flex-row items-center space-x-2">
|
||||
<Box className="h-6 w-6 flex-shrink-0" />
|
||||
<p className="truncate text-lg font-bold">{project.name}</p>
|
||||
<div className="flex flex-row items-start gap-2">
|
||||
<Box className="mt-[2px] h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex w-full flex-col">
|
||||
<p className="truncate font-bold">{project.name}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.region.name}
|
||||
</span>
|
||||
</div>
|
||||
<ProjectStatusIndicator status={project.appStates[0].stateId} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start gap-2">
|
||||
<DeploymentStatusMessage
|
||||
appCreatedAt={project.createdAt}
|
||||
deployment={latestDeployment}
|
||||
/>
|
||||
<div className="flex flex-1 flex-row items-start gap-2">
|
||||
<DeploymentStatusMessage deployment={latestDeployment} />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
@@ -53,6 +55,7 @@ export default function ProjectsGrid() {
|
||||
orgSlug: org?.slug,
|
||||
},
|
||||
skip: !org,
|
||||
pollInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -100,7 +103,7 @@ export default function ProjectsGrid() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{filteredProjects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
|
||||
@@ -128,6 +128,9 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
||||
Assistants
|
||||
</AINavLink>
|
||||
<AINavLink href="/file-stores" exact={false} onClick={handleSelect}>
|
||||
File Stores
|
||||
</AINavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
@@ -14,21 +14,27 @@ import { WebhooksDataSourcesFormSection } from '@/features/orgs/projects/ai/Assi
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertAssistantMutation,
|
||||
useUpdateAssistantMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { useIsFileStoreSupported } from '@/features/orgs/projects/common/hooks/useIsFileStoreSupported';
|
||||
import { type GraphiteFileStore } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/file-stores';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
name: Yup.string().required('The name is required.'),
|
||||
description: Yup.string(),
|
||||
instructions: Yup.string().required('The instructions are required'),
|
||||
model: Yup.string().required('The model is required'),
|
||||
fileStore: Yup.string().label('File Store'),
|
||||
graphql: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
@@ -65,14 +71,17 @@ export type AssistantFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export interface AssistantFormProps extends DialogFormProps {
|
||||
/**
|
||||
* To use in conjunction with initialData to allow for updating the autoEmbeddingsConfiguration
|
||||
* To use in conjunction with initialData to allow for updating the Assistant Configuration
|
||||
*/
|
||||
assistantId?: string;
|
||||
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: AssistantFormValues;
|
||||
initialData?: AssistantFormValues & {
|
||||
fileStores?: string[];
|
||||
};
|
||||
fileStores?: GraphiteFileStore[];
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
@@ -87,6 +96,7 @@ export interface AssistantFormProps extends DialogFormProps {
|
||||
export default function AssistantForm({
|
||||
assistantId,
|
||||
initialData,
|
||||
fileStores,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
location,
|
||||
@@ -103,8 +113,27 @@ export default function AssistantForm({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const isFileStoreSupported = useIsFileStoreSupported();
|
||||
|
||||
const fileStoresOptions = fileStores
|
||||
? fileStores.map((fileStore: GraphiteFileStore) => ({
|
||||
label: fileStore.name,
|
||||
value: fileStore.name,
|
||||
id: fileStore.id,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const assistantFileStore = initialData?.fileStores
|
||||
? fileStores?.find((fileStore: GraphiteFileStore) =>
|
||||
fileStore.id === initialData?.fileStores[0]
|
||||
)
|
||||
: null;
|
||||
|
||||
const formDefaultValues = { ...initialData, fileStores: [] };
|
||||
formDefaultValues.fileStore = assistantFileStore ? assistantFileStore.id : '';
|
||||
|
||||
const form = useForm<AssistantFormValues>({
|
||||
defaultValues: initialData,
|
||||
defaultValues: formDefaultValues,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
@@ -120,22 +149,32 @@ export default function AssistantForm({
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateAutoEmbeddings = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
const createOrUpdateAssistant = async (
|
||||
values: DeepRequired<AssistantFormValues> & {
|
||||
assistantID: string;
|
||||
},
|
||||
) => {
|
||||
// remove any __typename from the form values
|
||||
const payload = removeTypename(values);
|
||||
|
||||
if (values.webhooks.length === 0) {
|
||||
if (values.webhooks?.length === 0) {
|
||||
delete payload.webhooks;
|
||||
}
|
||||
|
||||
if (values.graphql.length === 0) {
|
||||
if (values.graphql?.length === 0) {
|
||||
delete payload.graphql;
|
||||
}
|
||||
|
||||
if (isFileStoreSupported && values.fileStore) {
|
||||
payload.fileStores = [values.fileStore];
|
||||
}
|
||||
if (!isFileStoreSupported) {
|
||||
delete payload.fileStores;
|
||||
}
|
||||
|
||||
// remove assistantId because the update mutation fails otherwise
|
||||
delete payload.assistantID;
|
||||
delete payload.fileStore;
|
||||
|
||||
// If the assistantId is set then we do an update
|
||||
if (assistantId) {
|
||||
@@ -152,7 +191,7 @@ export default function AssistantForm({
|
||||
await insertAssistantMutation({
|
||||
variables: {
|
||||
data: {
|
||||
...values,
|
||||
...payload,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -163,7 +202,7 @@ export default function AssistantForm({
|
||||
) => {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await createOrUpdateAutoEmbeddings(values);
|
||||
await createOrUpdateAssistant(values);
|
||||
onSubmit?.();
|
||||
},
|
||||
{
|
||||
@@ -175,6 +214,10 @@ export default function AssistantForm({
|
||||
);
|
||||
};
|
||||
|
||||
const fileStoreTooltip = isFileStoreSupported
|
||||
? "If specified, all text documents in this file store will be available to the assistant."
|
||||
: "Please upgrade Graphite to its latest version in order to use file stores.";
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
@@ -285,6 +328,36 @@ export default function AssistantForm({
|
||||
/>
|
||||
<GraphqlDataSourcesFormSection />
|
||||
<WebhooksDataSourcesFormSection />
|
||||
<ControlledSelect
|
||||
slotProps={{
|
||||
popper: { disablePortal: false, className: 'z-[10000]' },
|
||||
}}
|
||||
id="fileStore"
|
||||
name="fileStore"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>File Store</Text>
|
||||
<Tooltip title={fileStoreTooltip}>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
fullWidth
|
||||
error={!!errors?.model?.message}
|
||||
helperText={errors?.model?.message}
|
||||
disabled={!isFileStoreSupported}
|
||||
>
|
||||
<Option value="" />
|
||||
{fileStoresOptions.map((fileStore) => (
|
||||
<Option key={fileStore.id} value={fileStore.id}>
|
||||
{fileStore.label}
|
||||
</Option>
|
||||
))}
|
||||
</ControlledSelect>
|
||||
</div>
|
||||
|
||||
<Box className="flex flex-row justify-between w-full p-4 border-t rounded">
|
||||
|
||||
@@ -11,16 +11,22 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { AssistantForm } from '@/features/orgs/projects/ai/AssistantForm';
|
||||
import { DeleteAssistantModal } from '@/features/orgs/projects/ai/DeleteAssistantModal';
|
||||
import { type Assistant } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/assistants';
|
||||
import { type GraphiteFileStore } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/file-stores';
|
||||
import { copy } from '@/utils/copy';
|
||||
|
||||
interface AssistantsListProps {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
* The list of assistants
|
||||
*/
|
||||
assistants: Assistant[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating a service.
|
||||
* The list of file stores
|
||||
*/
|
||||
fileStores: GraphiteFileStore[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating an assistant.
|
||||
*
|
||||
* @example onDelete={() => refetch()}
|
||||
*/
|
||||
@@ -35,6 +41,7 @@ interface AssistantsListProps {
|
||||
|
||||
export default function AssistantsList({
|
||||
assistants,
|
||||
fileStores,
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: AssistantsListProps) {
|
||||
@@ -49,6 +56,7 @@ export default function AssistantsList({
|
||||
initialData={{
|
||||
...assistant,
|
||||
}}
|
||||
fileStores={fileStores}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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 { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { type GraphiteFileStore } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/file-stores';
|
||||
import { useDeleteFileStoreMutation } from '@/utils/__generated__/graphite.graphql';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DeleteFileStoreModalProps {
|
||||
fileStore: GraphiteFileStore;
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteFileStoreModal({
|
||||
fileStore,
|
||||
onDelete,
|
||||
close,
|
||||
}: DeleteFileStoreModalProps) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
|
||||
const [deleteFileStoreMutation] = useDeleteFileStoreMutation({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const deleteFileStore = async () => {
|
||||
await deleteFileStoreMutation({
|
||||
variables: {
|
||||
id: fileStore.id,
|
||||
},
|
||||
});
|
||||
await onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
|
||||
await execPromiseWithErrorToast(deleteFileStore, {
|
||||
loadingMessage: 'Deleting the file store...',
|
||||
successMessage: 'The file store has been deleted successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while deleting the file store. Please try again.',
|
||||
});
|
||||
}
|
||||
|
||||
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 File Store {fileStore?.name}{' '}
|
||||
</Text>{' '}
|
||||
<Text variant="subtitle2">
|
||||
{' '}
|
||||
Are you sure you want to delete this File Store?{' '}
|
||||
</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 ${fileStore?.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete File Store"
|
||||
/>
|
||||
</Box>
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove}
|
||||
loading={loading}
|
||||
>
|
||||
Delete File Store
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DeleteFileStoreModal } from './DeleteFileStoreModal';
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowsClockwise } from '@/components/ui/v2/icons/ArrowsClockwise';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient'
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import {
|
||||
useInsertFileStoreMutation,
|
||||
useUpdateFileStoreMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { useGetBucketsQuery } from '@/utils/__generated__/graphql';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
name: Yup.string().required('The name is required'),
|
||||
buckets: Yup.array()
|
||||
.of(
|
||||
Yup.object({
|
||||
label: Yup.string(),
|
||||
value: Yup.string(),
|
||||
}),
|
||||
)
|
||||
.label('Buckets')
|
||||
.required('At least one bucket is required'),
|
||||
});
|
||||
|
||||
export type FileStoreFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export interface FileStoreFormProps extends DialogFormProps {
|
||||
id?: string;
|
||||
initialData?: Omit<FileStoreFormValues, 'buckets'> & { buckets: string[] };
|
||||
onSubmit?: VoidFunction | ((args?: any) => Promise<any>);
|
||||
onCancel?: VoidFunction;
|
||||
}
|
||||
|
||||
export default function FileStoreForm({
|
||||
id,
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
location,
|
||||
}: FileStoreFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
|
||||
const [insertFileStore] = useInsertFileStoreMutation({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const [updateFileStore] = useUpdateFileStoreMutation({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||
const { data: buckets } = useGetBucketsQuery({
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
|
||||
const bucketOptions = buckets
|
||||
? buckets.buckets.map((bucket) => ({
|
||||
label: bucket.id,
|
||||
value: bucket.id,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const formDefaultValues = { ...initialData, buckets: [] };
|
||||
formDefaultValues.buckets = initialData?.buckets
|
||||
? initialData.buckets.map((bucket) => ({
|
||||
label: bucket,
|
||||
value: bucket,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const form = useForm<FileStoreFormValues>({
|
||||
defaultValues: formDefaultValues,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
} = form;
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateFileStore = async (
|
||||
values: DeepRequired<FileStoreFormValues> & { id: string },
|
||||
) => {
|
||||
const payload = removeTypename(values);
|
||||
delete payload.id;
|
||||
delete payload.vectorStoreID;
|
||||
|
||||
if (id) {
|
||||
await updateFileStore({
|
||||
variables: {
|
||||
id,
|
||||
object: { ...payload, buckets: values.buckets.map((b) => b.value) },
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await insertFileStore({
|
||||
variables: {
|
||||
object: { ...values, buckets: values.buckets.map((b) => b.value) },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
values: DeepRequired<FileStoreFormValues> & { id: string },
|
||||
) => {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await createOrUpdateFileStore(values);
|
||||
onSubmit?.();
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Creating File Store...',
|
||||
successMessage: 'The File Store has been created successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while creating the File Store. Please try again.',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col overflow-hidden border-t"
|
||||
>
|
||||
<div className="flex flex-1 flex-col space-y-4 overflow-auto p-4">
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Name</Text>
|
||||
<Tooltip title="Name of the file store">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder=""
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<ControlledAutocomplete
|
||||
id="buckets"
|
||||
name="buckets"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Buckets</Text>
|
||||
<Tooltip title="One or more buckets from storage from which documents can be used by Assistants">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
fullWidth
|
||||
multiple
|
||||
aria-label="Buckets"
|
||||
error={!!errors.buckets}
|
||||
options={bucketOptions}
|
||||
helperText={errors?.buckets?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Box className="flex w-full flex-row justify-between rounded border-t p-4">
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
startIcon={id ? <ArrowsClockwise /> : <PlusIcon />}
|
||||
>
|
||||
{id ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as FileStoreForm } from './FileStoreForm';
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
|
||||
import { FileStoresIcon } from '@/components/ui/v2/icons/FileStoresIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { DeleteFileStoreModal } from '@/features/orgs/projects/ai/DeleteFileStoreModal';
|
||||
import { FileStoreForm } from '@/features/orgs/projects/ai/FileStoreForm';
|
||||
import { type GraphiteFileStore } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/file-stores';
|
||||
import { copy } from '@/utils/copy';
|
||||
|
||||
interface FileStoresListProps {
|
||||
/**
|
||||
* List of File Stores to be displayed.
|
||||
*/
|
||||
fileStores: GraphiteFileStore[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating a File Store.
|
||||
*
|
||||
* @example onDelete={() => refetch()}
|
||||
*/
|
||||
onCreateOrUpdate?: () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Function to be called after a successful delete action.
|
||||
*
|
||||
*/
|
||||
onDelete?: () => Promise<any>;
|
||||
}
|
||||
|
||||
export default function FileStoresList({
|
||||
fileStores,
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: FileStoresListProps) {
|
||||
const { openDrawer, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const viewFileStore = async (fileStore: GraphiteFileStore) => {
|
||||
openDrawer({
|
||||
title: fileStore.name,
|
||||
component: (
|
||||
<FileStoreForm
|
||||
id={fileStore.id}
|
||||
initialData={{ ...fileStore }}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFileStore = async (fileStore: GraphiteFileStore) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<DeleteFileStoreModal
|
||||
fileStore={fileStore}
|
||||
close={closeDialog}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col">
|
||||
{fileStores.map((fileStore) => (
|
||||
<Box
|
||||
key={fileStore.id}
|
||||
className="flex h-[64px] w-full cursor-pointer items-center justify-between space-x-4 border-b-1 px-4 py-2 transition-colors"
|
||||
sx={{
|
||||
[`&:hover`]: {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => viewFileStore(fileStore)}
|
||||
className="flex w-full flex-row justify-between"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||
<FileStoresIcon className="h-5 w-5" />
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{fileStore?.name ?? 'unset'}
|
||||
</Text>
|
||||
<div className="hidden flex-row items-center space-x-2 md:flex">
|
||||
<Text variant="subtitle1" className="font-mono text-xs">
|
||||
{fileStore.id}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
copy(fileStore.id, 'File Store Id');
|
||||
event.stopPropagation();
|
||||
}}
|
||||
aria-label="Service Id"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
aria-label="More options"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-auto' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => viewFileStore(fileStore)}
|
||||
className="z-50 grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<Text className="font-medium">View {fileStore?.name}</Text>
|
||||
</Dropdown.Item>
|
||||
<Divider component="li" />
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() => deleteFileStore(fileStore)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
Delete {fileStore?.name}
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as FileStoresList } from './FileStoresList';
|
||||
@@ -3,10 +3,10 @@ import { Form } from '@/components/form/Form';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetSignInMethodsQuery,
|
||||
@@ -38,10 +38,10 @@ export default function EditUserPasswordForm({
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
const { closeDialog } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { project } = useProject();
|
||||
const { data } = useGetSignInMethodsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
skip: !currentProject?.id,
|
||||
variables: { appId: project?.id },
|
||||
skip: !project?.id,
|
||||
});
|
||||
|
||||
const passwordMinLength =
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useIsFileStoreSupported } from './useIsFileStoreSupported';
|
||||
@@ -0,0 +1,44 @@
|
||||
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useGetConfiguredVersionsQuery } from '@/utils/__generated__/graphql';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function compareSemver(v1: string, v2: string): number {
|
||||
const parse = (v: string) => v.split('.').map(Number);
|
||||
const [a, b] = [parse(v1), parse(v2)];
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (a[i] > b[i]) { return 1; }
|
||||
if (a[i] < b[i]) { return -1; }
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const MIN_VERSION_WITH_FILE_STORE_SUPPORT = '0.6.2';
|
||||
|
||||
export default function useIsFileStoreSupported() {
|
||||
const [isFileStoreSupported, setIsFileStoreSupported] = useState<boolean | null>(null);
|
||||
const { project } = useProject();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { data, loading, error } = useGetConfiguredVersionsQuery({
|
||||
variables: { appId: project?.id },
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data?.config?.ai?.version) {
|
||||
setIsFileStoreSupported(compareSemver(data.config.ai.version, MIN_VERSION_WITH_FILE_STORE_SUPPORT) >= 0);
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
return {
|
||||
isFileStoreSupported,
|
||||
version: data?.config?.ai?.version,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
@@ -39,10 +39,12 @@ export default function useUpdateColumnMutation({
|
||||
const {
|
||||
query: { dataSourceSlug, schemaSlug, tableSlug },
|
||||
} = useRouter();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const appUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
project?.subdomain,
|
||||
project?.region,
|
||||
'hasura',
|
||||
);
|
||||
const mutationFn = isPlatform ? updateColumn : updateColumnMigration;
|
||||
@@ -55,7 +57,7 @@ export default function useUpdateColumnMutation({
|
||||
adminSecret:
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: customAdminSecret || currentProject?.config?.hasura.adminSecret,
|
||||
: customAdminSecret || project?.config?.hasura.adminSecret,
|
||||
dataSource: customDataSource || (dataSourceSlug as string),
|
||||
schema: customSchema || (schemaSlug as string),
|
||||
table: customTable || (tableSlug as string),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import type { MutationOptions } from '@tanstack/react-query';
|
||||
@@ -40,10 +40,12 @@ export default function useUpdateRecordMutation<TData extends object = {}>({
|
||||
const {
|
||||
query: { dataSourceSlug, schemaSlug, tableSlug },
|
||||
} = useRouter();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const appUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
project?.subdomain,
|
||||
project?.region,
|
||||
'hasura',
|
||||
);
|
||||
|
||||
@@ -55,7 +57,7 @@ export default function useUpdateRecordMutation<TData extends object = {}>({
|
||||
adminSecret:
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: customAdminSecret || currentProject?.config?.hasura.adminSecret,
|
||||
: customAdminSecret || project?.config?.hasura.adminSecret,
|
||||
dataSource: customDataSource || (dataSourceSlug as string),
|
||||
schema: customSchema || (schemaSlug as string),
|
||||
table: customTable || (tableSlug as string),
|
||||
|
||||
@@ -23,30 +23,17 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
test('should render the avatar of the user who deployed the application', () => {
|
||||
render(
|
||||
<DeploymentStatusMessage
|
||||
deployment={defaultDeployment}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
render(<DeploymentStatusMessage deployment={defaultDeployment} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('img', {
|
||||
name: `Avatar of ${defaultDeployment.commitUserName}`,
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
'style',
|
||||
`background-image: url(${defaultDeployment.commitUserAvatarUrl});`,
|
||||
);
|
||||
).toHaveAttribute('src', `${defaultDeployment.commitUserAvatarUrl}`);
|
||||
});
|
||||
|
||||
test('should render "updated just now" when the deployment is in progress and has not ended', () => {
|
||||
render(
|
||||
<DeploymentStatusMessage
|
||||
deployment={defaultDeployment}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
render(<DeploymentStatusMessage deployment={defaultDeployment} />);
|
||||
|
||||
expect(screen.getByText(/updated just now/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -59,7 +46,6 @@ test('should render "updated just now" when the deployment\'s status is DEPLOYED
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
deploymentEndedAt: null,
|
||||
}}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -76,19 +62,8 @@ test('should render "deployed 1 day ago" when the deployment has ended', () => {
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
deploymentEndedAt: '2023-02-24T12:15:00.000Z',
|
||||
}}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/deployed 1 day ago/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render "created 1 day ago" if the application does not have a deployment', () => {
|
||||
vi.setSystemTime(new Date('2023-02-25T12:25:00.000Z'));
|
||||
|
||||
render(
|
||||
<DeploymentStatusMessage deployment={null} appCreatedAt="2023-02-24" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/created 1 day ago/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { Avatar } from '@/components/ui/v1/Avatar';
|
||||
import { Avatar } from '@/components/ui/v2/Avatar';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { Deployment } from '@/types/application';
|
||||
import formatDistance from 'date-fns/formatDistance';
|
||||
|
||||
export interface DeploymentStatusMessageProps {
|
||||
/**
|
||||
* The deployment to render the status message for.
|
||||
*/
|
||||
deployment: Partial<Deployment>;
|
||||
/**
|
||||
* The date the application was created.
|
||||
*/
|
||||
appCreatedAt: string;
|
||||
}
|
||||
|
||||
export default function DeploymentStatusMessage({
|
||||
deployment,
|
||||
appCreatedAt,
|
||||
}: DeploymentStatusMessageProps) {
|
||||
const isDeployingToProduction = [
|
||||
'SCHEDULED',
|
||||
@@ -29,11 +21,10 @@ export default function DeploymentStatusMessage({
|
||||
(deployment && !deployment.deploymentEndedAt)
|
||||
) {
|
||||
return (
|
||||
<span className="flex flex-row">
|
||||
<span className="flex flex-row justify-start">
|
||||
<Avatar
|
||||
component="span"
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
alt={`Avatar of ${deployment.commitUserName}`}
|
||||
src={deployment.commitUserAvatarUrl}
|
||||
className="mr-1 h-4 w-4 self-center"
|
||||
/>
|
||||
<Text component="span" className="self-center text-sm">
|
||||
@@ -44,30 +35,26 @@ export default function DeploymentStatusMessage({
|
||||
}
|
||||
|
||||
if (!isDeployingToProduction && deployment?.deploymentEndedAt) {
|
||||
const statusMessage = `deployed ${formatDistance(new Date(deployment.deploymentEndedAt), new Date(), { addSuffix: true })}`;
|
||||
|
||||
return (
|
||||
<span className="grid grid-flow-col">
|
||||
<div className="relative flex flex-row">
|
||||
<Avatar
|
||||
component="span"
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="mr-1 h-4 w-4 self-center"
|
||||
alt={`Avatar of ${deployment.commitUserName}`}
|
||||
src={deployment.commitUserAvatarUrl}
|
||||
className="mr-2 mt-1 h-4 w-4"
|
||||
/>
|
||||
<Text component="span" className="self-center truncate text-sm">
|
||||
{deployment.commitUserName} deployed{' '}
|
||||
{formatDistance(new Date(deployment.deploymentEndedAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Text>
|
||||
</span>
|
||||
<div className="flex flex-col text-sm text-muted-foreground">
|
||||
<p className="line-clamp-1 break-all">{deployment.commitUserName}</p>
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text component="span" className="text-sm">
|
||||
created{' '}
|
||||
{formatDistance(new Date(appCreatedAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
<Text component="span" className="text-sm text-muted-foreground">
|
||||
No deployments
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,132 @@
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import type { MetricsCardProps } from '@/features/orgs/projects/overview/components/MetricsCard';
|
||||
import { MetricsCard } from '@/features/orgs/projects/overview/components/MetricsCard';
|
||||
import { prettifyNumber } from '@/utils/prettifyNumber';
|
||||
import { prettifySize } from '@/utils/prettifySize';
|
||||
import { useGetProjectMetricsQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetProjectMetricsQuery,
|
||||
useGetProjectRequestsMetricQuery,
|
||||
useGetUserProjectMetricsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { prettifySize } from '@/utils/prettifySize';
|
||||
import { formatISO, startOfDay, startOfMonth, subMinutes } from 'date-fns';
|
||||
|
||||
const now = new Date();
|
||||
|
||||
export default function OverviewMetrics() {
|
||||
const { project } = useProject();
|
||||
const { data, loading, error } = useGetProjectMetricsQuery({
|
||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||
|
||||
const {
|
||||
data: {
|
||||
allUsers: { aggregate: { count: allUsers = 0 } = {} } = {},
|
||||
dailyActiveUsers: {
|
||||
aggregate: { count: dailyActiveUsers = 0 } = {},
|
||||
} = {},
|
||||
monthlyActiveUsers: {
|
||||
aggregate: { count: monthlyActiveUsers = 0 } = {},
|
||||
} = {},
|
||||
filesAggregate: {
|
||||
aggregate: { sum: { size: totalStorage = 0 } = {} } = {},
|
||||
} = {},
|
||||
} = {},
|
||||
} = useGetUserProjectMetricsQuery({
|
||||
client: remoteProjectGQLClient,
|
||||
variables: {
|
||||
appId: project?.id,
|
||||
startOfMonth: startOfMonth(new Date()),
|
||||
today: startOfDay(new Date()),
|
||||
},
|
||||
skip: !project,
|
||||
});
|
||||
|
||||
const {
|
||||
data: {
|
||||
totalRequests: { value: totalRequestsInLastFiveMinutes = 0 } = {},
|
||||
} = {},
|
||||
} = useGetProjectRequestsMetricQuery({
|
||||
variables: {
|
||||
appId: project.id,
|
||||
from: formatISO(subMinutes(new Date(), 6)), // 6 mns earlier
|
||||
to: formatISO(subMinutes(new Date(), 1)), // 1 mn earlier
|
||||
},
|
||||
skip: !project,
|
||||
pollInterval: 1000 * 60 * 5, // Poll every 5 minutes
|
||||
});
|
||||
|
||||
const {
|
||||
data: {
|
||||
functionsDuration: { value: functionsDuration = 0 } = {},
|
||||
totalRequests: { value: totalRequests = 0 } = {},
|
||||
postgresVolumeUsage: { value: postgresVolumeUsage = 0 } = {},
|
||||
egressVolume: { value: egressVolume = 0 } = {},
|
||||
} = {},
|
||||
loading,
|
||||
error,
|
||||
} = useGetProjectMetricsQuery({
|
||||
variables: {
|
||||
appId: project.id,
|
||||
subdomain: project?.subdomain,
|
||||
from: new Date(now.getFullYear(), now.getMonth(), 1),
|
||||
},
|
||||
skip: !project?.id,
|
||||
skip: !project,
|
||||
});
|
||||
|
||||
const cardElements: MetricsCardProps[] = [
|
||||
{
|
||||
label: 'CPU Usage Seconds',
|
||||
tooltip: 'Total time the service has used the CPUs',
|
||||
value: prettifyNumber(data?.cpuSecondsUsage?.value || 0),
|
||||
label: 'Daily Active Users',
|
||||
tooltip: 'Unique users active today',
|
||||
value: prettifyNumber(dailyActiveUsers),
|
||||
},
|
||||
{
|
||||
label: 'Monthly Active Users',
|
||||
tooltip: 'Unique users active this month',
|
||||
value: prettifyNumber(monthlyActiveUsers),
|
||||
},
|
||||
{
|
||||
label: 'All Users',
|
||||
tooltip: 'Total registered users',
|
||||
value: prettifyNumber(allUsers),
|
||||
},
|
||||
{
|
||||
label: 'RPS',
|
||||
tooltip: 'Requests Per Second (RPS) measured in the last 5 minutes',
|
||||
value: prettifyNumber(totalRequestsInLastFiveMinutes / 300, {
|
||||
numberOfDecimals: 2,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Total Requests',
|
||||
tooltip:
|
||||
'Total amount of requests your services have received excluding functions',
|
||||
value: prettifyNumber(data?.totalRequests?.value || 0, {
|
||||
numberOfDecimals: data?.totalRequests?.value > 1000 ? 2 : 0,
|
||||
tooltip: 'Total service requests this month so far (excluding functions)',
|
||||
value: prettifyNumber(totalRequests || 0, {
|
||||
numberOfDecimals: totalRequests > 1000 ? 2 : 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Function Invocations',
|
||||
tooltip: 'Number of times your functions have been called',
|
||||
value: prettifyNumber(data?.functionInvocations?.value || 0, {
|
||||
numberOfDecimals: 0,
|
||||
}),
|
||||
label: 'Egress',
|
||||
tooltip: 'Total outgoing data transfer this month so far',
|
||||
value: prettifySize(egressVolume),
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
tooltip: 'Amount of logs stored',
|
||||
value: prettifySize(data?.logsVolume?.value || 0),
|
||||
label: 'Functions Duration',
|
||||
tooltip: 'Total Functions execution this month so far',
|
||||
value: prettifyNumber(functionsDuration),
|
||||
},
|
||||
{
|
||||
label: 'Storage',
|
||||
tooltip: 'Total size of stored files in the storage service',
|
||||
value: prettifySize(totalStorage || 0),
|
||||
},
|
||||
{
|
||||
label: 'Postgres Volume Usage',
|
||||
tooltip: 'Used storage in the Postgres database',
|
||||
value: prettifySize(postgresVolumeUsage),
|
||||
},
|
||||
];
|
||||
|
||||
if (!data && error) {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export default function useNotFoundRedirect() {
|
||||
router.pathname === '/account' ||
|
||||
router.pathname === '/support/ticket' ||
|
||||
router.pathname === '/run-one-click-install' ||
|
||||
router.pathname.includes('/orgs/_') ||
|
||||
router.pathname.includes('/orgs/_/projects/_') ||
|
||||
orgSlug ||
|
||||
(orgSlug && appSubdomain) ||
|
||||
// If we are on a valid workspace and project, we don't want to redirect to 404
|
||||
|
||||
9
dashboard/src/gql/app/getProjectRequestsMetric.gql
Normal file
9
dashboard/src/gql/app/getProjectRequestsMetric.gql
Normal file
@@ -0,0 +1,9 @@
|
||||
query GetProjectRequestsMetric(
|
||||
$appId: String!
|
||||
$from: Timestamp
|
||||
$to: Timestamp
|
||||
) {
|
||||
totalRequests: getTotalRequests(appID: $appId, from: $from, to: $to) {
|
||||
value
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
query getAssistants {
|
||||
query getAssistants($isFileStoresSupported: Boolean!) {
|
||||
graphite {
|
||||
assistants {
|
||||
assistantID
|
||||
@@ -28,6 +28,7 @@ query getAssistants {
|
||||
required
|
||||
}
|
||||
}
|
||||
fileStores @include(if: $isFileStoresSupported)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation deleteFileStore($id: uuid!) {
|
||||
graphite {
|
||||
deleteFileStore(id: $id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
query getGraphiteFileStores {
|
||||
graphite {
|
||||
fileStores {
|
||||
id
|
||||
name
|
||||
vectorStoreID
|
||||
buckets
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation insertFileStore($object: graphiteFileStoreInput!) {
|
||||
graphite {
|
||||
insertFileStore(object: $object) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation updateFileStore($id: uuid!, $object: graphiteFileStoreInput!) {
|
||||
graphite {
|
||||
updateFileStore(id: $id, object: $object) {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ query getProjects($orgSlug: String!) {
|
||||
slug
|
||||
createdAt
|
||||
subdomain
|
||||
region {
|
||||
id
|
||||
name
|
||||
}
|
||||
deployments(limit: 4, order_by: { deploymentStartedAt: desc }) {
|
||||
id
|
||||
commitSHA
|
||||
@@ -20,5 +24,12 @@ query getProjects($orgSlug: String!) {
|
||||
email
|
||||
displayName
|
||||
}
|
||||
appStates(order_by: { createdAt: desc }, limit: 1) {
|
||||
id
|
||||
appId
|
||||
message
|
||||
stateId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
dashboard/src/gql/organizations/getUserProjectMetrics.gql
Normal file
28
dashboard/src/gql/organizations/getUserProjectMetrics.gql
Normal file
@@ -0,0 +1,28 @@
|
||||
query GetUserProjectMetrics($startOfMonth: timestamptz!, $today: timestamptz!) {
|
||||
monthlyActiveUsers: usersAggregate(
|
||||
where: { lastSeen: { _gte: $startOfMonth, _lte: $today } }
|
||||
) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
dailyActiveUsers: usersAggregate(where: { lastSeen: { _gte: $today } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
allUsers: usersAggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
filesAggregate {
|
||||
aggregate {
|
||||
count
|
||||
sum {
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { useIsGraphiteEnabled } from '@/features/projects/common/hooks/useIsGrap
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
useGetAssistantsQuery,
|
||||
type GetAssistantsQuery,
|
||||
type GetAssistantsQuery
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
|
||||
@@ -29,21 +29,29 @@ export type Assistant = Omit<
|
||||
export default function AssistantsPage() {
|
||||
const { openDrawer } = useDialog();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
const { isGraphiteEnabled } = useIsGraphiteEnabled();
|
||||
|
||||
const { data, loading, refetch } = useGetAssistantsQuery({
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
refetch,
|
||||
} = useGetAssistantsQuery({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const assistants = useMemo(() => data?.graphite?.assistants || [], [data]);
|
||||
|
||||
const assistants = useMemo(
|
||||
() => data?.graphite?.assistants || [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const openCreateAssistantForm = () => {
|
||||
openDrawer({
|
||||
title: 'Create a new Assistant',
|
||||
component: <AssistantForm onSubmit={refetch} />,
|
||||
component: (
|
||||
<AssistantForm onSubmit={refetch} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -97,7 +105,11 @@ export default function AssistantsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.graphite?.assistants.length === 0 && !loading) {
|
||||
if (loading) {
|
||||
return <Box className="p-4">Loading...</Box>;
|
||||
}
|
||||
|
||||
if (assistants.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-6"
|
||||
@@ -141,13 +153,11 @@ export default function AssistantsPage() {
|
||||
New
|
||||
</Button>
|
||||
</Box>
|
||||
<div>
|
||||
<AssistantsList
|
||||
assistants={assistants}
|
||||
onDelete={() => refetch()}
|
||||
onCreateOrUpdate={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
<AssistantsList
|
||||
assistants={assistants}
|
||||
onDelete={() => refetch()}
|
||||
onCreateOrUpdate={() => refetch()}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
|
||||
import {
|
||||
useGetAssistantsQuery,
|
||||
useGetGraphiteFileStoresQuery,
|
||||
type GetAssistantsQuery,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
@@ -21,6 +22,7 @@ import { AISidebar } from '@/features/orgs/layout/AISidebar';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { AssistantForm } from '@/features/orgs/projects/ai/AssistantForm';
|
||||
import { AssistantsList } from '@/features/orgs/projects/ai/AssistantsList';
|
||||
import { useIsFileStoreSupported } from '@/features/orgs/projects/common/hooks/useIsFileStoreSupported';
|
||||
import { useIsGraphiteEnabled } from '@/features/orgs/projects/common/hooks/useIsGraphiteEnabled';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||
@@ -43,24 +45,46 @@ export default function AssistantsPage() {
|
||||
const { isGraphiteEnabled, loading: loadingGraphite } =
|
||||
useIsGraphiteEnabled();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading: loadingAssistants,
|
||||
refetch,
|
||||
} = useGetAssistantsQuery({
|
||||
client: adminClient,
|
||||
});
|
||||
const { isFileStoreSupported, loading: fileStoreLoading } =
|
||||
useIsFileStoreSupported();
|
||||
|
||||
const assistants = useMemo(() => data?.graphite?.assistants || [], [data]);
|
||||
const {
|
||||
data: assistantsData,
|
||||
loading: assistantsLoading,
|
||||
refetch: assistantsRefetch,
|
||||
} = useGetAssistantsQuery({
|
||||
client: adminClient,
|
||||
variables: {
|
||||
isFileStoresSupported: isFileStoreSupported ?? false,
|
||||
},
|
||||
skip: isFileStoreSupported === null || fileStoreLoading,
|
||||
});
|
||||
const { data: fileStoresData } = useGetGraphiteFileStoresQuery({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const assistants = useMemo(
|
||||
() => assistantsData?.graphite?.assistants || [],
|
||||
[assistantsData],
|
||||
);
|
||||
const fileStores = useMemo(
|
||||
() => fileStoresData?.graphite?.fileStores || [],
|
||||
[fileStoresData],
|
||||
);
|
||||
|
||||
const openCreateAssistantForm = () => {
|
||||
openDrawer({
|
||||
title: 'Create a new Assistant',
|
||||
component: <AssistantForm onSubmit={refetch} />,
|
||||
component: (
|
||||
<AssistantForm
|
||||
onSubmit={assistantsRefetch}
|
||||
fileStores={isFileStoreSupported ? fileStores : undefined}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (loadingOrg || loadingProject || loadingGraphite || loadingAssistants) {
|
||||
if (loadingOrg || loadingProject || loadingGraphite || assistantsLoading) {
|
||||
return (
|
||||
<Box className="flex items-center justify-center w-full h-full">
|
||||
<ActivityIndicator
|
||||
@@ -114,7 +138,7 @@ export default function AssistantsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.graphite?.assistants.length === 0 && !loadingAssistants) {
|
||||
if (assistants.length === 0 && !assistantsLoading) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-6"
|
||||
@@ -161,8 +185,9 @@ export default function AssistantsPage() {
|
||||
<div>
|
||||
<AssistantsList
|
||||
assistants={assistants}
|
||||
onDelete={() => refetch()}
|
||||
onCreateOrUpdate={() => refetch()}
|
||||
fileStores={isFileStoreSupported ? fileStores : undefined}
|
||||
onDelete={() => assistantsRefetch()}
|
||||
onCreateOrUpdate={() => assistantsRefetch()}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
@@ -12,14 +12,13 @@ import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { AISidebar } from '@/features/orgs/layout/AISidebar';
|
||||
// import AILayout from '@/features/orgs/layout/AILayout/AILayout';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { AutoEmbeddingsForm } from '@/features/orgs/projects/ai/AutoEmbeddingsForm';
|
||||
import { AutoEmbeddingsList } from '@/features/orgs/projects/ai/AutoEmbeddingsList';
|
||||
import { useIsGraphiteEnabled } from '@/features/orgs/projects/common/hooks/useIsGraphiteEnabled';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import {
|
||||
useGetGraphiteAutoEmbeddingsConfigurationsQuery,
|
||||
@@ -36,9 +35,11 @@ export type AutoEmbeddingsConfiguration = Omit<
|
||||
export default function AutoEmbeddingsPage() {
|
||||
const limit = useRef(25);
|
||||
const router = useRouter();
|
||||
|
||||
const { openDrawer } = useDialog();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentOrg: org } = useOrgs();
|
||||
|
||||
const { org } = useCurrentOrg();
|
||||
const { project } = useProject();
|
||||
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
@@ -128,7 +129,7 @@ export default function AutoEmbeddingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.graphiteAutoEmbeddingsConfigurations.length === 0 && !loading) {
|
||||
if (autoEmbeddingsConfigurations.length === 0 && !loading) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-6"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { UpgradeToProBanner } from '@/components/common/UpgradeToProBanner';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { FileStoresIcon } from '@/components/ui/v2/icons/FileStoresIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { FileStoreForm } from '@/features/orgs/projects/ai/FileStoreForm';
|
||||
import { FileStoresList } from '@/features/orgs/projects/ai/FileStoresList';
|
||||
import { useIsFileStoreSupported } from '@/features/orgs/projects/common/hooks/useIsFileStoreSupported';
|
||||
import { useIsGraphiteEnabled } from '@/features/orgs/projects/common/hooks/useIsGraphiteEnabled';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import {
|
||||
useGetGraphiteFileStoresQuery,
|
||||
type GetGraphiteFileStoresQuery
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { AISidebar } from '@/features/orgs/layout/AISidebar';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
|
||||
export type GraphiteFileStore = Omit<
|
||||
GetGraphiteFileStoresQuery['graphite']['fileStores'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function FileStoresPage() {
|
||||
const { openDrawer } = useDialog();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { org, loading: loadingOrg } = useCurrentOrg();
|
||||
const { project, loading: loadingProject } = useProject();
|
||||
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
const { isGraphiteEnabled } = useIsGraphiteEnabled();
|
||||
const { isFileStoreSupported } = useIsFileStoreSupported();
|
||||
|
||||
const { data, loading, refetch } = useGetGraphiteFileStoresQuery({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const fileStores = useMemo(() => data?.graphite.fileStores || [], [data]);
|
||||
|
||||
const openCreateFileStoreForm = () => {
|
||||
openDrawer({
|
||||
title: 'Create a new File Store',
|
||||
component: <FileStoreForm onSubmit={refetch} />,
|
||||
});
|
||||
};
|
||||
|
||||
if (loadingOrg || loadingProject || loading) {
|
||||
return (
|
||||
<Box className="flex items-center justify-center w-full h-full">
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading File Stores..."
|
||||
className="justify-center"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPlatform && org?.plan?.isFree) {
|
||||
return (
|
||||
<Box className="p-4" sx={{ backgroundColor: 'background.default' }}>
|
||||
<UpgradeToProBanner
|
||||
title="Upgrade to Nhost Pro."
|
||||
description={
|
||||
<Text>
|
||||
Graphite is an addon to the Pro plan. To unlock it, please upgrade
|
||||
to Pro first.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(isPlatform &&
|
||||
!org?.plan?.isFree &&
|
||||
!project.config?.ai) ||
|
||||
!isGraphiteEnabled
|
||||
) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-4"
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
>
|
||||
<Alert className="grid w-full grid-flow-col place-content-between items-center gap-2">
|
||||
<Text className="grid grid-flow-row justify-items-start gap-0.5">
|
||||
<Text component="span">
|
||||
To enable graphite, configure the service first in{' '}
|
||||
<Link
|
||||
href={`/orgs/${org?.slug}/projects/${project?.subdomain}/settings/ai`}
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
AI Settings
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Text>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileStores.length === 0 && !loading) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-6"
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
>
|
||||
<Box className="flex flex-col items-center justify-center space-y-5 rounded-lg border px-48 py-12 shadow-sm">
|
||||
<FileStoresIcon className="h-10 w-10" />
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Text className="text-center font-medium" variant="h3">
|
||||
No File Stores are configured
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
File Stores are used to share storage documents with your
|
||||
AI assistants.
|
||||
</Text>
|
||||
{!isFileStoreSupported && (
|
||||
<Box className="px-4 pb-4">
|
||||
<Alert className="mt-2 text-left">
|
||||
Please upgrade Graphite to its latest version in order to use
|
||||
file stores.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row place-content-between rounded-lg">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-full"
|
||||
onClick={openCreateFileStoreForm}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
disabled={!isFileStoreSupported}
|
||||
>
|
||||
Add a new File Store
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col w-full overflow-hidden">
|
||||
<Box className="flex flex-row place-content-end border-b-1 p-4">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={openCreateFileStoreForm}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Box>
|
||||
<div>
|
||||
<FileStoresList
|
||||
fileStores={fileStores}
|
||||
onDelete={() => refetch()}
|
||||
onCreateOrUpdate={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
FileStoresPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<ProjectLayout
|
||||
mainContainerProps={{ className: 'flex flex-row w-full h-full' }}
|
||||
>
|
||||
<AISidebar className="w-full max-w-sidebar" />
|
||||
<RetryableErrorBoundary>{page}</RetryableErrorBoundary>
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
};
|
||||
15
dashboard/src/pages/orgs/_/[...slug].tsx
Normal file
15
dashboard/src/pages/orgs/_/[...slug].tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SelectOrg } from '@/components/common/SelectOrg';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function SelectOrganization() {
|
||||
return <SelectOrg />
|
||||
}
|
||||
|
||||
SelectOrganization.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select an Organization">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
15
dashboard/src/pages/orgs/_/projects/_/[...slug].tsx
Normal file
15
dashboard/src/pages/orgs/_/projects/_/[...slug].tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import { SelectOrgAndProject } from '@/components/common/SelectOrgAndProject';
|
||||
|
||||
export default function OrganizationAndProject() {
|
||||
return <SelectOrgAndProject />
|
||||
}
|
||||
|
||||
OrganizationAndProject.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select a Project">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
14
dashboard/src/pages/orgs/_/projects/_/index.tsx
Normal file
14
dashboard/src/pages/orgs/_/projects/_/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { SelectOrgAndProject } from '@/components/common/SelectOrgAndProject';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function SelectOrganizationAndProject() {
|
||||
return <SelectOrgAndProject />
|
||||
}
|
||||
|
||||
SelectOrganizationAndProject.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select a Project">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
@@ -290,10 +290,10 @@ function TicketPage() {
|
||||
<ControlledAutocomplete
|
||||
id="services"
|
||||
name="services"
|
||||
label="services"
|
||||
label="Services"
|
||||
fullWidth
|
||||
multiple
|
||||
aria-label="Enabled APIs"
|
||||
aria-label="Services"
|
||||
options={[
|
||||
'Dashboard',
|
||||
'Database',
|
||||
|
||||
22227
dashboard/src/utils/__generated__/graphite.graphql.ts
generated
22227
dashboard/src/utils/__generated__/graphite.graphql.ts
generated
File diff suppressed because it is too large
Load Diff
156
dashboard/src/utils/__generated__/graphql.ts
generated
156
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -2946,6 +2946,7 @@ export type ConfigSystemConfig = {
|
||||
__typename?: 'ConfigSystemConfig';
|
||||
auth?: Maybe<ConfigSystemConfigAuth>;
|
||||
graphql?: Maybe<ConfigSystemConfigGraphql>;
|
||||
persistentVolumesEncrypted?: Maybe<Scalars['Boolean']>;
|
||||
postgres: ConfigSystemConfigPostgres;
|
||||
};
|
||||
|
||||
@@ -3015,6 +3016,7 @@ export type ConfigSystemConfigComparisonExp = {
|
||||
_or?: InputMaybe<Array<ConfigSystemConfigComparisonExp>>;
|
||||
auth?: InputMaybe<ConfigSystemConfigAuthComparisonExp>;
|
||||
graphql?: InputMaybe<ConfigSystemConfigGraphqlComparisonExp>;
|
||||
persistentVolumesEncrypted?: InputMaybe<ConfigBooleanComparisonExp>;
|
||||
postgres?: InputMaybe<ConfigSystemConfigPostgresComparisonExp>;
|
||||
};
|
||||
|
||||
@@ -3045,6 +3047,7 @@ export type ConfigSystemConfigGraphqlUpdateInput = {
|
||||
export type ConfigSystemConfigInsertInput = {
|
||||
auth?: InputMaybe<ConfigSystemConfigAuthInsertInput>;
|
||||
graphql?: InputMaybe<ConfigSystemConfigGraphqlInsertInput>;
|
||||
persistentVolumesEncrypted?: InputMaybe<Scalars['Boolean']>;
|
||||
postgres: ConfigSystemConfigPostgresInsertInput;
|
||||
};
|
||||
|
||||
@@ -3143,6 +3146,7 @@ export type ConfigSystemConfigPostgresUpdateInput = {
|
||||
export type ConfigSystemConfigUpdateInput = {
|
||||
auth?: InputMaybe<ConfigSystemConfigAuthUpdateInput>;
|
||||
graphql?: InputMaybe<ConfigSystemConfigGraphqlUpdateInput>;
|
||||
persistentVolumesEncrypted?: InputMaybe<Scalars['Boolean']>;
|
||||
postgres?: InputMaybe<ConfigSystemConfigPostgresUpdateInput>;
|
||||
};
|
||||
|
||||
@@ -4337,6 +4341,7 @@ export type Apps = {
|
||||
appStates: Array<AppStateHistory>;
|
||||
/** An aggregate relationship */
|
||||
appStates_aggregate: AppStateHistory_Aggregate;
|
||||
automaticDeploys: Scalars['Boolean'];
|
||||
/** An array relationship */
|
||||
backups: Array<Backups>;
|
||||
/** An aggregate relationship */
|
||||
@@ -4615,6 +4620,7 @@ export type Apps_Bool_Exp = {
|
||||
_or?: InputMaybe<Array<Apps_Bool_Exp>>;
|
||||
appStates?: InputMaybe<AppStateHistory_Bool_Exp>;
|
||||
appStates_aggregate?: InputMaybe<AppStateHistory_Aggregate_Bool_Exp>;
|
||||
automaticDeploys?: InputMaybe<Boolean_Comparison_Exp>;
|
||||
backups?: InputMaybe<Backups_Bool_Exp>;
|
||||
backups_aggregate?: InputMaybe<Backups_Aggregate_Bool_Exp>;
|
||||
billingDedicatedCompute?: InputMaybe<Billing_Dedicated_Compute_Bool_Exp>;
|
||||
@@ -4692,6 +4698,7 @@ export type Apps_Inc_Input = {
|
||||
/** input type for inserting data into table "apps" */
|
||||
export type Apps_Insert_Input = {
|
||||
appStates?: InputMaybe<AppStateHistory_Arr_Rel_Insert_Input>;
|
||||
automaticDeploys?: InputMaybe<Scalars['Boolean']>;
|
||||
backups?: InputMaybe<Backups_Arr_Rel_Insert_Input>;
|
||||
billingDedicatedCompute?: InputMaybe<Billing_Dedicated_Compute_Obj_Rel_Insert_Input>;
|
||||
billingSubscriptions?: InputMaybe<Billing_Subscriptions_Obj_Rel_Insert_Input>;
|
||||
@@ -4858,6 +4865,7 @@ export type Apps_On_Conflict = {
|
||||
/** Ordering options when selecting data from "apps". */
|
||||
export type Apps_Order_By = {
|
||||
appStates_aggregate?: InputMaybe<AppStateHistory_Aggregate_Order_By>;
|
||||
automaticDeploys?: InputMaybe<Order_By>;
|
||||
backups_aggregate?: InputMaybe<Backups_Aggregate_Order_By>;
|
||||
billingDedicatedCompute?: InputMaybe<Billing_Dedicated_Compute_Order_By>;
|
||||
billingSubscriptions?: InputMaybe<Billing_Subscriptions_Order_By>;
|
||||
@@ -4909,6 +4917,8 @@ export type Apps_Prepend_Input = {
|
||||
|
||||
/** select columns of table "apps" */
|
||||
export enum Apps_Select_Column {
|
||||
/** column name */
|
||||
AutomaticDeploys = 'automaticDeploys',
|
||||
/** column name */
|
||||
CreatedAt = 'createdAt',
|
||||
/** column name */
|
||||
@@ -4961,6 +4971,8 @@ export enum Apps_Select_Column {
|
||||
|
||||
/** select "apps_aggregate_bool_exp_bool_and_arguments_columns" columns of table "apps" */
|
||||
export enum Apps_Select_Column_Apps_Aggregate_Bool_Exp_Bool_And_Arguments_Columns {
|
||||
/** column name */
|
||||
AutomaticDeploys = 'automaticDeploys',
|
||||
/** column name */
|
||||
IsLocked = 'isLocked',
|
||||
/** column name */
|
||||
@@ -4969,6 +4981,8 @@ export enum Apps_Select_Column_Apps_Aggregate_Bool_Exp_Bool_And_Arguments_Column
|
||||
|
||||
/** select "apps_aggregate_bool_exp_bool_or_arguments_columns" columns of table "apps" */
|
||||
export enum Apps_Select_Column_Apps_Aggregate_Bool_Exp_Bool_Or_Arguments_Columns {
|
||||
/** column name */
|
||||
AutomaticDeploys = 'automaticDeploys',
|
||||
/** column name */
|
||||
IsLocked = 'isLocked',
|
||||
/** column name */
|
||||
@@ -4977,6 +4991,7 @@ export enum Apps_Select_Column_Apps_Aggregate_Bool_Exp_Bool_Or_Arguments_Columns
|
||||
|
||||
/** input type for updating data in table "apps" */
|
||||
export type Apps_Set_Input = {
|
||||
automaticDeploys?: InputMaybe<Scalars['Boolean']>;
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
creatorUserId?: InputMaybe<Scalars['uuid']>;
|
||||
currentState?: InputMaybe<Scalars['Int']>;
|
||||
@@ -5051,6 +5066,7 @@ export type Apps_Stream_Cursor_Input = {
|
||||
|
||||
/** Initial value of the column from where the streaming should start */
|
||||
export type Apps_Stream_Cursor_Value_Input = {
|
||||
automaticDeploys?: InputMaybe<Scalars['Boolean']>;
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
creatorUserId?: InputMaybe<Scalars['uuid']>;
|
||||
currentState?: InputMaybe<Scalars['Int']>;
|
||||
@@ -5092,6 +5108,8 @@ export type Apps_Sum_Order_By = {
|
||||
|
||||
/** update columns of table "apps" */
|
||||
export enum Apps_Update_Column {
|
||||
/** column name */
|
||||
AutomaticDeploys = 'automaticDeploys',
|
||||
/** column name */
|
||||
CreatedAt = 'createdAt',
|
||||
/** column name */
|
||||
@@ -13334,6 +13352,7 @@ export type Mutation_Root = {
|
||||
delete_regions?: Maybe<Regions_Mutation_Response>;
|
||||
/** delete single row from the table: "regions" */
|
||||
delete_regions_by_pk?: Maybe<Regions>;
|
||||
encryptPersistentVolumes: Scalars['Boolean'];
|
||||
/** insert a single row into the table: "announcements_read" */
|
||||
insertAnnouncementRead?: Maybe<Announcements_Read>;
|
||||
/** insert data into the table: "announcements_read" */
|
||||
@@ -14659,6 +14678,12 @@ export type Mutation_RootDelete_Regions_By_PkArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootEncryptPersistentVolumesArgs = {
|
||||
appID: Scalars['uuid'];
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootInsertAnnouncementReadArgs = {
|
||||
object: Announcements_Read_Insert_Input;
|
||||
@@ -27400,6 +27425,15 @@ export type GetProjectMetricsQueryVariables = Exact<{
|
||||
|
||||
export type GetProjectMetricsQuery = { __typename?: 'query_root', logsVolume: { __typename?: 'Metrics', value: any }, cpuSecondsUsage: { __typename?: 'Metrics', value: any }, functionInvocations: { __typename?: 'Metrics', value: any }, functionsDuration: { __typename?: 'Metrics', value: any }, postgresVolumeCapacity: { __typename?: 'Metrics', value: any }, postgresVolumeUsage: { __typename?: 'Metrics', value: any }, totalRequests: { __typename?: 'Metrics', value: any }, egressVolume: { __typename?: 'Metrics', value: any } };
|
||||
|
||||
export type GetProjectRequestsMetricQueryVariables = Exact<{
|
||||
appId: Scalars['String'];
|
||||
from?: InputMaybe<Scalars['Timestamp']>;
|
||||
to?: InputMaybe<Scalars['Timestamp']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetProjectRequestsMetricQuery = { __typename?: 'query_root', totalRequests: { __typename?: 'Metrics', value: any } };
|
||||
|
||||
export type GetProjectServicesHealthQueryVariables = Exact<{
|
||||
appId: Scalars['String'];
|
||||
}>;
|
||||
@@ -27840,7 +27874,15 @@ export type GetProjectsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetProjectsQuery = { __typename?: 'query_root', apps: Array<{ __typename?: 'apps', id: any, name: string, slug: string, createdAt: any, subdomain: string, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null }> };
|
||||
export type GetProjectsQuery = { __typename?: 'query_root', apps: Array<{ __typename?: 'apps', id: any, name: string, slug: string, createdAt: any, subdomain: string, region: { __typename?: 'regions', id: any, name: string }, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }> }> };
|
||||
|
||||
export type GetUserProjectMetricsQueryVariables = Exact<{
|
||||
startOfMonth: Scalars['timestamptz'];
|
||||
today: Scalars['timestamptz'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetUserProjectMetricsQuery = { __typename?: 'query_root', monthlyActiveUsers: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null }, dailyActiveUsers: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null }, allUsers: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null }, filesAggregate: { __typename?: 'files_aggregate', aggregate?: { __typename?: 'files_aggregate_fields', count: number, sum?: { __typename?: 'files_sum_fields', size?: number | null } | null } | null } };
|
||||
|
||||
export type InsertOrgApplicationMutationVariables = Exact<{
|
||||
app: Apps_Insert_Input;
|
||||
@@ -29925,6 +29967,46 @@ export type GetProjectMetricsQueryResult = Apollo.QueryResult<GetProjectMetricsQ
|
||||
export function refetchGetProjectMetricsQuery(variables: GetProjectMetricsQueryVariables) {
|
||||
return { query: GetProjectMetricsDocument, variables: variables }
|
||||
}
|
||||
export const GetProjectRequestsMetricDocument = gql`
|
||||
query GetProjectRequestsMetric($appId: String!, $from: Timestamp, $to: Timestamp) {
|
||||
totalRequests: getTotalRequests(appID: $appId, from: $from, to: $to) {
|
||||
value
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetProjectRequestsMetricQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetProjectRequestsMetricQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetProjectRequestsMetricQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetProjectRequestsMetricQuery({
|
||||
* variables: {
|
||||
* appId: // value for 'appId'
|
||||
* from: // value for 'from'
|
||||
* to: // value for 'to'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetProjectRequestsMetricQuery(baseOptions: Apollo.QueryHookOptions<GetProjectRequestsMetricQuery, GetProjectRequestsMetricQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetProjectRequestsMetricQuery, GetProjectRequestsMetricQueryVariables>(GetProjectRequestsMetricDocument, options);
|
||||
}
|
||||
export function useGetProjectRequestsMetricLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetProjectRequestsMetricQuery, GetProjectRequestsMetricQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetProjectRequestsMetricQuery, GetProjectRequestsMetricQueryVariables>(GetProjectRequestsMetricDocument, options);
|
||||
}
|
||||
export type GetProjectRequestsMetricQueryHookResult = ReturnType<typeof useGetProjectRequestsMetricQuery>;
|
||||
export type GetProjectRequestsMetricLazyQueryHookResult = ReturnType<typeof useGetProjectRequestsMetricLazyQuery>;
|
||||
export type GetProjectRequestsMetricQueryResult = Apollo.QueryResult<GetProjectRequestsMetricQuery, GetProjectRequestsMetricQueryVariables>;
|
||||
export function refetchGetProjectRequestsMetricQuery(variables: GetProjectRequestsMetricQueryVariables) {
|
||||
return { query: GetProjectRequestsMetricDocument, variables: variables }
|
||||
}
|
||||
export const GetProjectServicesHealthDocument = gql`
|
||||
query getProjectServicesHealth($appId: String!) {
|
||||
getProjectStatus(appID: $appId) {
|
||||
@@ -32463,6 +32545,10 @@ export const GetProjectsDocument = gql`
|
||||
slug
|
||||
createdAt
|
||||
subdomain
|
||||
region {
|
||||
id
|
||||
name
|
||||
}
|
||||
deployments(limit: 4, order_by: {deploymentStartedAt: desc}) {
|
||||
id
|
||||
commitSHA
|
||||
@@ -32478,6 +32564,13 @@ export const GetProjectsDocument = gql`
|
||||
email
|
||||
displayName
|
||||
}
|
||||
appStates(order_by: {createdAt: desc}, limit: 1) {
|
||||
id
|
||||
appId
|
||||
message
|
||||
stateId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -32512,6 +32605,67 @@ export type GetProjectsQueryResult = Apollo.QueryResult<GetProjectsQuery, GetPro
|
||||
export function refetchGetProjectsQuery(variables: GetProjectsQueryVariables) {
|
||||
return { query: GetProjectsDocument, variables: variables }
|
||||
}
|
||||
export const GetUserProjectMetricsDocument = gql`
|
||||
query GetUserProjectMetrics($startOfMonth: timestamptz!, $today: timestamptz!) {
|
||||
monthlyActiveUsers: usersAggregate(
|
||||
where: {lastSeen: {_gte: $startOfMonth, _lte: $today}}
|
||||
) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
dailyActiveUsers: usersAggregate(where: {lastSeen: {_gte: $today}}) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
allUsers: usersAggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
filesAggregate {
|
||||
aggregate {
|
||||
count
|
||||
sum {
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetUserProjectMetricsQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetUserProjectMetricsQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetUserProjectMetricsQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetUserProjectMetricsQuery({
|
||||
* variables: {
|
||||
* startOfMonth: // value for 'startOfMonth'
|
||||
* today: // value for 'today'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetUserProjectMetricsQuery(baseOptions: Apollo.QueryHookOptions<GetUserProjectMetricsQuery, GetUserProjectMetricsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetUserProjectMetricsQuery, GetUserProjectMetricsQueryVariables>(GetUserProjectMetricsDocument, options);
|
||||
}
|
||||
export function useGetUserProjectMetricsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetUserProjectMetricsQuery, GetUserProjectMetricsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetUserProjectMetricsQuery, GetUserProjectMetricsQueryVariables>(GetUserProjectMetricsDocument, options);
|
||||
}
|
||||
export type GetUserProjectMetricsQueryHookResult = ReturnType<typeof useGetUserProjectMetricsQuery>;
|
||||
export type GetUserProjectMetricsLazyQueryHookResult = ReturnType<typeof useGetUserProjectMetricsLazyQuery>;
|
||||
export type GetUserProjectMetricsQueryResult = Apollo.QueryResult<GetUserProjectMetricsQuery, GetUserProjectMetricsQueryVariables>;
|
||||
export function refetchGetUserProjectMetricsQuery(variables: GetUserProjectMetricsQueryVariables) {
|
||||
return { query: GetUserProjectMetricsDocument, variables: variables }
|
||||
}
|
||||
export const InsertOrgApplicationDocument = gql`
|
||||
mutation insertOrgApplication($app: apps_insert_input!) {
|
||||
insertApp(object: $app) {
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 2.24.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a99f034: chore: fix function name
|
||||
|
||||
## 2.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 14e6100: feat: add documentation for sign-in with ID token
|
||||
|
||||
## 2.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 10b0f74: feat: add jwt docs + openapi improvements
|
||||
- fe6e8e2: feat: add signin with otp reference docs
|
||||
- 8f77914: fix: added pg_repack and an extension overview to database guide
|
||||
|
||||
## 2.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -34,6 +34,9 @@ nhost
|
||||
│ │ ├── password-reset
|
||||
│ │ │ ├── body.html
|
||||
│ │ │ └── subject.txt
|
||||
│ │ ├── signin-otp
|
||||
│ │ │ ├── body.html
|
||||
│ │ │ └── subject.txt
|
||||
│ │ ├── signin-passwordless
|
||||
│ │ │ ├── body.html
|
||||
│ │ │ └── subject.txt
|
||||
@@ -49,6 +52,9 @@ nhost
|
||||
│ ├── password-reset
|
||||
│ │ ├── body.html
|
||||
│ │ └── subject.txt
|
||||
│ ├── signin-otp
|
||||
│ │ ├── body.html
|
||||
│ │ └── subject.txt
|
||||
│ ├── signin-passwordless
|
||||
│ │ ├── body.html
|
||||
│ │ └── subject.txt
|
||||
@@ -82,7 +88,7 @@ The following variables are available to all email templates:
|
||||
| `serverUrl` | URL of the authentication server |
|
||||
| `clientUrl` | URL of your client app |
|
||||
| `redirectTo` | URL where the user will be redirected to after clicking the link and finishing the action of the email |
|
||||
| `ticket` | Ticket that is used to authorize the link request |
|
||||
| `ticket` | Ticket or OTP that is used to authorize the request. |
|
||||
| `displayName` | The display name of the user |
|
||||
| `email` | The email of the user |
|
||||
| `locale` | Locale of the user as a two-letter language code (e.g. "en") |
|
||||
|
||||
315
docs/guides/auth/jwt.mdx
Normal file
315
docs/guides/auth/jwt.mdx
Normal file
@@ -0,0 +1,315 @@
|
||||
---
|
||||
title: JSON Web Tokens (JWTs)
|
||||
description: Configure JSON Web Tokens to your needs
|
||||
icon: key
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
JSON Web Tokens (JWT) are encoded strings designed to securely transmit information between parties in the form of a JSON object. Each JWT consists of three parts:
|
||||
|
||||
- header
|
||||
- payload
|
||||
- signature
|
||||
|
||||
JWTs are commonly used for authentication post-login. The server generates a token containing user claims (like identity and permissions) that subsequent requests can include to prove authorization.
|
||||
|
||||
Here's how JWTs typically work in an authentication flow:
|
||||
|
||||
1. User logs in with credentials (username/password)
|
||||
2. Server validates credentials and generates a signed JWT containing user information and permissions
|
||||
3. Server sends the JWT to the client, which stores it (usually in browser storage)
|
||||
4. For subsequent requests, the client includes the JWT in the Authorization header
|
||||
5. Server verifies the token's signature and grants access based on the encoded permissions
|
||||
|
||||
The main advantage is that the server doesn't need to store session information - all necessary data is contained within the token itself, making it ideal for stateless authentication.
|
||||
|
||||
<Info>For more information about JSON Web Tokens, visit [jwt.io](https://jwt.io).</Info>
|
||||
|
||||
## JWT Configuration
|
||||
|
||||
You can configure your project to use three different kinds of JWTs:
|
||||
|
||||
- JWTs signed with symmetric keys
|
||||
- JWTs signed with asymmetric keys
|
||||
- JWTs signed externally via a third-party service
|
||||
|
||||
<Note>
|
||||
Currently we default to using symmetric keys for signing JWTs. However, we plan to change this to use asymmetric keys in the near future.
|
||||
</Note>
|
||||
|
||||
### Symmetric Keys
|
||||
|
||||
With symmetric keys, your project uses a single key for both signing and verifying JWTs. This key is stored in the project's configuration and is responsible for signing JWTs. When a client sends a JWT to the server, the server uses the same key to verify the JWT’s signature. If you need to verify JWTs in a different service, the same key can be used for verification. Since the same key is used for both signing and verification, it is crucial to keep it secret, as sharing it with others can compromise the security of your JWTs.
|
||||
|
||||
|
||||
Below you can see an example of a symmetric key configuration:
|
||||
|
||||
|
||||
<Tabs>
|
||||
<Tab title="nhost.toml">
|
||||
```toml
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = 'f03d5f5a0ed055e3fcbc0a3639405aca0511e6abe6d60e40d1fff610c6248f2a'
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="dashboard">
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Note>
|
||||
We recommend using a [secret](/platform/secrets) to configure the key.
|
||||
</Note>
|
||||
|
||||
In addition to `HS256`, you can also use `HS384` and `HS512` for extra security. To quickly generate a key, you can use the following command:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="HS256">
|
||||
```shell
|
||||
openssl rand -base64 32
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="HS384">
|
||||
```shell
|
||||
openssl rand -base64 48
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="HS512">
|
||||
```shell
|
||||
openssl rand -base64 64
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Asymmetric Keys
|
||||
|
||||
With asymmetric keys, your project uses a pair of public and private keys for signing and verifying JWTs. The private key, stored securely in the project's configuration, is used to sign the JWTs. The public key, on the other hand, is made available to clients and is used to verify the JWTs. When a client sends a JWT to the server, the server uses the public key to validate the JWT’s signature. If verification is needed in a different service, the public key can be used without compromising security. Since the public key is only used for verification and the private key for signing, sharing the public key is safe and does not jeopardize the security of your JWTs.
|
||||
|
||||
|
||||
Below you can see an example of an asymmetric key configuration:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="nhost.toml">
|
||||
```toml
|
||||
[[hasura.jwtSecrets]]
|
||||
type = "RS256"
|
||||
kid = "bskhwtelkajsd"
|
||||
key = ""
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqSFS8Kx9LuiYpIms+NoZ
|
||||
(ommited for brevity)
|
||||
jwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
""
|
||||
signingKey = ""
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCpIVLwrH0u6Jik
|
||||
(ommited for brevity)
|
||||
s6fJmz3ZeArPI8KFSI3Q2xqm
|
||||
-----END PRIVATE KEY-----
|
||||
""
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="dashboard">
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
In addition to `RS256`, you can also use `RS384` and `RS512` for extra security. To quickly generate a key pair, you can use the following commands:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="RS256">
|
||||
```shell
|
||||
# Generate a private key
|
||||
openssl genpkey -algorithm RSA -out jwt_private.pem -pkeyopt rsa_keygen_bits:2048
|
||||
|
||||
# Generate a public key from the private key
|
||||
openssl rsa -pubout -in jwt_private.pem -out jwt_public.pem
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="RS384">
|
||||
```shell
|
||||
# Generate a private key
|
||||
openssl genpkey -algorithm RSA -out jwt_private.pem -pkeyopt rsa_keygen_bits:3072
|
||||
|
||||
# Generate a public key from the private key
|
||||
openssl rsa -pubout -in jwt_private.pem -out jwt_public.pem
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="RS512">
|
||||
```shell
|
||||
# Generate a private key
|
||||
openssl genpkey -algorithm RSA -out jwt_private.pem -pkeyopt rsa_keygen_bits:4096
|
||||
|
||||
# Generate a public key from the private key
|
||||
openssl rsa -pubout -in jwt_private.pem -out jwt_public.pem
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
You can then copy the contents of `jwt_private.pem` into the `signingKey` field and the contents of `jwt_public.pem` into the `key` field.
|
||||
|
||||
The `kid` value in your configuration can be any unique string of your choice and must be distinct for each key. It is used to identify the correct key when verifying JWTs through the JWKS endpoint.
|
||||
|
||||
### External Signing
|
||||
|
||||
If you are using a third party service like Auth0 or Clerk you can configure your project to use their JWK endpoint to verify JWTs. Below you can see an example of an external signing configuration:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="nhost.toml">
|
||||
|
||||
```toml
|
||||
[[hasura.jwtSecrets]]
|
||||
jwk_url = "https://mythirdpartyservice.com/jwks.json"
|
||||
```
|
||||
|
||||
Alternatively, you can configure the public key directly:
|
||||
|
||||
```toml
|
||||
[[hasura.jwtSecrets]]
|
||||
type = "RS256"
|
||||
key = ""
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqSFS8Kx9LuiYpIms+NoZ
|
||||
(ommited for brevity)
|
||||
jwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
""
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="dashboard">
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Note>
|
||||
When using external signing the Auth service will be automatically disabled.
|
||||
</Note>
|
||||
|
||||
## Verify a JWT
|
||||
|
||||
|
||||
### Symmetric Keys
|
||||
|
||||
To verify a JWT signed with a symmetric key in a serverless function or third party service you can use code similar to the following:
|
||||
|
||||
```javascript
|
||||
import { Request, Response } from 'express'
|
||||
import process from 'process'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
const JWT_SECRET = process.env.HASURA_GRAPHQL_JWT_SECRET;
|
||||
|
||||
export default (req: Request, res: Response) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized: missing header' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
const verifyToken = new Promise((resolve, reject) => {
|
||||
const verifyOptions = {
|
||||
algorithms: ['HS256', 'HS384', 'HS512'],
|
||||
};
|
||||
|
||||
jwt.verify(token, JWT_SECRET, verifyOptions, (err, decoded) => {
|
||||
if (err) reject(err);
|
||||
else resolve(decoded);
|
||||
});
|
||||
});
|
||||
|
||||
verifyToken
|
||||
.then((decoded) => {
|
||||
res.status(200).json({
|
||||
token: decoded,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
res.status(401).json({ error: `Unauthorized: ${err}` });
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Keep in mind that you need access to the same key that was used to sign the JWT in order to verify it so this mechanism may not be suitable for all use cases.
|
||||
|
||||
### Asymmetric Keys
|
||||
|
||||
To verify a JWT signed with an asymmetric key you can leverage the JWKS endpoint that is automatically enabled in your project when you configure it to use asymmetric keys. The JWKS endpoint can be found at `https://<subdomain>.auth.<region>.nhost.run/v1/.well-known/jwks.json`. For instance:
|
||||
|
||||
```shell
|
||||
$ curl -s https://local.auth.local.nhost.run/v1/.well-known/jwks.json | jq
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"alg": "RS256",
|
||||
"e": "AQAB",
|
||||
"kid": "bskhwtelkajsd",
|
||||
"kty": "RSA",
|
||||
"n": "qSFS8Kx9LuiYpIms-NoZdSIcIgVp3z531bCSq1shx6ZqsKxHyNAjQ9vcYDBcW1gS1q0NFCDWyDLoNyd_lYUDlsc6zjXZAGyjiT1l_Qe9USHjXhT6Yv8SQlVbj8YCYPhYV9g6Bj922gXOmwXpWToHVYK5bjZmq897doksTErKiny6-FlPJvLVp3cpTFuNy6DKkZkIliuZnmf8EMFOVoFuQtNVlDZZZjk9TK9SP-qN1bvFPTdlCxdkA8ws8IkvhFivgfOflLRlzEE4fECEkaC3tZzGzjhPOmV5T8UC8eNz0Ir87nez8_fVyq61ffPkFftfGOjZ4hUfQqn-YW4sH_VTjw",
|
||||
"use": "sig"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Using the public key from the JWKS endpoint you can verify the JWT in a serverless function using code similar to the following:
|
||||
|
||||
```javascript
|
||||
import { Request, Response } from 'express'
|
||||
import process from 'process'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import jwksClient from 'jwks-rsa'
|
||||
|
||||
const subdomain = process.env.NHOST_SUBDOMAIN;
|
||||
const region = process.env.NHOST_REGION;
|
||||
|
||||
// Initialize the JWKS client
|
||||
const client = jwksClient({
|
||||
jwksUri: `https://${subdomain}.auth.${region}.nhost.run/v1/.well-known/jwks.json`,
|
||||
cache: true,
|
||||
cacheMaxAge: 86400000, // 24 hours cache
|
||||
});
|
||||
|
||||
export default (req: Request, res: Response) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized: missing header' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
const verifyToken = new Promise((resolve, reject) => {
|
||||
const verifyOptions = {
|
||||
algorithms: ['RS256', 'RS384', 'RS512'],
|
||||
};
|
||||
|
||||
jwt.verify(token, (header, callback) => {
|
||||
client.getSigningKey(header.kid, (err, key) => {
|
||||
if (err) return callback(err);
|
||||
callback(null, key.getPublicKey());
|
||||
});
|
||||
}, verifyOptions, (err, decoded) => {
|
||||
if (err) reject(err);
|
||||
else resolve(decoded);
|
||||
});
|
||||
});
|
||||
|
||||
verifyToken
|
||||
.then((decoded) => {
|
||||
res.status(200).json({
|
||||
token: decoded,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
res.status(401).json({ error: `Unauthorized: ${err}` });
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Claims
|
||||
|
||||
You can attach extra information to your JWTs in the form of custom claims. These claims can be used for authorization purposes in your application. For more details on how to add custom claims to your JWTs and how to use them, see the [Permissions Variables](/guides/api/permissions#permission-variables) documentation.
|
||||
93
docs/guides/auth/sign-in-idtokens.mdx
Normal file
93
docs/guides/auth/sign-in-idtokens.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Sign In with ID tokens
|
||||
sidebarTitle: IDTokens
|
||||
description: Learn about ID tokens
|
||||
icon: binary
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ID tokens are tokens provided by identity providers that contain authenticated user information and are specifically designed for authentication purposes, unlike access tokens which are used for authorization. ID tokens include claims about the user's identity, such as user ID, name, and email, along with metadata like token expiration time and intended audience.
|
||||
|
||||
ID tokens serve as a secure proof that a user has already been authenticated by a trusted identity provider. When someone logs in through their device's built-in authentication (like Sign in with Apple on iOS/macOS or Google Sign-in on Android), the system generates an ID token. This token can then be passed to your authentication service, confirming the user's identity without requiring them to log in again. This streamlined approach works with any OpenID Connect (OIDC) provider, including popular services like Google One Tap sign-in, making the authentication process both secure and user-friendly.
|
||||
|
||||
## Usage
|
||||
|
||||
To use ID tokens, you need to configure supported identity providers (currently [apple](/guides/auth/social/sign-in-apple) and [google](/guides/auth/social/sign-in-google)) and make sure the `audience` is set correctly.
|
||||
|
||||
### Sign in
|
||||
|
||||
Once everything is configured you can use an ID token to authenticate users with just a single call:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="javascript">
|
||||
```js
|
||||
nhost.auth.signInIdToken({
|
||||
provider: 'google', // The provider name, e.g., 'google', 'apple', etc.
|
||||
idToken: '...', // The ID token issued by the provider.
|
||||
nonce: '...' // Optional: The nonce used during token generation.
|
||||
})
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
{' '}
|
||||
<Tab title="react">See [react docs](/reference/react/use-sign-in-id-token) for details</Tab>
|
||||
|
||||
{' '}
|
||||
<Tab title="vue">See [vue docs](/reference/react/use-sign-in-id-token) for details</Tab>
|
||||
|
||||
<Tab title="dart">
|
||||
```dart
|
||||
nhost.auth.signInIdToken(provider: 'google', idToken: '...', nonce: '...');
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Link Provider to existing user
|
||||
|
||||
Similarly to the [Social Connect](/guides/auth/social-connect) feature, you can link an identity provider to an existing user:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="javascript">
|
||||
```js
|
||||
nhost.auth.linkIdToken({
|
||||
provider: 'google', // The provider name, e.g., 'google', 'apple', etc.
|
||||
idToken: '...', // The ID token issued by the provider.
|
||||
nonce: '...' // Optional: The nonce used during token generation.
|
||||
})
|
||||
```
|
||||
|
||||
{' '}
|
||||
<Note>Keep in mind this is an authenticated method so the user must be logged in already.</Note>
|
||||
|
||||
</Tab>
|
||||
|
||||
{' '}
|
||||
<Tab title="react">See [react docs](/reference/react/use-link-id-token) for details</Tab>
|
||||
|
||||
{' '}
|
||||
<Tab title="vue">See [vue docs](/reference/vue/use-link-id-token) for details</Tab>
|
||||
|
||||
<Tab title="dart">
|
||||
```dart
|
||||
nhost.auth.linkIdToken(provider: 'google', idToken: '...', nonce: '...');
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Examples
|
||||
|
||||
Below you can find some examples on how to extract an ID Token from various identity providers to be used with the Auth service. Keep in mind these are just some examples, use cases and sources are not limited to the examples below.
|
||||
|
||||
### React Native
|
||||
|
||||
#### Apple
|
||||
|
||||
For an example on how to authenticate using "Sign in with Apple" on iOS using React Native you can refer to our [sample component](https://github.com/nhost/nhost/blob/main/examples/react_native/src/components/SignInWithAppleButton.tsx).
|
||||
|
||||
#### Google
|
||||
|
||||
For an example on how to authenticate using "Sign in with Google" on Android using React Native you can refer to our [sample component](https://github.com/nhost/nhost/blob/main/examples/react_native/src/components/SignInWithGoogleButton.tsx).
|
||||
73
docs/guides/auth/sign-in-otp.mdx
Normal file
73
docs/guides/auth/sign-in-otp.mdx
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Sign In with One-Time Passwords
|
||||
sidebarTitle: One-Time Passwords
|
||||
description: Learn about One-Time Passwords
|
||||
icon: lock-hashtag
|
||||
---
|
||||
|
||||
One-Time Passwords (OTPs) are temporary codes for single use that can be delivered to users via email. OTPs expire after 5 minutes and can only be used once. OTPs can provide a more secure and convenient alternative to regular passwords.
|
||||
|
||||
To use One-Time Passwords, they need to be enabled in the configuration:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="nhost.toml">
|
||||
```toml
|
||||
[auth.method.otp.email]
|
||||
enabled = true
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Dashboard">
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
After the functionality has been enabled the flow is as follows:
|
||||
|
||||
1. User requests an OTP:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="javascript">
|
||||
```js
|
||||
nhost.auth.signInEmailOTP('user@example.com')
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="react">
|
||||
See [react docs](/reference/react/use-sign-in-email-otp) for details
|
||||
</Tab>
|
||||
|
||||
<Tab title="vue">
|
||||
See [vue docs](/reference/vue/use-sign-in-email-otp) for details
|
||||
</Tab>
|
||||
|
||||
<Tab title="dart">
|
||||
```dart
|
||||
nhost.auth.signInEmailOTP(email: "user@example.com");
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
2. User receives an email with the OTP
|
||||
3. User enters the OTP
|
||||
|
||||
<Tabs>
|
||||
<Tab title="javascript">
|
||||
```js
|
||||
nhost.auth.verifyEmailOTP('user@example.com', '123456')
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="react">
|
||||
See [react docs](/reference/react/use-sign-in-email-otp) for details
|
||||
</Tab>
|
||||
|
||||
<Tab title="vue">
|
||||
See [vue docs](/reference/vue/use-sign-in-email-otp) for details
|
||||
</Tab>
|
||||
|
||||
<Tab title="dart">
|
||||
```dart
|
||||
nhost.auth.verifyEmailOTP(email: "user@example.com", otp: "123456");
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
@@ -40,7 +40,7 @@ The domains in the URLs above will all return the IP address for localhost, `127
|
||||
local.auth.local.nhost.run has address 127.0.0.1
|
||||
```
|
||||
|
||||
However, those URLs are powered by a dynamic DNS that can return any IPv4 address you need, you just need to replace the subdomain `local` with a `subdomain` that contains the 4 octets of the IPv4 adress you want separated by `-`. For instance:
|
||||
However, those URLs are powered by a dynamic DNS that can return any IPv4 address you need, you just need to replace the subdomain `local` with a `subdomain` that contains the 4 octets of the IPv4 address you want separated by `-`. For instance:
|
||||
|
||||
```
|
||||
> host 192-168-100-1.auth.local.nhost.run
|
||||
@@ -52,6 +52,13 @@ However, those URLs are powered by a dynamic DNS that can return any IPv4 addres
|
||||
|
||||
This is useful if you need to connect to your environment from a different device, a VM or a mobile device emulator.
|
||||
|
||||
<Warning>
|
||||
Some ISPs filter DNS responses that point to localhost and/or private IP space. If your provider is one of them you may have troubles accessing your local dev environment. As a workaround you have two options:
|
||||
|
||||
1. Follow the instructions under [offline access](/guides/cli/subdomain#offline-access) to create static DNS entries in your machine.
|
||||
2. Configure your computer to use a different [DNS provider](https://privacysavvy.com/security/business/best-free-public-dns-servers/).
|
||||
</Warning>
|
||||
|
||||
To make use of this functionality you can start your development environment after setting the environment variable `NHOST_LOCAL_SUBDOMAIN` or passing the flag `--local-subdomain` :
|
||||
|
||||
```
|
||||
|
||||
@@ -4,6 +4,78 @@ description: List of available extensions with Nhost Postgres.
|
||||
icon: grid
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
In the table below you can find a list of available extensions with Nhost Postgres:
|
||||
|
||||
| name | version | comment |
|
||||
| -----------------------------|---------|--------------------------------------------------------------------------------------------------------------------- |
|
||||
| address_standardizer | 3.5.0 | Used to parse an address into constituent elements. Generally used to support geocoding address normalization step. |
|
||||
| address_standardizer_data_us | 3.5.0 | Address Standardizer US dataset example |
|
||||
| adminpack | 2.1 | administrative functions for PostgreSQL |
|
||||
| amcheck | 1.3 | functions for verifying relation integrity |
|
||||
| autoinc | 1.0 | functions for autoincrementing fields |
|
||||
| bloom | 1.0 | bloom access method - signature file based index |
|
||||
| btree_gin | 1.3 | support for indexing common datatypes in GIN |
|
||||
| btree_gist | 1.6 | support for indexing common datatypes in GiST |
|
||||
| citext | 1.6 | data type for case-insensitive character strings |
|
||||
| cube | 1.5 | data type for multidimensional cubes |
|
||||
| dblink | 1.2 | connect to other PostgreSQL databases from within a database |
|
||||
| dict_int | 1.0 | text search dictionary template for integers |
|
||||
| dict_xsyn | 1.0 | text search dictionary template for extended synonym processing |
|
||||
| earthdistance | 1.1 | calculate great-circle distances on the surface of the Earth |
|
||||
| file_fdw | 1.0 | foreign-data wrapper for flat file access |
|
||||
| fuzzystrmatch | 1.1 | determine similarities and distance between strings |
|
||||
| hstore | 1.8 | data type for storing sets of (key, value) pairs |
|
||||
| http | 1.6 | HTTP client for PostgreSQL, allows web page retrieval inside the database. |
|
||||
| hypopg | 1.4.1 | Hypothetical indexes for PostgreSQL |
|
||||
| insert_username | 1.0 | functions for tracking who changed a table |
|
||||
| intagg | 1.1 | integer aggregator and enumerator (obsolete) |
|
||||
| intarray | 1.5 | functions, operators, and index support for 1-D arrays of integers |
|
||||
| ip4r | 2.4 | |
|
||||
| isn | 1.2 | data types for international product numbering standards |
|
||||
| lo | 1.1 | Large Object maintenance |
|
||||
| ltree | 1.2 | data type for hierarchical tree-like structures |
|
||||
| moddatetime | 1.0 | functions for tracking last modification time |
|
||||
| old_snapshot | 1.0 | utilities in support of old_snapshot_threshold |
|
||||
| pageinspect | 1.9 | inspect the contents of database pages at a low level |
|
||||
| pg_buffercache | 1.3 | examine the shared buffer cache |
|
||||
| pg_cron | 1.6 | Job scheduler for PostgreSQL |
|
||||
| pg_freespacemap | 1.2 | examine the free space map (FSM) |
|
||||
| pg_hashids | 1.3 | pg_hashids |
|
||||
| pg_ivm | 1.9 | incremental view maintenance on PostgreSQL |
|
||||
| pg_prewarm | 1.2 | prewarm relation data |
|
||||
| pg_repack | 1.5.1 | Reorganize tables in PostgreSQL databases with minimal locks |
|
||||
| pg_squeeze | 1.7 | A tool to remove unused space from a relation. |
|
||||
| pg_stat_statements | 1.9 | track planning and execution statistics of all SQL statements executed |
|
||||
| pg_surgery | 1.0 | extension to perform surgery on a damaged relation |
|
||||
| pg_trgm | 1.6 | text similarity measurement and index searching based on trigrams |
|
||||
| pg_visibility | 1.2 | examine the visibility map (VM) and page-level visibility info |
|
||||
| pgcrypto | 1.3 | cryptographic functions |
|
||||
| pgrowlocks | 1.2 | show row-level locking information |
|
||||
| pgstattuple | 1.5 | show tuple-level statistics |
|
||||
| plpgsql | 1.0 | PL/pgSQL procedural language |
|
||||
| postgis | 3.5.0 | PostGIS geometry and geography spatial types and functions |
|
||||
| postgis_raster | 3.5.0 | PostGIS raster types and functions |
|
||||
| postgis_tiger_geocoder | 3.5.0 | PostGIS tiger geocoder and reverse geocoder |
|
||||
| postgis_topology | 3.5.0 | PostGIS topology spatial types and functions |
|
||||
| postgres_fdw | 1.1 | foreign-data wrapper for remote PostgreSQL servers |
|
||||
| refint | 1.0 | functions for implementing referential integrity (obsolete) |
|
||||
| seg | 1.4 | data type for representing line segments or floating-point intervals |
|
||||
| sslinfo | 1.2 | information about SSL certificates |
|
||||
| tablefunc | 1.0 | functions that manipulate whole tables, including crosstab |
|
||||
| tcn | 1.0 | Triggered change notifications |
|
||||
| timescaledb | 2.17.2 | Enables scalable inserts and complex queries for time-series data (Community Edition) |
|
||||
| tsm_system_rows | 1.0 | TABLESAMPLE method which accepts number of rows as a limit |
|
||||
| tsm_system_time | 1.0 | TABLESAMPLE method which accepts time in milliseconds as a limit |
|
||||
| unaccent | 1.1 | text search dictionary that removes accents |
|
||||
| uuid-ossp | 1.1 | generate universally unique identifiers (UUIDs) |
|
||||
| vector | 0.8.0 | vector data type and ivfflat and hnsw access methods |
|
||||
| xml2 | 1.1 | XPath querying and XSLT |
|
||||
|
||||
In addition, you can find more information about some of the extensions below
|
||||
|
||||
|
||||
## hypopg
|
||||
|
||||
HypoPG is a PostgreSQL extension adding support for hypothetical indexes.
|
||||
@@ -205,6 +277,31 @@ DROP EXTENSION pg_ivm;
|
||||
|
||||
- [GitHub](https://github.com/sraoss/pg_ivm)
|
||||
|
||||
## pg_repack
|
||||
|
||||
pg_repack is a PostgreSQL extension which lets you remove bloat from tables and indexes, and optionally restore the physical order of clustered indexes. Unlike CLUSTER and VACUUM FULL it works online, without holding an exclusive lock on the processed tables during processing. pg_repack is efficient to boot, with performance comparable to using CLUSTER directly.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```sql SQL
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION pg_repack;
|
||||
```
|
||||
In addition, you may need to configure the WAL level and replication slots. Check the official documentation for details.
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```sql SQL
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION pg_repack;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
- [GitHub](https://github.com/cybertec-postgresql/pg_squeeze)
|
||||
|
||||
## pg_squeeze
|
||||
|
||||
PostgreSQL extension that removes unused space from a table and optionally sorts tuples according to particular index (as if CLUSTER command was executed concurrently with regular reads / writes). In fact we try to replace pg_repack extension.
|
||||
|
||||
BIN
docs/images/guides/auth/jwt/asymmetric.png
Normal file
BIN
docs/images/guides/auth/jwt/asymmetric.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/images/guides/auth/jwt/external.png
Normal file
BIN
docs/images/guides/auth/jwt/external.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 994 KiB |
BIN
docs/images/guides/auth/jwt/symmetric.png
Normal file
BIN
docs/images/guides/auth/jwt/symmetric.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 984 KiB |
BIN
docs/images/guides/auth/sign-in-otp.png
Normal file
BIN
docs/images/guides/auth/sign-in-otp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1014 KiB |
126
docs/main.go
Normal file
126
docs/main.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const tpl = `---
|
||||
title: "%s"
|
||||
openapi: %s %s
|
||||
---`
|
||||
|
||||
const authReferencePath = "reference/auth"
|
||||
|
||||
type OpenAPIMinimal struct {
|
||||
Paths map[string]map[string]any
|
||||
}
|
||||
|
||||
type Endpoint struct {
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e Endpoint) Filepath() string {
|
||||
return authReferencePath + "/" + e.Method + strings.ReplaceAll(e.Path, "/", "-") + ".mdx"
|
||||
}
|
||||
|
||||
func (e Endpoint) Content() string {
|
||||
return fmt.Sprintf(tpl, e.Path, e.Method, e.Path)
|
||||
}
|
||||
|
||||
func (e Endpoint) Mintlify() string {
|
||||
return ` "` + strings.Replace(e.Filepath(), ".mdx", "", 1) + `",`
|
||||
}
|
||||
|
||||
type Endpoints []Endpoint
|
||||
|
||||
func (e Endpoints) Sort() {
|
||||
slices.SortFunc(e, func(a, b Endpoint) int {
|
||||
if a.Path == b.Path {
|
||||
return strings.Compare(a.Method, b.Method)
|
||||
}
|
||||
return strings.Compare(a.Path, b.Path)
|
||||
})
|
||||
}
|
||||
|
||||
func funcReadOpenAPIFile(filepath string) (*OpenAPIMinimal, error) {
|
||||
b, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
var oam *OpenAPIMinimal
|
||||
if err := yaml.Unmarshal(b, &oam); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal yaml: %w", err)
|
||||
}
|
||||
|
||||
return oam, nil
|
||||
}
|
||||
|
||||
func processOAMFiles(oam *OpenAPIMinimal) (Endpoints, error) {
|
||||
endpoints := make(Endpoints, 0, len(oam.Paths)*2)
|
||||
|
||||
for path, methods := range oam.Paths {
|
||||
for method := range methods {
|
||||
e := Endpoint{Method: method, Path: path}
|
||||
endpoints = append(endpoints, e)
|
||||
|
||||
if err := os.WriteFile(e.Filepath(), []byte(e.Content()), 0o644); err != nil {
|
||||
return nil, fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
func process() error {
|
||||
if err := os.RemoveAll(authReferencePath); err != nil {
|
||||
return fmt.Errorf("failed to remove directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Mkdir(authReferencePath, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
oam, err := funcReadOpenAPIFile("reference/openapi-auth.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read openapi-auth.yaml file: %w", err)
|
||||
}
|
||||
|
||||
endpoints, err := processOAMFiles(oam)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to process OAM files: %w", err)
|
||||
}
|
||||
|
||||
oamOld, err := funcReadOpenAPIFile("reference/openapi-auth-old.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read openapi-auth-old.yaml file: %w", err)
|
||||
}
|
||||
|
||||
endpointsOld, err := processOAMFiles(oamOld)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to process OAM files: %w", err)
|
||||
}
|
||||
|
||||
endpoints = append(endpoints, endpointsOld...)
|
||||
endpoints.Sort()
|
||||
|
||||
for _, e := range endpoints {
|
||||
fmt.Println(e.Mintlify())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := process(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
145
docs/mint.json
145
docs/mint.json
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/schema.json",
|
||||
"name": "Documentation",
|
||||
"openapi": ["reference/openapi-auth.yaml", "reference/openapi-storage.yaml"],
|
||||
"openapi": [
|
||||
"reference/openapi-auth.yaml",
|
||||
"reference/openapi-auth-old.yaml",
|
||||
"reference/openapi-storage.yaml"
|
||||
],
|
||||
"logo": {
|
||||
"dark": "/logo/dark.svg",
|
||||
"light": "/logo/light.svg"
|
||||
@@ -135,6 +139,7 @@
|
||||
"pages": [
|
||||
"guides/auth/overview",
|
||||
"guides/auth/users",
|
||||
"guides/auth/jwt",
|
||||
{
|
||||
"group": "Social Sign In",
|
||||
"icon": "at",
|
||||
@@ -153,9 +158,11 @@
|
||||
},
|
||||
"guides/auth/social-connect",
|
||||
"guides/auth/sign-in-email-password",
|
||||
"guides/auth/sign-in-otp",
|
||||
"guides/auth/sign-in-magic-link",
|
||||
"guides/auth/sign-in-phone-number",
|
||||
"guides/auth/sign-in-webauthn",
|
||||
"guides/auth/sign-in-idtokens",
|
||||
"guides/auth/elevated-permissions",
|
||||
"guides/auth/bot-protection",
|
||||
"guides/auth/email-templates",
|
||||
@@ -176,10 +183,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Functions",
|
||||
"pages": [
|
||||
"guides/functions/overview",
|
||||
"guides/functions/runtimes"
|
||||
]
|
||||
"pages": ["guides/functions/overview", "guides/functions/runtimes"]
|
||||
},
|
||||
{
|
||||
"group": "Run",
|
||||
@@ -213,76 +217,46 @@
|
||||
"group": "Authentication",
|
||||
"icon": "users",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Email and Password",
|
||||
"icon": "envelope",
|
||||
"pages": [
|
||||
"reference/auth/sign-up-email-and-password",
|
||||
"reference/auth/sign-in-email-and-password"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Passwordless",
|
||||
"icon": "message-sms",
|
||||
"pages": [
|
||||
"reference/auth/sign-in-email-passwordless",
|
||||
"reference/auth/sign-in-sms-passwordless",
|
||||
"reference/auth/sign-in-sms-passwordless-otp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "OAuth",
|
||||
"icon": "at",
|
||||
"pages": [
|
||||
"reference/auth/sign-in-oauth-provider",
|
||||
"reference/auth/oauth-callback-url-that-will-be-used-by-the-oauth-provider-to-redirect-to-the-client-application-attention:-all-providers-are-using-a-get-operation-except-apple-and-azure-ad-that-use-post"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "WebAuthn",
|
||||
"icon": "atom",
|
||||
"pages": [
|
||||
"reference/auth/sign-up-using-email-via-fido2-webauthn-authentication",
|
||||
"reference/auth/verfiy-fido2-webauthn-authentication-and-complete-signup",
|
||||
"reference/auth/sign-in-using-email-via-fido2-webauthn-authentication",
|
||||
"reference/auth/verfiy-fido2-webauthn-authentication-using-public-key-cryptography",
|
||||
"reference/auth/elevate-webauthn",
|
||||
"reference/auth/elevate-webauthn-verify"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Anonymous",
|
||||
"icon": "luchador-mask",
|
||||
"pages": [
|
||||
"reference/auth/sign-in-anonymous",
|
||||
"reference/auth/deanonymize-an-anonymous-user-in-adding-missing-email-or-email+password-depending-on-the-chosen-authentication-method-will-send-a-confirmation-email-if-the-server-is-configured-to-do-so"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "MFA",
|
||||
"icon": "message-sms",
|
||||
"pages": [
|
||||
"reference/auth/generate-a-secret-to-request-the-activation-of-time-based-one-time-password-totp-multi-factor-authentication",
|
||||
"reference/auth/sign-in-totp",
|
||||
"reference/auth/activatedeactivate-multi-factor-authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "User",
|
||||
"icon": "user",
|
||||
"pages": [
|
||||
"reference/auth/change-the-current-users-email",
|
||||
"reference/auth/send-an-email-asking-the-user-to-reset-their-password",
|
||||
"reference/auth/send-an-email-to-verify-the-account",
|
||||
"reference/auth/set-a-new-password",
|
||||
"reference/auth/get-user-information",
|
||||
"reference/auth/refresh-the-oauth-access-tokens-of-a-given-user-you-must-be-an-admin-to-perform-this-operation",
|
||||
"reference/auth/initialize-adding-of-a-new-webauthn-security-key-device-browser",
|
||||
"reference/auth/verfiy-adding-of-a-new-webauth-security-key-device-browser",
|
||||
"reference/auth/create-personal-access-token-pat"
|
||||
]
|
||||
},
|
||||
"reference/auth/sign-out"
|
||||
"reference/auth/get-.well-known-jwks.json",
|
||||
"reference/auth/post-elevate-webauthn",
|
||||
"reference/auth/post-elevate-webauthn-verify",
|
||||
"reference/auth/get-healthz",
|
||||
"reference/auth/head-healthz",
|
||||
"reference/auth/post-link-idtoken",
|
||||
"reference/auth/get-mfa-totp-generate",
|
||||
"reference/auth/post-pat",
|
||||
"reference/auth/post-signin-anonymous",
|
||||
"reference/auth/post-signin-email-password",
|
||||
"reference/auth/post-signin-idtoken",
|
||||
"reference/auth/post-signin-mfa-totp",
|
||||
"reference/auth/post-signin-otp-email",
|
||||
"reference/auth/post-signin-otp-email-verify",
|
||||
"reference/auth/post-signin-passwordless-email",
|
||||
"reference/auth/post-signin-passwordless-sms",
|
||||
"reference/auth/post-signin-passwordless-sms-otp",
|
||||
"reference/auth/post-signin-pat",
|
||||
"reference/auth/get-signin-provider-{provider}",
|
||||
"reference/auth/get-signin-provider-{provider}-callback",
|
||||
"reference/auth/post-signin-webauthn",
|
||||
"reference/auth/post-signin-webauthn-verify",
|
||||
"reference/auth/post-signout",
|
||||
"reference/auth/post-signup-email-password",
|
||||
"reference/auth/post-signup-webauthn",
|
||||
"reference/auth/post-signup-webauthn-verify",
|
||||
"reference/auth/post-token",
|
||||
"reference/auth/post-token-verify",
|
||||
"reference/auth/get-user",
|
||||
"reference/auth/post-user-deanonymize",
|
||||
"reference/auth/post-user-email-change",
|
||||
"reference/auth/post-user-email-send-verification-email",
|
||||
"reference/auth/post-user-mfa",
|
||||
"reference/auth/post-user-password",
|
||||
"reference/auth/post-user-password-reset",
|
||||
"reference/auth/post-user-provider-tokens",
|
||||
"reference/auth/post-user-webauthn-add",
|
||||
"reference/auth/post-user-webauthn-verify",
|
||||
"reference/auth/get-verify",
|
||||
"reference/auth/get-version"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -376,7 +350,11 @@
|
||||
"reference/javascript/auth/sign-up",
|
||||
"reference/javascript/auth/add-security-key",
|
||||
"reference/javascript/auth/elevate-email-security-key",
|
||||
"reference/javascript/auth/connect-provider"
|
||||
"reference/javascript/auth/connect-provider",
|
||||
"reference/javascript/auth/sign-in-email-otp",
|
||||
"reference/javascript/auth/verify-email-otp",
|
||||
"reference/javascript/auth/sign-in-id-token",
|
||||
"reference/javascript/auth/link-id-token"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -459,7 +437,10 @@
|
||||
"reference/react/use-user-id",
|
||||
"reference/react/use-user-is-anonymous",
|
||||
"reference/react/use-user-locale",
|
||||
"reference/react/use-user-roles"
|
||||
"reference/react/use-user-roles",
|
||||
"reference/react/use-sign-in-email-otp",
|
||||
"reference/react/use-sign-in-id-token",
|
||||
"reference/react/use-link-id-token"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -506,7 +487,10 @@
|
||||
"reference/nextjs/use-user-id",
|
||||
"reference/nextjs/use-user-is-anonymous",
|
||||
"reference/nextjs/use-user-locale",
|
||||
"reference/nextjs/use-user-roles"
|
||||
"reference/nextjs/use-user-roles",
|
||||
"reference/nextjs/use-sign-in-email-otp",
|
||||
"reference/nextjs/use-sign-in-id-token",
|
||||
"reference/nextjs/use-link-id-token"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -548,7 +532,10 @@
|
||||
"reference/vue/use-add-security-key",
|
||||
"reference/vue/use-elevate-security-key-email",
|
||||
"reference/vue/use-sign-in-email-security-key",
|
||||
"reference/vue/use-sign-up-email-security-key"
|
||||
"reference/vue/use-sign-up-email-security-key",
|
||||
"reference/vue/use-sign-in-email-otp",
|
||||
"reference/vue/use-sign-in-id-token",
|
||||
"reference/vue/use-link-id-token"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "2.21.0",
|
||||
"version": "2.24.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "mintlify dev"
|
||||
|
||||
@@ -13,13 +13,17 @@ Combined with a powerful **Permission Rules** system, Nhost Auth offers everythi
|
||||
<CardGroup cols={4}>
|
||||
<Card title="Email and Password" icon="square-1" href="../guides/auth/sign-in-email-password">
|
||||
</Card>
|
||||
<Card title="Magic Link" icon="square-2" href="../guides/auth/sign-in-magic-link">
|
||||
<Card title="One-Time Passwords (OTP)" icon="square-2" href="../guides/auth/sign-in-otp">
|
||||
</Card>
|
||||
<Card title="Phone Number (SMS)" icon="square-3" href="../guides/auth/sign-in-phone-number">
|
||||
<Card title="Magic Link" icon="square-3" href="../guides/auth/sign-in-magic-link">
|
||||
</Card>
|
||||
<Card title="Security Keys (WebAuthn)" icon="square-4" href="../guides/auth/sign-in-webauthn">
|
||||
<Card title="Phone Number (SMS)" icon="square-4" href="../guides/auth/sign-in-phone-number">
|
||||
</Card>
|
||||
<Card title="Elevated Permissions" icon="square-5" href="../guides/auth/elevated-permissions">
|
||||
<Card title="Security Keys (WebAuthn)" icon="square-5" href="../guides/auth/sign-in-webauthn">
|
||||
</Card>
|
||||
<Card title="ID Tokens" icon="square-6" href="../guides/auth/sign-in-idtokens">
|
||||
</Card>
|
||||
<Card title="Elevated Permissions" icon="square-7" href="../guides/auth/elevated-permissions">
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
4
docs/reference/auth/get-.well-known-jwks.json.mdx
Normal file
4
docs/reference/auth/get-.well-known-jwks.json.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "/.well-known/jwks.json"
|
||||
openapi: get /.well-known/jwks.json
|
||||
---
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
title: "/healthz"
|
||||
openapi: get /healthz
|
||||
---
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
title: "/mfa/totp/generate"
|
||||
openapi: get /mfa/totp/generate
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "/signin/provider/{provider}/callback"
|
||||
openapi: get /signin/provider/{provider}/callback
|
||||
sidebarTitle: Sign In Callback
|
||||
---
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "/signin/provider/{provider}"
|
||||
openapi: get /signin/provider/{provider}
|
||||
sidebarTitle: Sign In
|
||||
---
|
||||
---
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
title: "/user"
|
||||
openapi: get /user
|
||||
---
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
title: "/verify"
|
||||
openapi: get /verify
|
||||
---
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
title: "/version"
|
||||
openapi: get /version
|
||||
---
|
||||
4
docs/reference/auth/head-healthz.mdx
Normal file
4
docs/reference/auth/head-healthz.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "/healthz"
|
||||
openapi: head /healthz
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "/elevate/webauthn/verify"
|
||||
openapi: post /elevate/webauthn/verify
|
||||
sidebarTitle: Elevate Verify
|
||||
---
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "/elevate/webauthn"
|
||||
openapi: post /elevate/webauthn
|
||||
sidebarTitle: Elevate
|
||||
---
|
||||
---
|
||||
4
docs/reference/auth/post-link-idtoken.mdx
Normal file
4
docs/reference/auth/post-link-idtoken.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "/link/idtoken"
|
||||
openapi: post /link/idtoken
|
||||
---
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
title: "/pat"
|
||||
openapi: post /pat
|
||||
---
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
title: "/signin/anonymous"
|
||||
openapi: post /signin/anonymous
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "/signin/email-password"
|
||||
openapi: post /signin/email-password
|
||||
sidebarTitle: 'Sign In'
|
||||
---
|
||||
---
|
||||
4
docs/reference/auth/post-signin-idtoken.mdx
Normal file
4
docs/reference/auth/post-signin-idtoken.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "/signin/idtoken"
|
||||
openapi: post /signin/idtoken
|
||||
---
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
title: "/signin/mfa/totp"
|
||||
openapi: post /signin/mfa/totp
|
||||
---
|
||||
4
docs/reference/auth/post-signin-otp-email-verify.mdx
Normal file
4
docs/reference/auth/post-signin-otp-email-verify.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "/signin/otp/email/verify"
|
||||
openapi: post /signin/otp/email/verify
|
||||
---
|
||||
4
docs/reference/auth/post-signin-otp-email.mdx
Normal file
4
docs/reference/auth/post-signin-otp-email.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "/signin/otp/email"
|
||||
openapi: post /signin/otp/email
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "/signin/passwordless/email"
|
||||
openapi: post /signin/passwordless/email
|
||||
sidebarTitle: Sign In Email
|
||||
---
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "/signin/passwordless/sms/otp"
|
||||
openapi: post /signin/passwordless/sms/otp
|
||||
sidebarTitle: Sign In SMS Verify OTP
|
||||
---
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "/signin/passwordless/sms"
|
||||
openapi: post /signin/passwordless/sms
|
||||
sidebarTitle: Sign In SMS
|
||||
---
|
||||
---
|
||||
4
docs/reference/auth/post-signin-pat.mdx
Normal file
4
docs/reference/auth/post-signin-pat.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "/signin/pat"
|
||||
openapi: post /signin/pat
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "/signin/webauthn/verify"
|
||||
openapi: post /signin/webauthn/verify
|
||||
sidebarTitle: Sign In Verify
|
||||
---
|
||||
---
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user