Compare commits
130 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42bd7807b2 | ||
|
|
eea59bd202 | ||
|
|
7248eb733f | ||
|
|
fceb6a4a89 | ||
|
|
b10eca09a8 | ||
|
|
4799b65e96 | ||
|
|
067eb9d6a9 | ||
|
|
219d5ecdcf | ||
|
|
9073182d51 | ||
|
|
bdb5783e79 | ||
|
|
ece717d6e0 | ||
|
|
b135ef695c | ||
|
|
82b3353110 | ||
|
|
3f165a85e3 | ||
|
|
aa4018909f | ||
|
|
98397e3ccd | ||
|
|
911e7112c9 | ||
|
|
e62402ecfc | ||
|
|
9190dd726d | ||
|
|
ae093283d0 | ||
|
|
875327fbea | ||
|
|
3d5c34f4ce | ||
|
|
58c2a20532 | ||
|
|
6c90cb5024 | ||
|
|
7e37570587 | ||
|
|
87d225a840 | ||
|
|
7b0de27c80 | ||
|
|
564fc76195 | ||
|
|
2ed4f40c12 | ||
|
|
d67a023e21 | ||
|
|
c99d117d1c | ||
|
|
a497a6ba0a | ||
|
|
160cd08cc7 | ||
|
|
120151c40c | ||
|
|
9dc16f29b3 | ||
|
|
964fc5644a | ||
|
|
2f907fc68f | ||
|
|
fe6cadc2cd | ||
|
|
338c8e5a80 | ||
|
|
e6f3a1a39d | ||
|
|
a168faeb69 | ||
|
|
b1628c59b5 | ||
|
|
32a2f5db9a | ||
|
|
818a48f74d | ||
|
|
bed377d05f | ||
|
|
709a616cfa | ||
|
|
860e2d877c | ||
|
|
5c6b2f88b9 | ||
|
|
f151a0e872 | ||
|
|
4a84bbb410 | ||
|
|
fa3a50e323 | ||
|
|
398152358c | ||
|
|
34ae9046f3 | ||
|
|
a478689587 | ||
|
|
9dbc0607dc | ||
|
|
7455efdd53 | ||
|
|
d0aff6141f | ||
|
|
aed0c4f82a | ||
|
|
74d4276c1a | ||
|
|
1e98130aa1 | ||
|
|
52e9b510da | ||
|
|
ece197eb6b | ||
|
|
d14e112bff | ||
|
|
83884f04a5 | ||
|
|
977de21e86 | ||
|
|
462a60a8f8 | ||
|
|
9aa4371ef4 | ||
|
|
f0feddd83f | ||
|
|
0748cab125 | ||
|
|
27885491ee | ||
|
|
a36bdbf907 | ||
|
|
d3e8bb94ae | ||
|
|
645595ee43 | ||
|
|
4d82bc5609 | ||
|
|
fdf1e555d8 | ||
|
|
90c694cbba | ||
|
|
3262fa7b37 | ||
|
|
ab43fe567f | ||
|
|
b4c10f9f8a | ||
|
|
f4c6e7cfab | ||
|
|
72d1e94cb3 | ||
|
|
82d221a48d | ||
|
|
3fe46771b9 | ||
|
|
a1c487aa21 | ||
|
|
cf455608e2 | ||
|
|
5dac12dd41 | ||
|
|
2389b46e0d | ||
|
|
6fe2d22d0e | ||
|
|
0b439149e4 | ||
|
|
a9d7da8af7 | ||
|
|
3ecc21a45e | ||
|
|
aa19e85cdc | ||
|
|
26c650227d | ||
|
|
face99ccde | ||
|
|
49bcc525ad | ||
|
|
533563c893 | ||
|
|
cfe527307e | ||
|
|
1e36c6706d | ||
|
|
6e40b114fc | ||
|
|
77acf1385d | ||
|
|
cec7edd2d5 | ||
|
|
9dbbdb3121 | ||
|
|
79d2602648 | ||
|
|
b0363a4f4c | ||
|
|
17045b2018 | ||
|
|
c49cc11862 | ||
|
|
c83fe7d776 | ||
|
|
235b4c7405 | ||
|
|
c2c0fbd33a | ||
|
|
300e3f49e0 | ||
|
|
a95a77886b | ||
|
|
1f3f683202 | ||
|
|
4c67fd23c4 | ||
|
|
93d8d71e34 | ||
|
|
47bda15ff2 | ||
|
|
4563488b5d | ||
|
|
8fd35f3fea | ||
|
|
9c61c69a7b | ||
|
|
030ad4621e | ||
|
|
ee0b9b8edc | ||
|
|
c6fa8da6df | ||
|
|
dd9dedc226 | ||
|
|
5638a91240 | ||
|
|
cdefbdebee | ||
|
|
923abd3655 | ||
|
|
ef28540f9a | ||
|
|
d54e4cdd4e | ||
|
|
4a00963602 | ||
|
|
7ea9b890c8 | ||
|
|
f866120a65 |
@@ -26,10 +26,10 @@ runs:
|
||||
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-node-
|
||||
- name: Use Node.js v16
|
||||
- name: Use Node.js v18
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
- shell: bash
|
||||
name: Install packages
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
3
.github/workflows/changesets.yaml
vendored
3
.github/workflows/changesets.yaml
vendored
@@ -10,6 +10,7 @@ on:
|
||||
- '**.md'
|
||||
- '!.changeset/**'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
@@ -99,7 +100,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push to Docker Hub
|
||||
uses: docker/build-push-action@v4
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
with:
|
||||
context: .
|
||||
file: ./dashboard/Dockerfile
|
||||
|
||||
@@ -34,7 +34,7 @@ Nhost consists of open source software:
|
||||
- Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/)
|
||||
- Storage: [Hasura Storage](https://github.com/nhost/hasura-storage)
|
||||
- Serverless Functions: Node.js (JavaScript and TypeScript)
|
||||
- [Nhost CLI](https://docs.nhost.io/reference/cli) for local development
|
||||
- [Nhost CLI](https://docs.nhost.io/cli) for local development
|
||||
|
||||
## Architecture of Nhost
|
||||
|
||||
@@ -97,7 +97,7 @@ Nhost is frontend agnostic, which means Nhost works with all frontend frameworks
|
||||
|
||||
# Resources
|
||||
|
||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/reference/cli)
|
||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/cli)
|
||||
|
||||
## Nhost Clients
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import baseLibConfig from './vite.lib.config'
|
||||
export default defineConfig({
|
||||
...baseLibConfig,
|
||||
optimizeDeps: {
|
||||
include: ['react/jsx-runtime']
|
||||
include: ['react/jsx-runtime'],
|
||||
exclude: ['react-hook-form']
|
||||
},
|
||||
plugins: [react({ jsxRuntime: 'classic' }), ...baseLibConfig.plugins]
|
||||
})
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.20.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9073182d5: chore(dashboard): bump `turbo` to 1.10.11
|
||||
- ece717d6e: feat(logs): show services in the logs page
|
||||
- 82b335311: feat(metrics): change grafana link to point to the dashboards
|
||||
- b135ef695: fix(services): set command as optional and set min replicas to 0
|
||||
|
||||
## 0.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3d5c34f4c: fix(auth): fix users pagination limit
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c99d117d1: feat(services): add support for custom services
|
||||
|
||||
## 0.19.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- face99ccd: chore(deps): bump turbo version
|
||||
- cfe527307: style: tweak pull config warning in dark mode
|
||||
- a9d7da8af: chore(deps): update dependency @types/pluralize to ^0.0.30
|
||||
- 9aa4371ef: chore: add hasura-auth version 0.21.2
|
||||
- d14e112bf: chore(deps): update dependency prettier-plugin-tailwindcss to ^0.4.0
|
||||
- d3e8bb94a: chore(deps): update dependency vite-plugin-dts to v3
|
||||
|
||||
## 0.19.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.32
|
||||
- @nhost/nextjs@1.13.34
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9c61c69a7: chore(dashboard):add postgres 14.6-20230705-1 to the version selector
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 47bda15ff: feat(settings): add warning to pull config
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ee0b9b8ed: chore(dashboard):add hasura v2.28.2 and v2.29.0 to the version selector
|
||||
|
||||
## 0.17.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.31
|
||||
- @nhost/nextjs@1.13.33
|
||||
|
||||
## 0.17.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f866120a6: fix(users): use the password length from the config
|
||||
|
||||
## 0.17.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,7 @@ RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo@1.10.6
|
||||
RUN yarn global add turbo@1.10.11
|
||||
COPY . .
|
||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { openProject } from '@/e2e/utils';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
async function globalTeardown() {
|
||||
const browser = await chromium.launch();
|
||||
const browser = await chromium.launch({ slowMo: 1000 });
|
||||
|
||||
const context = await browser.newContext({
|
||||
baseURL: TEST_DASHBOARD_URL,
|
||||
@@ -46,18 +46,23 @@ async function globalTeardown() {
|
||||
await hasuraPage.locator('a', { hasText: /data/i }).click();
|
||||
await hasuraPage.getByRole('link', { name: /sql/i }).click();
|
||||
|
||||
await hasuraPage.locator('#raw_sql > textarea').fill(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
// Set the value of the Ace code editor using JavaScript evaluation in the browser context
|
||||
await hasuraPage.evaluate(() => {
|
||||
const editor = ace.edit('raw_sql');
|
||||
|
||||
editor.setValue(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
});
|
||||
|
||||
await hasuraPage.getByRole('button', { name: /run!/i }).click();
|
||||
await hasuraPage.getByText(/sql executed!/i).waitFor();
|
||||
|
||||
5
dashboard/hypertune.graphql
Normal file
5
dashboard/hypertune.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
query InitQuery {
|
||||
root {
|
||||
enableServices
|
||||
}
|
||||
}
|
||||
5
dashboard/hypertune.json
Normal file
5
dashboard/hypertune.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projectId": 2596,
|
||||
"token": "U2FsdGVkX19+V8BJnVR0xLEC+42OW5qZl/A0i6beAaRmJoIhFh5Yf6eIKBzLbV9h",
|
||||
"outputDirectoryPath": "src/hypertune"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.17.18",
|
||||
"version": "0.20.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -54,6 +54,7 @@
|
||||
"graphql-request": "^6.0.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.11.2",
|
||||
"hypertune": "^1.4.4",
|
||||
"just-kebab-case": "^4.1.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"next": "^12.3.1",
|
||||
@@ -71,6 +72,7 @@
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
"react-table": "^7.8.0",
|
||||
"sharp": "^0.32.0",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.5",
|
||||
"stripe": "^10.17.0",
|
||||
"tailwind-merge": "^1.8.0",
|
||||
@@ -101,12 +103,15 @@
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/ace": "^0.0.48",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/pluralize": "^0.0.30",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/react-table": "^7.7.12",
|
||||
"@types/shell-quote": "^1.7.1",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/validator": "^13.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
@@ -136,7 +141,7 @@
|
||||
"postcss": "^8.4.19",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.4.0",
|
||||
"react-date-fns-hooks": "^0.9.4",
|
||||
"require-from-string": "^2.0.2",
|
||||
"snake-case": "^3.0.4",
|
||||
|
||||
@@ -15,6 +15,7 @@ export type PaginationProps = DetailedHTMLProps<
|
||||
* Total number of pages.
|
||||
*/
|
||||
totalNrOfPages: number;
|
||||
|
||||
/**
|
||||
* Number of total elements per page.
|
||||
*/
|
||||
@@ -23,6 +24,10 @@ export type PaginationProps = DetailedHTMLProps<
|
||||
* Total number of elements.
|
||||
*/
|
||||
totalNrOfElements: number;
|
||||
/**
|
||||
* Label of the elements displayed ex: pages, users...
|
||||
*/
|
||||
itemsLabel: string;
|
||||
/**
|
||||
* Current page number.
|
||||
*/
|
||||
@@ -64,6 +69,7 @@ export default function Pagination({
|
||||
elementsPerPage,
|
||||
onPageChange,
|
||||
totalNrOfElements,
|
||||
itemsLabel,
|
||||
...props
|
||||
}: PaginationProps) {
|
||||
return (
|
||||
@@ -132,7 +138,7 @@ export default function Pagination({
|
||||
{totalNrOfElements < currentPageNumber * elementsPerPage
|
||||
? totalNrOfElements
|
||||
: currentPageNumber * elementsPerPage}{' '}
|
||||
of {totalNrOfElements} users
|
||||
of {totalNrOfElements} {itemsLabel}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function SettingsContainer({
|
||||
<Box
|
||||
{...root}
|
||||
className={twMerge(
|
||||
'grid grid-flow-row gap-4 rounded-lg border-1 py-4',
|
||||
'grid grid-flow-row gap-4 overflow-hidden rounded-lg border-1 py-4',
|
||||
root?.className || rootClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,11 @@ import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
|
||||
import { SettingsSidebar } from '@/components/layout/SettingsSidebar';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface SettingsLayoutProps extends ProjectLayoutProps {
|
||||
@@ -22,6 +26,10 @@ export default function SettingsLayout({
|
||||
sidebarProps: { className: sidebarClassName, ...sidebarProps } = {},
|
||||
...props
|
||||
}: SettingsLayoutProps) {
|
||||
const theme = useTheme();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const hasGitRepo = !!currentProject?.githubRepository;
|
||||
|
||||
return (
|
||||
<ProjectLayout
|
||||
mainContainerProps={{
|
||||
@@ -37,9 +45,46 @@ export default function SettingsLayout({
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-x-hidden"
|
||||
className="flex flex-col flex-auto w-full overflow-scroll overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
|
||||
<RetryableErrorBoundary>
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row gap-2 place-content-center"
|
||||
>
|
||||
<Text color="warning" className="text-sm ">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository and
|
||||
you only want these changes to apply to a subset of them, please
|
||||
check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/cli/overlays"
|
||||
>
|
||||
docs.nhost.io/cli/overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
</Box>
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
40
dashboard/src/components/ui/v2/icons/CubeIcon/CubeIcon.tsx
Normal file
40
dashboard/src/components/ui/v2/icons/CubeIcon/CubeIcon.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
|
||||
function CubeIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M14 11.0826V4.91742C14 4.8287 13.9764 4.74158 13.9316 4.665C13.8868 4.58841 13.8225 4.52513 13.7451 4.48163L8.24513 1.38788C8.17029 1.34578 8.08587 1.32367 8 1.32367C7.91413 1.32367 7.82971 1.34578 7.75487 1.38788L2.25487 4.48163C2.17754 4.52513 2.11318 4.58841 2.0684 4.665C2.02361 4.74158 2 4.8287 2 4.91742V11.0826C2 11.1713 2.02361 11.2584 2.0684 11.335C2.11318 11.4116 2.17754 11.4749 2.25487 11.5184L7.75487 14.6121C7.82971 14.6542 7.91413 14.6763 8 14.6763C8.08587 14.6763 8.17029 14.6542 8.24513 14.6121L13.7451 11.5184C13.8225 11.4749 13.8868 11.4116 13.9316 11.335C13.9764 11.2584 14 11.1713 14 11.0826Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.9311 4.66414L8.0594 8.00001L2.06934 4.66357"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.05916 8L8.00049 14.6763"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
CubeIcon.displayName = 'NhostCubeIcon';
|
||||
|
||||
export default CubeIcon;
|
||||
1
dashboard/src/components/ui/v2/icons/CubeIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/CubeIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CubeIcon } from './CubeIcon';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function ServicesIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Services"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.89295 4.15125H9.21701C9.28097 4.15125 9.33291 4.09959 9.33326 4.03565V2.8556C9.33291 2.79163 9.28097 2.73999 9.21701 2.73999H7.89295C7.82909 2.73999 7.77734 2.79174 7.77734 2.8556V4.03562C7.77734 4.09948 7.82911 4.15125 7.89295 4.15125ZM5.53406 5.84862H4.21001C4.14594 5.84826 4.09411 5.79643 4.09375 5.73236V4.55298C4.09411 4.48902 4.14606 4.43738 4.21001 4.43738H5.53406C5.5979 4.43738 5.64967 4.48912 5.64967 4.55298V5.73236C5.64967 5.79631 5.59801 5.84826 5.53406 5.84862ZM14.6307 6.48419C15.4316 6.48419 15.8114 6.77094 15.8521 6.80325L16 6.92016L15.9386 7.09971C15.8408 7.34738 15.69 7.57067 15.4968 7.75398C15.2062 8.04139 14.6791 8.38436 13.8221 8.38436H13.6839C13.337 9.26145 12.8707 10.2484 12.0879 11.1345C11.6196 11.6644 11.0689 12.1152 10.457 12.4696C9.71438 12.8901 8.90665 13.1835 8.06725 13.3376C7.4634 13.45 6.85036 13.5056 6.23616 13.5036C4.87658 13.5036 3.67717 13.2453 2.93893 12.7932C2.28012 12.3908 1.77374 11.7333 1.43337 10.8407C1.13576 10.0277 0.989105 9.1673 1.00063 8.30169C1.00204 8.04363 1.21146 7.83507 1.46954 7.83472H11.3503C11.471 7.8302 12.0678 7.77917 12.4399 7.57185C12.1318 7.08484 12.0446 6.51519 12.188 5.9087C12.2639 5.59123 12.3932 5.28898 12.5703 5.01479L12.7118 4.81068L12.9268 4.93471L12.9269 4.93473C12.9668 4.9583 13.8447 5.47632 13.9996 6.53843C14.2082 6.50325 14.4192 6.48511 14.6307 6.48419ZM3.7092 7.54529H2.38514C2.32128 7.54529 2.26953 7.49353 2.26953 7.42967V6.25029V6.24964C2.26953 6.1858 2.32128 6.13403 2.38514 6.13403H3.7092H3.70985C3.77369 6.13439 3.82516 6.18643 3.8248 6.25029V7.42969C3.8248 7.49353 3.77306 7.54529 3.7092 7.54529ZM4.21003 7.54529H5.53409C5.59794 7.54529 5.64969 7.49353 5.64969 7.42969V6.25029C5.65005 6.18643 5.59858 6.13439 5.53472 6.13403H5.53407H4.21001C4.14579 6.13403 4.09375 6.18607 4.09375 6.25029V7.42967C4.09413 7.49363 4.14606 7.54529 4.21003 7.54529ZM7.38597 7.54529H6.06191C5.99808 7.54529 5.94631 7.49353 5.94629 7.42967V6.25029V6.24964C5.94629 6.1858 5.99803 6.13403 6.06189 6.13403H7.38595H7.3866C7.45046 6.13439 7.50193 6.18643 7.50157 6.25029V7.42969C7.50157 7.49353 7.44983 7.54529 7.38597 7.54529ZM7.89295 7.54529H9.21701C9.28097 7.54529 9.33291 7.49365 9.33326 7.42969V6.25029C9.33326 6.18607 9.28122 6.13403 9.21701 6.13403H7.89295C7.82909 6.13403 7.77734 6.1858 7.77734 6.24964V6.25029V7.42967C7.77734 7.49353 7.82911 7.54529 7.89295 7.54529ZM6.06189 5.84862H7.38595C7.4499 5.84826 7.50156 5.79631 7.50156 5.73236V4.55298C7.50156 4.48912 7.44979 4.43738 7.38595 4.43738H6.06189C5.99804 4.43738 5.94629 4.48915 5.94629 4.55298V5.73236C5.94629 5.79631 5.99795 5.84826 6.06189 5.84862ZM9.21701 5.84862H7.89295C7.82901 5.84826 7.77734 5.79631 7.77734 5.73236V4.55298C7.77734 4.48915 7.82909 4.43738 7.89295 4.43738H9.21701C9.28097 4.43738 9.33291 4.48902 9.33326 4.55298V5.73236C9.33291 5.79643 9.28108 5.84826 9.21701 5.84862ZM11.0637 7.54529H9.73963C9.67579 7.54529 9.62402 7.49353 9.62402 7.42967V6.25029V6.24964C9.62402 6.1858 9.67579 6.13403 9.73963 6.13403H11.0637C11.1279 6.13403 11.1799 6.18607 11.1799 6.25029V7.42969C11.1796 7.49365 11.1277 7.54529 11.0637 7.54529Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ServicesIcon.displayName = 'NhostServicesIcon';
|
||||
|
||||
export default ServicesIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServicesIcon } from './ServicesIcon';
|
||||
@@ -29,6 +29,7 @@ export type AuthServiceVersionFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
const AVAILABLE_AUTH_VERSIONS = [
|
||||
'0.21.2',
|
||||
'0.20.1',
|
||||
'0.20.0',
|
||||
'0.19.3',
|
||||
|
||||
@@ -3,12 +3,16 @@ 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 type { DialogFormProps } from '@/types/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import { useUpdateRemoteAppUserMutation } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { useState } from 'react';
|
||||
@@ -27,19 +31,6 @@ export interface EditUserPasswordFormProps extends DialogFormProps {
|
||||
user: RemoteAppGetUsersQuery['users'][0];
|
||||
}
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
password: Yup.string()
|
||||
.label('Users Password')
|
||||
.min(8, 'Password must be at least 8 characters long.')
|
||||
.required('This field is required.'),
|
||||
cpassword: Yup.string()
|
||||
.required('Confirm Password is required')
|
||||
.min(8, 'Password must be at least 8 characters long.')
|
||||
.oneOf([Yup.ref('password')], 'Passwords do not match'),
|
||||
});
|
||||
|
||||
export type EditUserPasswordFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function EditUserPasswordForm({
|
||||
onCancel,
|
||||
user,
|
||||
@@ -49,26 +40,52 @@ export default function EditUserPasswordForm({
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
const { closeDialog } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { data } = useGetSignInMethodsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
skip: !currentProject?.id,
|
||||
});
|
||||
|
||||
const passwordMinLength =
|
||||
data?.config?.auth?.method?.emailPassword?.passwordMinLength || 1;
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
password: Yup.string()
|
||||
.label('Password')
|
||||
.min(
|
||||
passwordMinLength,
|
||||
`Password must be at least ${passwordMinLength} characters long.`,
|
||||
)
|
||||
.required('This field is required.'),
|
||||
cpassword: Yup.string()
|
||||
.label('Password Confirmation')
|
||||
.min(
|
||||
passwordMinLength,
|
||||
`Password must be at least ${passwordMinLength} characters long.`,
|
||||
)
|
||||
.oneOf([Yup.ref('password')], 'Passwords do not match')
|
||||
.required('This field is required.'),
|
||||
});
|
||||
|
||||
const [editUserPasswordFormError, setEditUserPasswordFormError] =
|
||||
useState<Error | null>(null);
|
||||
|
||||
const form = useForm<EditUserPasswordFormValues>({
|
||||
const form = useForm<Yup.InferType<typeof validationSchema>>({
|
||||
defaultValues: {},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const handleSubmit = async ({ password }: EditUserPasswordFormValues) => {
|
||||
const handleSubmit = async ({
|
||||
password,
|
||||
}: Yup.InferType<typeof validationSchema>) => {
|
||||
setEditUserPasswordFormError(null);
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const updateUserPasswordPromise = updateUser({
|
||||
variables: {
|
||||
id: user.id,
|
||||
user: {
|
||||
passwordHash,
|
||||
},
|
||||
user: { passwordHash },
|
||||
},
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export type DatabaseServiceVersionFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
const AVAILABLE_POSTGRES_VERSIONS = [
|
||||
'14.6-20230705-1',
|
||||
'14.6-20230613-1',
|
||||
'14.6-20230525',
|
||||
'14.6-20230406-2',
|
||||
|
||||
@@ -31,6 +31,8 @@ export type HasuraServiceVersionFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
const AVAILABLE_HASURA_VERSIONS = [
|
||||
'v2.29.0-ce',
|
||||
'v2.28.2-ce',
|
||||
'v2.27.0-ce',
|
||||
'v2.25.1-ce',
|
||||
'v2.25.0-ce',
|
||||
|
||||
@@ -8,11 +8,13 @@ import { GraphQLIcon } from '@/components/ui/v2/icons/GraphQLIcon';
|
||||
import { HasuraIcon } from '@/components/ui/v2/icons/HasuraIcon';
|
||||
import { HomeIcon } from '@/components/ui/v2/icons/HomeIcon';
|
||||
import { RocketIcon } from '@/components/ui/v2/icons/RocketIcon';
|
||||
import { ServicesIcon } from '@/components/ui/v2/icons/ServicesIcon';
|
||||
import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
import type { SvgIconProps } from '@/components/ui/v2/icons/SvgIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useHypertune } from '@/hooks/useHypertune';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export interface ProjectRoute {
|
||||
@@ -56,8 +58,26 @@ export interface ProjectRoute {
|
||||
export default function useProjectRoutes() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, loading: currentProjectLoading } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const {
|
||||
currentWorkspace,
|
||||
currentProject,
|
||||
loading: currentProjectLoading,
|
||||
} = useCurrentWorkspaceAndProject();
|
||||
|
||||
const hypertune = useHypertune();
|
||||
|
||||
const enableServices =
|
||||
currentWorkspace &&
|
||||
hypertune
|
||||
.root({
|
||||
context: {
|
||||
workSpace: {
|
||||
id: currentWorkspace.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.enableServices({})
|
||||
.get(false);
|
||||
|
||||
const nhostRoutes: ProjectRoute[] = [
|
||||
{
|
||||
@@ -98,7 +118,7 @@ export default function useProjectRoutes() {
|
||||
},
|
||||
];
|
||||
|
||||
const allRoutes: ProjectRoute[] = [
|
||||
let allRoutes: ProjectRoute[] = [
|
||||
{
|
||||
relativePath: '/',
|
||||
exact: true,
|
||||
@@ -136,9 +156,19 @@ export default function useProjectRoutes() {
|
||||
label: 'Storage',
|
||||
icon: <StorageIcon />,
|
||||
},
|
||||
...nhostRoutes,
|
||||
];
|
||||
|
||||
if (enableServices) {
|
||||
allRoutes.push({
|
||||
relativePath: '/services',
|
||||
exact: false,
|
||||
label: 'Run',
|
||||
icon: <ServicesIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
allRoutes = [...allRoutes, ...nhostRoutes];
|
||||
|
||||
return {
|
||||
nhostRoutes,
|
||||
allRoutes,
|
||||
|
||||
@@ -69,7 +69,7 @@ test('should generate a per service subdomain in remote mode', () => {
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'grafana')).toBe(
|
||||
'https://test.grafana.eu-west-1.nhost.run',
|
||||
'https://test.grafana.eu-west-1.nhost.run/dashboards',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ test('should generate staging subdomains in staging environment', () => {
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', stagingRegion, 'grafana')).toBe(
|
||||
'https://test.grafana.eu-west-1.staging.nhost.run',
|
||||
'https://test.grafana.eu-west-1.staging.nhost.run/dashboards',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ test('should generate no slug for Hasura and Grafana neither in local mode nor i
|
||||
'https://test.hasura.eu-west-1.staging.nhost.run',
|
||||
);
|
||||
expect(generateAppServiceUrl('test', stagingRegion, 'grafana')).toBe(
|
||||
'https://test.grafana.eu-west-1.staging.nhost.run',
|
||||
'https://test.grafana.eu-west-1.staging.nhost.run/dashboards',
|
||||
);
|
||||
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
@@ -129,7 +129,7 @@ test('should generate no slug for Hasura and Grafana neither in local mode nor i
|
||||
'https://test.hasura.eu-west-1.nhost.run',
|
||||
);
|
||||
expect(generateAppServiceUrl('test', region, 'grafana')).toBe(
|
||||
'https://test.grafana.eu-west-1.nhost.run',
|
||||
'https://test.grafana.eu-west-1.nhost.run/dashboards',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -102,5 +102,11 @@ export default function generateAppServiceUrl(
|
||||
.filter(Boolean)
|
||||
.join('.');
|
||||
|
||||
return `https://${constructedDomain}${remoteBackendSlugs[service]}`;
|
||||
let url = `https://${constructedDomain}${remoteBackendSlugs[service]}`;
|
||||
|
||||
if (service === 'grafana') {
|
||||
url = `${url}/dashboards`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { LogsCustomInterval } from '@/features/projects/logs/utils/constant
|
||||
import { LOGS_AVAILABLE_INTERVALS } from '@/features/projects/logs/utils/constants/intervals';
|
||||
import type { AvailableLogsService } from '@/features/projects/logs/utils/constants/services';
|
||||
import { LOGS_AVAILABLE_SERVICES } from '@/features/projects/logs/utils/constants/services';
|
||||
import { useGetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
import { subMinutes } from 'date-fns';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -132,6 +133,35 @@ export default function LogsHeader({
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const applicationCreationDate = new Date(currentProject.createdAt);
|
||||
|
||||
const [runServices, setRunServices] = useState<
|
||||
{
|
||||
label: string;
|
||||
value: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const { data, loading } = useGetRunServicesQuery({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
resolve: false,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
const services = data.app?.runServices ?? [];
|
||||
|
||||
setRunServices(
|
||||
services.map((s) => ({
|
||||
label: s.config.name,
|
||||
value: s.config.name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [loading, data]);
|
||||
|
||||
/**
|
||||
* Will subtract the `customInterval` time in minutes from the current date.
|
||||
*/
|
||||
@@ -181,15 +211,17 @@ export default function LogsHeader({
|
||||
root: { className: 'min-h-[initial] h-9 leading-[initial]' },
|
||||
}}
|
||||
>
|
||||
{LOGS_AVAILABLE_SERVICES.map(({ value, label }) => (
|
||||
<Option
|
||||
key={value}
|
||||
value={value}
|
||||
className="text-sm+ font-medium"
|
||||
>
|
||||
{label}
|
||||
</Option>
|
||||
))}
|
||||
{[...LOGS_AVAILABLE_SERVICES, ...runServices].map(
|
||||
({ value, label }) => (
|
||||
<Option
|
||||
key={value}
|
||||
value={value}
|
||||
className="text-sm+ font-medium"
|
||||
>
|
||||
{label}
|
||||
</Option>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -48,6 +48,21 @@ export const MIN_SERVICE_VCPU = 0.25 * RESOURCE_VCPU_MULTIPLIER;
|
||||
*/
|
||||
export const MAX_SERVICE_VCPU = 7 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* Best resource utilization ration for CPU-Memory.
|
||||
*/
|
||||
export const MEM_CPU_RATIO = 2.048;
|
||||
|
||||
/**
|
||||
* Minimum storage capacity (Gib)
|
||||
*/
|
||||
export const MIN_STORAGE_CAPACITY = 1;
|
||||
|
||||
/**
|
||||
* Maximum storage capacity (Gib)
|
||||
*/
|
||||
export const MAX_STORAGE_CAPACITY = 1000;
|
||||
|
||||
/**
|
||||
* The minimum amount of memory that has to be allocated per service.
|
||||
*/
|
||||
@@ -135,3 +150,8 @@ export const resourceSettingsValidationSchema = Yup.object({
|
||||
export type ResourceSettingsFormValues = Yup.InferType<
|
||||
typeof resourceSettingsValidationSchema
|
||||
>;
|
||||
|
||||
export const MIN_SERVICES_CPU = Math.floor(128 / MEM_CPU_RATIO);
|
||||
export const MIN_SERVICES_MEM = 128;
|
||||
export const MAX_SERVICES_CPU = 7000;
|
||||
export const MAX_SERVICES_MEM = Math.floor(MAX_SERVICES_CPU * MEM_CPU_RATIO);
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import {
|
||||
MAX_SERVICES_CPU,
|
||||
MAX_SERVICES_MEM,
|
||||
MAX_SERVICE_REPLICAS,
|
||||
MIN_SERVICES_CPU,
|
||||
MIN_SERVICES_MEM,
|
||||
} from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import { ComputeFormSection } from '@/features/services/components/ServiceForm/components/ComputeFormSection';
|
||||
import { EnvironmentFormSection } from '@/features/services/components/ServiceForm/components/EnvironmentFormSection';
|
||||
import { PortsFormSection } from '@/features/services/components/ServiceForm/components/PortsFormSection';
|
||||
import { ReplicasFormSection } from '@/features/services/components/ServiceForm/components/ReplicasFormSection';
|
||||
import { StorageFormSection } from '@/features/services/components/ServiceForm/components/StorageFormSection';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useInsertRunServiceMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { parse } from 'shell-quote';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export enum PortTypes {
|
||||
HTTP = 'http',
|
||||
TCP = 'tcp',
|
||||
UDP = 'udp',
|
||||
}
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
name: Yup.string().required('The name is required.'),
|
||||
image: Yup.string().label('Image to run'),
|
||||
command: Yup.string(),
|
||||
environment: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
value: Yup.string().required(),
|
||||
}),
|
||||
),
|
||||
compute: Yup.object({
|
||||
cpu: Yup.number().min(MIN_SERVICES_CPU).max(MAX_SERVICES_CPU).required(),
|
||||
memory: Yup.number().min(MIN_SERVICES_MEM).max(MAX_SERVICES_MEM).required(),
|
||||
}),
|
||||
replicas: Yup.number().min(0).max(MAX_SERVICE_REPLICAS).required(),
|
||||
ports: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
port: Yup.number().required(),
|
||||
type: Yup.mixed<PortTypes>().oneOf(Object.values(PortTypes)).required(),
|
||||
publish: Yup.boolean().default(false),
|
||||
}),
|
||||
),
|
||||
storage: Yup.array().of(
|
||||
Yup.object()
|
||||
.shape({
|
||||
name: Yup.string().required(),
|
||||
path: Yup.string().required(),
|
||||
capacity: Yup.number().nonNullable().required(),
|
||||
})
|
||||
.required(),
|
||||
),
|
||||
});
|
||||
|
||||
export type ServiceFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export interface ServiceFormProps extends DialogFormProps {
|
||||
/**
|
||||
* To use in conjunction with initialData to allow for updating the service
|
||||
*/
|
||||
serviceID?: string;
|
||||
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: ServiceFormValues;
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the submit is successful.
|
||||
*/
|
||||
onSubmit?: VoidFunction | ((args?: any) => Promise<any>);
|
||||
}
|
||||
|
||||
export default function ServiceForm({
|
||||
serviceID,
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
location,
|
||||
}: ServiceFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const [insertRunService] = useInsertRunServiceMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation();
|
||||
|
||||
const [createServiceFormError, setCreateServiceFormError] =
|
||||
useState<Error | null>(null);
|
||||
|
||||
const form = useForm<ServiceFormValues>({
|
||||
defaultValues: initialData ?? {
|
||||
compute: {
|
||||
cpu: 62,
|
||||
memory: 128,
|
||||
},
|
||||
replicas: 1,
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
watch,
|
||||
register,
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
} = form;
|
||||
|
||||
const serviceImage = watch('image');
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateService = async (values: ServiceFormValues) => {
|
||||
const config: ConfigRunServiceConfigInsertInput = {
|
||||
name: values.name,
|
||||
image: {
|
||||
image: values.image,
|
||||
},
|
||||
command: parse(values.command).map((item) => item.toString()),
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: values.compute.cpu,
|
||||
memory: values.compute.memory,
|
||||
},
|
||||
storage: values.storage.map((item) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
capacity: item.capacity,
|
||||
})),
|
||||
replicas: values.replicas,
|
||||
},
|
||||
environment: values.environment.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
})),
|
||||
ports: values.ports.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type,
|
||||
publish: item.publish,
|
||||
})),
|
||||
};
|
||||
|
||||
if (initialData) {
|
||||
// Update service config
|
||||
await replaceRunServiceConfig({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
serviceID,
|
||||
config,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Insert service config
|
||||
const {
|
||||
data: {
|
||||
insertRunService: { id: newServiceID },
|
||||
},
|
||||
} = await insertRunService({
|
||||
variables: {
|
||||
object: {
|
||||
appID: currentProject.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await insertRunServiceConfig({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
serviceID: newServiceID,
|
||||
config: {
|
||||
...config,
|
||||
image: {
|
||||
// If the image field left empty then we auto-populate following this format
|
||||
// registry.<region>.<nhost_domain>/<service_id>
|
||||
image:
|
||||
values.image.length > 0
|
||||
? values.image
|
||||
: `registry.${currentProject.region.awsName}.${currentProject.region.domain}/${newServiceID}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: ServiceFormValues) => {
|
||||
try {
|
||||
await toast.promise(
|
||||
createOrUpdateService(values),
|
||||
{
|
||||
loading: 'Configuring the service...',
|
||||
success: `The service has been configured successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while configuring the service. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
// await refetchWorkspaceAndProject();
|
||||
// refestch the services
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-flow-row gap-4 px-6 pb-6"
|
||||
>
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Name</Text>
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="Service name"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('image')}
|
||||
id="image"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Image</Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Image to use, it can be hosted on any public registry or it
|
||||
can use the{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/registry"
|
||||
className="underline"
|
||||
>
|
||||
Nhost registry
|
||||
</a>
|
||||
. Image needs to support arm.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="To automatically fill the private registry, leave it blank."
|
||||
hideEmptyHelperText
|
||||
error={!!errors.image}
|
||||
helperText={errors?.image?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{/* This shows only when trying to edit a service */}
|
||||
{serviceID && serviceImage && (
|
||||
<InfoCard
|
||||
title="Private registry"
|
||||
value={`registry.${currentProject.region.awsName}.${currentProject.region.domain}/${serviceID}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
{...register('command')}
|
||||
id="command"
|
||||
label={
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text>Command</Text>
|
||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
placeholder="$ npm start"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.command}
|
||||
helperText={errors?.command?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<ComputeFormSection />
|
||||
|
||||
<ReplicasFormSection />
|
||||
|
||||
<EnvironmentFormSection />
|
||||
|
||||
<PortsFormSection />
|
||||
|
||||
<StorageFormSection />
|
||||
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setCreateServiceFormError(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{initialData ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowLeftIcon } from '@/components/ui/v2/icons/ArrowLeftIcon';
|
||||
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Slider } from '@/components/ui/v2/Slider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import {
|
||||
MAX_SERVICES_MEM,
|
||||
MEM_CPU_RATIO,
|
||||
MIN_SERVICES_MEM,
|
||||
} from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function ComputeFormSection() {
|
||||
const { setValue } = useFormContext<ServiceFormValues>();
|
||||
|
||||
const formValues = useWatch<ServiceFormValues>();
|
||||
|
||||
const handleSliderUpdate = (value: string) => {
|
||||
const updatedMem = parseFloat(value);
|
||||
|
||||
if (Number.isNaN(updatedMem) || updatedMem < MIN_SERVICES_MEM) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('compute.memory', Math.floor(updatedMem), { shouldDirty: true });
|
||||
setValue('compute.cpu', Math.floor(updatedMem / MEM_CPU_RATIO), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
const incrementCompute = () => {
|
||||
const newMemoryValue = formValues.compute.memory + 128;
|
||||
setValue('compute.memory', newMemoryValue);
|
||||
setValue('compute.cpu', Math.floor(newMemoryValue / MEM_CPU_RATIO));
|
||||
};
|
||||
|
||||
const decrementCompute = () => {
|
||||
const newMemoryValue = formValues.compute.memory - 128;
|
||||
setValue('compute.memory', newMemoryValue);
|
||||
setValue('compute.cpu', Math.floor(newMemoryValue / MEM_CPU_RATIO));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
CPU: {formValues.compute.cpu} / Memory: {formValues.compute.memory}
|
||||
</Text>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Compute resources dedicated for the service. Refer to{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/resources"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-row items-center justify-between space-x-4">
|
||||
<Button
|
||||
disabled={formValues.compute.memory <= MIN_SERVICES_MEM}
|
||||
variant="outlined"
|
||||
onClick={decrementCompute}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Slider
|
||||
value={Number(formValues.compute.memory)}
|
||||
onChange={(_event, value) => handleSliderUpdate(value.toString())}
|
||||
max={MAX_SERVICES_MEM}
|
||||
min={MIN_SERVICES_MEM}
|
||||
step={256}
|
||||
aria-label="Compute resources"
|
||||
marks
|
||||
/>
|
||||
<Button
|
||||
disabled={formValues.compute.memory >= MAX_SERVICES_MEM}
|
||||
variant="outlined"
|
||||
onClick={incrementCompute}
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ComputeFormSection } from './ComputeFormSection';
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function EnvironmentFormSection() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'environment',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Environment
|
||||
</Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Environment variables to add to the service. Other than the ones
|
||||
specified here only <code>NHOST_SUBDOMAIN</code> and{' '}
|
||||
<code>NHOST_REGION</code> are added automatically to the
|
||||
service.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => append({ name: '', value: '' })}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box
|
||||
key={field.id}
|
||||
className="flex w-full flex-col space-y-2 xs+:flex-row xs+:space-y-0 xs+:space-x-2"
|
||||
>
|
||||
<Input
|
||||
{...register(`environment.${index}.name`)}
|
||||
id={`${field.id}-name`}
|
||||
label={!index && 'Name'}
|
||||
placeholder={`Key ${index}`}
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.environment?.at(index)}
|
||||
helperText={errors?.environment?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
{...register(`environment.${index}.value`)}
|
||||
id={`${field.id}-value`}
|
||||
label={!index && 'Value'}
|
||||
placeholder={`Value ${index}`}
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.environment?.at(index)}
|
||||
helperText={errors?.environment?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className=""
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
export { default as EnvironmentFormSection } from './EnvironmentFormSection';
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ControlledSwitch } from '@/components/form/ControlledSwitch';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Select } from '@/components/ui/v2/Select';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import {
|
||||
PortTypes,
|
||||
type ServiceFormValues,
|
||||
} from '@/features/services/components/ServiceForm';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function PortsFormSection() {
|
||||
const form = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'ports',
|
||||
});
|
||||
|
||||
const formValues = useWatch<ServiceFormValues>();
|
||||
|
||||
const onChangePortType = (value: string | undefined, index: number) =>
|
||||
setValue(`ports.${index}.type`, value as PortTypes);
|
||||
|
||||
const showURL = (index: number) =>
|
||||
formValues.ports[index]?.type === PortTypes.HTTP &&
|
||||
formValues.ports[index]?.publish;
|
||||
|
||||
const getPortURL = (_port: string | number, _name: string) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
const name = _name && _name.length > 0 ? _name : '[name]';
|
||||
|
||||
return `https://${currentProject.subdomain}-${name}-${port}.svc.${currentProject.region.awsName}.${currentProject.region.domain}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Ports
|
||||
</Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Network ports to configure for the service. Refer to{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/networking"
|
||||
className="underline"
|
||||
>
|
||||
Networking
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => append({ port: null, type: null, publish: false })}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box key={field.id} className="flex flex-col space-y-2">
|
||||
<Box className="flex w-full flex-col space-y-2 xs+:flex-row xs+:space-x-2 xs+:space-y-0">
|
||||
<Input
|
||||
{...register(`ports.${index}.port`)}
|
||||
id={`${field.id}-port`}
|
||||
placeholder="Port"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.ports?.at(index)}
|
||||
helperText={errors?.ports?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Select
|
||||
fullWidth
|
||||
value={formValues.ports.at(index)?.type || ''}
|
||||
onChange={(_event, inputValue) =>
|
||||
onChangePortType(inputValue as string, index)
|
||||
}
|
||||
placeholder="Select port type"
|
||||
slotProps={{
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{['http', 'tcp', 'udp']?.map((portType) => (
|
||||
<Option key={portType} value={portType}>
|
||||
{portType}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<ControlledSwitch
|
||||
{...register(`ports.${index}.publish`)}
|
||||
disabled={false} // TODO turn off and disable if the port is not http
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Publish
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="borderless"
|
||||
className=""
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{showURL(index) && (
|
||||
<InfoCard
|
||||
title="URL"
|
||||
value={getPortURL(
|
||||
formValues.ports[index]?.port,
|
||||
formValues.name,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
export { default as PortsFormSection } from './PortsFormSection';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Slider } from '@/components/ui/v2/Slider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { MAX_SERVICE_REPLICAS } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function ReplicasFormSection() {
|
||||
const { setValue } = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { replicas } = useWatch<ServiceFormValues>();
|
||||
|
||||
const handleReplicasChange = (value: string) => {
|
||||
const updatedReplicas = parseInt(value, 10);
|
||||
|
||||
setValue('replicas', updatedReplicas, { shouldDirty: true });
|
||||
|
||||
// TODO Trigger revalidate storage
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Replicas ({replicas})
|
||||
</Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Number of replicas for the service. Multiple replicas can process
|
||||
requests/work in parallel. You can set replicas to 0 to pause the
|
||||
service. Refer to{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/resources"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Slider
|
||||
value={replicas}
|
||||
onChange={(_event, value) => handleReplicasChange(value.toString())}
|
||||
min={0}
|
||||
max={MAX_SERVICE_REPLICAS}
|
||||
step={1}
|
||||
aria-label="Replicas"
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ReplicasFormSection } from './ReplicasFormSection';
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import {
|
||||
MAX_STORAGE_CAPACITY,
|
||||
MIN_STORAGE_CAPACITY,
|
||||
} from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function StorageFormSection() {
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'storage',
|
||||
});
|
||||
|
||||
const checkBounds = (value: string, index: number) => {
|
||||
const storageCapacity = parseInt(value, 10);
|
||||
|
||||
if (Number.isNaN(storageCapacity)) {
|
||||
setValue(`storage.${index}.capacity`, 1);
|
||||
}
|
||||
|
||||
if (storageCapacity > MAX_STORAGE_CAPACITY) {
|
||||
setValue(`storage.${index}.capacity`, MAX_STORAGE_CAPACITY);
|
||||
}
|
||||
|
||||
if (storageCapacity < MIN_STORAGE_CAPACITY) {
|
||||
setValue(`storage.${index}.capacity`, MIN_STORAGE_CAPACITY);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Storage
|
||||
</Text>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
By default, services do not have persistent storage. You can add
|
||||
SSD disks to the service here. Refer to{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/storage"
|
||||
className="underline"
|
||||
>
|
||||
Storage
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => append({ name: '', capacity: 1, path: '' })}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-col space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Box
|
||||
key={field.id}
|
||||
className="flex w-full flex-col space-y-2 xs+:flex-row xs+:space-y-0 xs+:space-x-2"
|
||||
>
|
||||
<Input
|
||||
{...register(`storage.${index}.name`)}
|
||||
id={`${field.id}-name`}
|
||||
label={!index && 'Name'}
|
||||
placeholder="Name"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.storage?.at(index)}
|
||||
helperText={errors?.storage?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`storage.${index}.capacity`, {
|
||||
onBlur: (event) => checkBounds(event.target.value, index),
|
||||
})}
|
||||
id={`${field.id}-capacity`}
|
||||
label={!index && 'Capacity'}
|
||||
type="number"
|
||||
placeholder="Capacity"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.storage?.at(index)}
|
||||
helperText={errors?.storage?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
endAdornment={
|
||||
<Text sx={{ color: 'grey.500' }} className="pr-2">
|
||||
GiB
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`storage.${index}.path`)}
|
||||
id={`${field.id}-path`}
|
||||
label={!index && 'Path'}
|
||||
placeholder="Path"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.storage?.at(index)}
|
||||
helperText={errors?.storage?.at(index)?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className=""
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as StorageFormSection } from './StorageFormSection';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ServiceForm';
|
||||
export { default as ServiceForm } from './ServiceForm';
|
||||
@@ -0,0 +1,223 @@
|
||||
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 { CubeIcon } from '@/components/ui/v2/icons/CubeIcon';
|
||||
import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
ServiceForm,
|
||||
type PortTypes,
|
||||
} from '@/features/services/components/ServiceForm';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import {
|
||||
useDeleteRunServiceConfigMutation,
|
||||
useDeleteRunServiceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { RunService } from 'pages/[workspaceSlug]/[appSlug]/services';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ServicesListProps {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
*/
|
||||
services: RunService[];
|
||||
|
||||
/**
|
||||
* Function to be called after a submitting the form for either creating or updating a service.
|
||||
*
|
||||
* @example onDelete={() => refetch()}
|
||||
*/
|
||||
onCreateOrUpdate?: () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Function to be called after a successful delete action.
|
||||
*
|
||||
*/
|
||||
onDelete?: () => Promise<any>;
|
||||
}
|
||||
|
||||
export default function ServicesList({
|
||||
services,
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: ServicesListProps) {
|
||||
const { openDrawer } = useDialog();
|
||||
const [deleteRunService] = useDeleteRunServiceMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [deleteRunServiceConfig] = useDeleteRunServiceConfigMutation();
|
||||
|
||||
const deleteServiceAndConfig = async (appID: string, serviceID: string) => {
|
||||
await deleteRunService({ variables: { serviceID } });
|
||||
await deleteRunServiceConfig({ variables: { appID, serviceID } });
|
||||
await onDelete?.();
|
||||
};
|
||||
|
||||
const viewService = async (service: RunService) => {
|
||||
const {
|
||||
image,
|
||||
command,
|
||||
ports,
|
||||
resources: { compute, replicas, storage },
|
||||
} = service.config;
|
||||
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<Text>Edit {service.config.name}</Text>
|
||||
</Box>
|
||||
),
|
||||
component: (
|
||||
<ServiceForm
|
||||
serviceID={service.id}
|
||||
initialData={{
|
||||
...service.config,
|
||||
image: image.image,
|
||||
command: command?.join(' '),
|
||||
ports: ports.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type as PortTypes,
|
||||
publish: item.publish,
|
||||
})),
|
||||
compute,
|
||||
replicas,
|
||||
storage,
|
||||
}}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const deleteService = async (serviceID: string) => {
|
||||
await toast.promise(
|
||||
deleteServiceAndConfig(currentProject.id, serviceID),
|
||||
{
|
||||
loading: 'Deleteing the service...',
|
||||
success: `The service has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting the service. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col">
|
||||
{services.map((service) => (
|
||||
<Box
|
||||
key={service.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={() => viewService(service)}
|
||||
className="flex w-full flex-row justify-between"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{service.config.name}
|
||||
</Text>
|
||||
<Tooltip title={service.updatedAt}>
|
||||
<span className="hidden cursor-pointer text-sm text-slate-500 xs+:flex">
|
||||
Deployed {formatDistanceToNow(new Date(service.updatedAt))}{' '}
|
||||
ago
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-row items-center space-x-2 md:flex">
|
||||
<Text variant="subtitle1" className="font-mono text-xs">
|
||||
{service.id}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
copy(service.id, 'Service Id');
|
||||
event.stopPropagation();
|
||||
}}
|
||||
aria-label="Service Id"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</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-52' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => viewService(service)}
|
||||
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 Service</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={() => deleteService(service.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
Delete Service
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServicesList } from './ServicesList';
|
||||
5
dashboard/src/gql/services/deleteRunService.graphql
Normal file
5
dashboard/src/gql/services/deleteRunService.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation deleteRunService($serviceID: uuid!) {
|
||||
deleteRunService(id: $serviceID) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation deleteRunServiceConfig($appID: uuid!, $serviceID: uuid!) {
|
||||
deleteRunServiceConfig(appID: $appID, serviceID: $serviceID) {
|
||||
name
|
||||
}
|
||||
}
|
||||
33
dashboard/src/gql/services/getRunService.graphql
Normal file
33
dashboard/src/gql/services/getRunService.graphql
Normal file
@@ -0,0 +1,33 @@
|
||||
query getRunService($id: uuid!, $resolve: Boolean!) {
|
||||
runService(id: $id) {
|
||||
id
|
||||
config(resolve: $resolve) {
|
||||
name
|
||||
image {
|
||||
image
|
||||
}
|
||||
command
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
storage {
|
||||
name
|
||||
path
|
||||
capacity
|
||||
}
|
||||
replicas
|
||||
}
|
||||
environment {
|
||||
name
|
||||
value
|
||||
}
|
||||
ports {
|
||||
port
|
||||
type
|
||||
publish
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
dashboard/src/gql/services/getRunServices.graphql
Normal file
48
dashboard/src/gql/services/getRunServices.graphql
Normal file
@@ -0,0 +1,48 @@
|
||||
query getRunServices(
|
||||
$appID: uuid!
|
||||
$resolve: Boolean!
|
||||
$limit: Int!
|
||||
$offset: Int!
|
||||
) {
|
||||
app(id: $appID) {
|
||||
runServices(limit: $limit, offset: $offset) {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
config(resolve: $resolve) {
|
||||
name
|
||||
image {
|
||||
image
|
||||
}
|
||||
command
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
storage {
|
||||
name
|
||||
path
|
||||
capacity
|
||||
}
|
||||
replicas
|
||||
}
|
||||
environment {
|
||||
name
|
||||
value
|
||||
}
|
||||
ports {
|
||||
port
|
||||
type
|
||||
publish
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runServices_aggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
dashboard/src/gql/services/inserRunService.graphql
Normal file
6
dashboard/src/gql/services/inserRunService.graphql
Normal file
@@ -0,0 +1,6 @@
|
||||
mutation insertRunService($object: run_service_insert_input!) {
|
||||
insertRunService(object: $object) {
|
||||
id
|
||||
appID
|
||||
}
|
||||
}
|
||||
13
dashboard/src/gql/services/insertRunServiceConfig.graphql
Normal file
13
dashboard/src/gql/services/insertRunServiceConfig.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation insertRunServiceConfig(
|
||||
$appID: uuid!
|
||||
$serviceID: uuid!
|
||||
$config: ConfigRunServiceConfigInsertInput!
|
||||
) {
|
||||
insertRunServiceConfig(
|
||||
appID: $appID
|
||||
serviceID: $serviceID
|
||||
config: $config
|
||||
) {
|
||||
name
|
||||
}
|
||||
}
|
||||
13
dashboard/src/gql/services/replaceRunServiceConfig.graphql
Normal file
13
dashboard/src/gql/services/replaceRunServiceConfig.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation replaceRunServiceConfig(
|
||||
$appID: uuid!
|
||||
$serviceID: uuid!
|
||||
$config: ConfigRunServiceConfigInsertInput!
|
||||
) {
|
||||
replaceRunServiceConfig(
|
||||
appID: $appID
|
||||
serviceID: $serviceID
|
||||
config: $config
|
||||
) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
13
dashboard/src/gql/services/updateRunServiceConfig.graphql
Normal file
13
dashboard/src/gql/services/updateRunServiceConfig.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation updateRunServiceConfig(
|
||||
$appID: uuid!
|
||||
$serviceID: uuid!
|
||||
$config: ConfigRunServiceConfigUpdateInput!
|
||||
) {
|
||||
updateRunServiceConfig(
|
||||
appID: $appID
|
||||
serviceID: $serviceID
|
||||
config: $config
|
||||
) {
|
||||
name
|
||||
}
|
||||
}
|
||||
1
dashboard/src/hooks/useHypertune/index.ts
Normal file
1
dashboard/src/hooks/useHypertune/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useHypertune } from './useHypertune';
|
||||
14
dashboard/src/hooks/useHypertune/useHypertune.ts
Normal file
14
dashboard/src/hooks/useHypertune/useHypertune.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import hypertune from '@/hypertune/hypertune';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function useHypertune() {
|
||||
const [, setIsInitialized] = useState<boolean>(hypertune.isInitialized());
|
||||
|
||||
useEffect(() => {
|
||||
hypertune.waitForInitialization().then(() => {
|
||||
setIsInitialized(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return hypertune;
|
||||
}
|
||||
5
dashboard/src/hypertune/hypertune.ts
Normal file
5
dashboard/src/hypertune/hypertune.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { initializeHypertune } from './project_2596';
|
||||
|
||||
const hypertune = initializeHypertune({});
|
||||
|
||||
export default hypertune;
|
||||
107
dashboard/src/hypertune/project_2596.ts
Normal file
107
dashboard/src/hypertune/project_2596.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import * as sdk from "hypertune";
|
||||
|
||||
const projectId = 2596;
|
||||
|
||||
const businessToken = `U2FsdGVkX19+V8BJnVR0xLEC+42OW5qZl/A0i6beAaRmJoIhFh5Yf6eIKBzLbV9h`;
|
||||
|
||||
const queryCode = `query InitQuery {
|
||||
root {
|
||||
enableServices
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const query = {"Query":{"objectTypeName":"Query","selection":{"root":{"fieldArguments":{"__isPartialObject__":true},"fieldQuery":{"Root":{"objectTypeName":"Root","selection":{"enableServices":{"fieldArguments":{},"fieldQuery":null}}}}}}}};
|
||||
|
||||
const fallbackInitData: sdk.FallbackInitData & { [key: string]: unknown } = {"commitId":3297,"reducedExpression":{"id":"caxyeQqTKX3UGOXClvbnW","logs":{"events":{},"exposures":{},"evaluations":{}},"type":"ObjectExpression","fields":{"root":{"id":"PoMWxsy7KbW9fCq5XXvx4","body":{"id":"IUICRjZ7iSnh9k0cWBmnd","logs":{"events":{},"exposures":{},"evaluations":{}},"type":"ObjectExpression","fields":{"enableServices":{"id":"7WZWy2AIy_q9Vbz4cn9KB","logs":{"evaluations":{"XNOtHkUBpglrY1nkYa_bf":1},"events":{},"exposures":{}},"type":"BooleanExpression","value":true,"valueType":{"type":"BooleanValueType"}}},"valueType":{"type":"ObjectValueType","objectTypeName":"Root"},"objectTypeName":"Root"},"logs":{"events":{},"exposures":{},"evaluations":{}},"type":"FunctionExpression","valueType":{"type":"FunctionValueType","returnValueType":{"type":"ObjectValueType","objectTypeName":"Root"},"parameterValueTypes":[{"type":"ObjectValueType","objectTypeName":"Query_root_args"}]},"parameters":[{"id":"Ygjhl2LqjiwcousTABFQz","name":"rootArgs"}]}},"metadata":{"permissions":{"user":{},"group":{"team":{"write":"allow"}}}},"valueType":{"type":"ObjectValueType","objectTypeName":"Query"},"objectTypeName":"Query"},"splits":{},"eventTypes":{},"commitConfig":{"splitConfig":{}},"initLogId":0,"commitHash":"4178461588049503","sdkConfig":{"hashPollInterval":1000,"flushLogsInterval":1000,"maxLogsPerFlush":1},"query":{"Query":{"objectTypeName":"Query","selection":{"root":{"fieldArguments":{"__isPartialObject__":true},"fieldQuery":{"Root":{"objectTypeName":"Root","selection":{"enableServices":{"fieldArguments":{},"fieldQuery":null}}}}}}}}};
|
||||
|
||||
export function initializeHypertune(
|
||||
variableValues: Rec,
|
||||
options: sdk.InitializeOptions = {}
|
||||
): QueryNode {
|
||||
const defaultOptions = { businessToken, query, fallbackInitData };
|
||||
|
||||
return sdk.initialize(
|
||||
QueryNode,
|
||||
projectId,
|
||||
queryCode,
|
||||
variableValues,
|
||||
{ ...defaultOptions, ...options }
|
||||
);
|
||||
}
|
||||
|
||||
// Enum types
|
||||
|
||||
|
||||
|
||||
// Input object types
|
||||
|
||||
export type Rec = {
|
||||
|
||||
//
|
||||
};
|
||||
|
||||
export type Rec2 = {
|
||||
context: Rec3;
|
||||
//
|
||||
};
|
||||
|
||||
export type Rec3 = {
|
||||
workSpace: Rec4;
|
||||
//
|
||||
};
|
||||
|
||||
export type Rec4 = {
|
||||
id: string;
|
||||
//
|
||||
};
|
||||
|
||||
// Enum node classes
|
||||
|
||||
|
||||
|
||||
// Fragment node classes
|
||||
|
||||
export class QueryNode extends sdk.Node {
|
||||
typeName = "Query" as const;
|
||||
|
||||
root(args: Rec2): RootNode {
|
||||
const props0 = this.getField("root", args);
|
||||
const expression0 = props0.expression;
|
||||
|
||||
if (
|
||||
expression0 &&
|
||||
expression0.type === "ObjectExpression"
|
||||
&& expression0.objectTypeName === "Root"
|
||||
) {
|
||||
return new RootNode(props0);
|
||||
}
|
||||
|
||||
const node = new RootNode(props0);
|
||||
node._logUnexpectedTypeError();
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
export class RootNode extends sdk.Node {
|
||||
typeName = "Root" as const;
|
||||
|
||||
enableServices(args: Rec): sdk.BooleanNode {
|
||||
const props0 = this.getField("enableServices", args);
|
||||
const expression0 = props0.expression;
|
||||
|
||||
if (
|
||||
expression0 &&
|
||||
expression0.type === "BooleanExpression"
|
||||
|
||||
) {
|
||||
return new sdk.BooleanNode(props0);
|
||||
}
|
||||
|
||||
const node = new sdk.BooleanNode(props0);
|
||||
node._logUnexpectedTypeError();
|
||||
return node;
|
||||
}
|
||||
}
|
||||
190
dashboard/src/pages/[workspaceSlug]/[appSlug]/services/index.tsx
Normal file
190
dashboard/src/pages/[workspaceSlug]/[appSlug]/services/index.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Pagination } from '@/components/common/Pagination';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { CubeIcon } from '@/components/ui/v2/icons/CubeIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { ServicesIcon } from '@/components/ui/v2/icons/ServicesIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import type { GetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
import { useGetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
import { UpgradeNotification } from '@/features/projects/common/components/UpgradeNotification';
|
||||
import { ServiceForm } from '@/features/services/components/ServiceForm';
|
||||
import ServicesList from '@/features/services/components/ServicesList/ServicesList';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
|
||||
|
||||
export type RunService = Omit<
|
||||
GetRunServicesQuery['app']['runServices'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function ServicesPage() {
|
||||
const limit = useRef(25);
|
||||
const router = useRouter();
|
||||
const { openDrawer } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPlanFree = currentProject.plan.isFree;
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(
|
||||
parseInt(router.query.page as string, 10) || 1,
|
||||
);
|
||||
|
||||
const [nrOfPages, setNrOfPages] = useState(0);
|
||||
|
||||
const offset = useMemo(() => currentPage - 1, [currentPage]);
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
refetch: refetchServices,
|
||||
} = useGetRunServicesQuery({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
resolve: false,
|
||||
limit: limit.current,
|
||||
offset,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userCount = data?.app?.runServices_aggregate.aggregate.count ?? 0;
|
||||
|
||||
setNrOfPages(Math.ceil(userCount / limit.current));
|
||||
}, [data, loading]);
|
||||
|
||||
const services = useMemo(
|
||||
() => data?.app?.runServices.map((service) => service) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const openCreateServiceDialog = () => {
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<Text>Create a new service</Text>
|
||||
</Box>
|
||||
),
|
||||
component: <ServiceForm onSubmit={refetchServices} />,
|
||||
});
|
||||
};
|
||||
|
||||
if (isPlanFree) {
|
||||
return (
|
||||
<Container>
|
||||
<UpgradeNotification
|
||||
message="Unlock Nhost Run by upgrading your project to the Pro plan."
|
||||
className="mt-4"
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.app.runServices.length === 0 && !loading) {
|
||||
return (
|
||||
<Container className="mx-auto max-w-9xl space-y-5 overflow-x-hidden">
|
||||
<div className="flex flex-row place-content-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={openCreateServiceDialog}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
Add service
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Box className="flex flex-col items-center justify-center space-y-5 rounded-lg border px-48 py-12 shadow-sm">
|
||||
<ServicesIcon className="h-10 w-10" />
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Text className="text-center font-medium" variant="h3">
|
||||
No custom services are available
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
All your project’s custom services will be listed here.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-row place-content-between rounded-lg ">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-full"
|
||||
onClick={openCreateServiceDialog}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
Add service
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Box className="flex flex-row place-content-end border-b-1 p-4">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={openCreateServiceDialog}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
Add service
|
||||
</Button>
|
||||
</Box>
|
||||
<Box className="space-y-4">
|
||||
<ServicesList
|
||||
services={services}
|
||||
onDelete={() => refetchServices()}
|
||||
onCreateOrUpdate={() => refetchServices()}
|
||||
/>
|
||||
<Pagination
|
||||
className="px-2"
|
||||
totalNrOfPages={nrOfPages}
|
||||
currentPageNumber={currentPage}
|
||||
totalNrOfElements={
|
||||
data?.app?.runServices_aggregate.aggregate.count ?? 0
|
||||
}
|
||||
itemsLabel="services"
|
||||
elementsPerPage={limit.current}
|
||||
onPrevPageClick={async () => {
|
||||
setCurrentPage((page) => page - 1);
|
||||
if (currentPage - 1 !== 1) {
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, page: currentPage - 1 },
|
||||
});
|
||||
}
|
||||
}}
|
||||
onNextPageClick={async () => {
|
||||
setCurrentPage((page) => page + 1);
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, page: currentPage + 1 },
|
||||
});
|
||||
}}
|
||||
onPageChange={async (page) => {
|
||||
setCurrentPage(page);
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, page },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ServicesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
};
|
||||
@@ -347,6 +347,7 @@ export default function UsersPage() {
|
||||
.count
|
||||
: dataRemoteAppUsers?.usersAggregate?.aggregate?.count
|
||||
}
|
||||
itemsLabel="users"
|
||||
elementsPerPage={
|
||||
searchString
|
||||
? dataRemoteAppUsers?.filteredUsersAggreggate.aggregate
|
||||
|
||||
1102
dashboard/src/utils/__generated__/graphql.ts
generated
1102
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ module.exports = {
|
||||
extend: {
|
||||
colors: {
|
||||
github: '#24292E;',
|
||||
brown: '#382D22',
|
||||
copper: '#DD792D',
|
||||
},
|
||||
boxShadow: {
|
||||
outline: 'inset 0 0 0 2px rgba(0, 82, 205, 0.6)',
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"noImplicitAny": false,
|
||||
"baseUrl": "./src",
|
||||
"useUnknownInCatchVariables": false,
|
||||
"types": ["@types/ace"],
|
||||
"paths": {
|
||||
"@/tests/*": ["tests/*"],
|
||||
"@/e2e/*": ["../e2e/*"],
|
||||
@@ -28,7 +29,8 @@
|
||||
"@/styles/*": ["styles/*"],
|
||||
"@/data/*": ["data/*"],
|
||||
"@/generated/*": ["utils/__generated__/*"],
|
||||
"@/features/*": ["features/*"]
|
||||
"@/features/*": ["features/*"],
|
||||
"@/hypertune/*": ["hypertune/*"]
|
||||
},
|
||||
"incremental": true
|
||||
},
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c6fa8da6d: fix(docs): remove outdated reference/cli
|
||||
|
||||
## 0.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 923abd365: chore(deps): update dependency @tsconfig/docusaurus to v2
|
||||
|
||||
## 0.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -35,4 +35,4 @@ sudo nhost sw upgrade
|
||||
- [Local Development](/cli/local-development)
|
||||
- [Migrate to Nhost Config](/cli/migrate-config)
|
||||
- [Multiple Projects in Parallel](/cli/multiple-projects)
|
||||
- [CLI commands reference](/reference/cli)
|
||||
- [CLI Documentation](/cli)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
title: 'down'
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
Delete all containers created by `nhost up`
|
||||
|
||||
```bash
|
||||
nhost down
|
||||
```
|
||||
|
||||
To delete all containers **and the local database**, append `--data` to the command.
|
||||
|
||||
```bash
|
||||
nhost down --data
|
||||
```
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
title: 'Global Flags'
|
||||
sidebar_position: 9
|
||||
---
|
||||
|
||||
### `--debug`, `-d`
|
||||
|
||||
Turn on debug output.
|
||||
|
||||
```bash
|
||||
nhost up --debug
|
||||
nhost init -d
|
||||
```
|
||||
|
||||
### `--log-file`, `-f`
|
||||
|
||||
Save output to a given file.
|
||||
|
||||
```bash
|
||||
nhost up -d --log-file some-file.txt
|
||||
nhost logs -f some-file.txt
|
||||
```
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: 'CLI'
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
This section is a reference for the commands available in the [Nhost CLI](/cli).
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
title: 'init'
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
Initialize a local Nhost project.
|
||||
|
||||
```
|
||||
nhost init
|
||||
```
|
||||
|
||||
If you have an existing Nhost project in Nhost Cloud that you want to use as a starting point for local development and for the [Git-based workflow](/platform/git), run `nhost init --remote`.
|
||||
|
||||
The `nhost init --remote` command does the following:
|
||||
|
||||
- Creates a new local Nhost project.
|
||||
- Pulls the database migrations and Hasura metadata from the Nhost Cloud project.
|
||||
- Resets the remote Nhost Cloud project's database migrations.
|
||||
|
||||
:::warning
|
||||
|
||||
The `nhost init --remote` command should only be run **once**. Running it multiple times will reset the remote Nhost Cloud project's database migrations which can cause migration conflict issues in your development team.
|
||||
|
||||
:::
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: 'link'
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
Link the local Nhost project in your working directory to a project in Nhost Cloud.
|
||||
|
||||
```bash
|
||||
nhost link
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: 'list'
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
List projects in Nhost Cloud.
|
||||
|
||||
```bash
|
||||
nhost list
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: 'login'
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
Authenticate the CLI with your Nhost user.
|
||||
|
||||
```bash
|
||||
nhost login
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: 'logout'
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
Remove authentication for the CLI.
|
||||
|
||||
```bash
|
||||
nhost logout
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: 'logs'
|
||||
sidebar_position: 9
|
||||
---
|
||||
|
||||
View logs of all services.
|
||||
|
||||
```bash
|
||||
nhost logs
|
||||
```
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
title: 'up'
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
To launch the development environment for your project, use the command `nhost up`. Once the environment is running, this command will
|
||||
|
||||
- Apply database migrations.
|
||||
- Apply Hasura metadata.
|
||||
|
||||
```bash
|
||||
nhost up
|
||||
```
|
||||
|
||||
If it's the first time you start the project, [seed data](/database#seed-data) will be applied.
|
||||
|
||||
## Stop
|
||||
|
||||
Use `ctrl+c` to stop the development environment.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: 'upgrade'
|
||||
sidebar_position: 8
|
||||
---
|
||||
|
||||
Upgrade the CLI to the latest version.
|
||||
|
||||
```bash
|
||||
nhost upgrade
|
||||
```
|
||||
@@ -30,7 +30,3 @@ In this section:
|
||||
- [Getting started](/reference/vue)
|
||||
- [Protecting routes](/reference/vue/protecting-routes)
|
||||
- [Apollo GraphQL](/reference/vue/apollo)
|
||||
|
||||
### Nhost CLI
|
||||
|
||||
- [CLI overview](/reference/cli)
|
||||
|
||||
@@ -69,12 +69,12 @@ HTTP endpoints are automatically generated based on the file structure inside `f
|
||||
|
||||
Here's an example of four Serverless Functions with their files and their HTTP endpoints:
|
||||
|
||||
| File | HTTP Endpoint |
|
||||
| --------------------------- | ----------------------------------------------------------------- |
|
||||
| `functions/index.js` | `https://[project-subdomain].nhost.run/v1/functions/` |
|
||||
| `functions/users/index.ts` | `https://[project-subdomain].nhost.run/v1/functions/users` |
|
||||
| `functions/users/active.ts` | `https://[project-subdomain].nhost.run/v1/functions/users/active` |
|
||||
| `functions/my-company.js` | `https://[project-subdomain].nhost.run/v1/functions/my-company` |
|
||||
| File | HTTP Endpoint |
|
||||
| --------------------------- | ------------------------------------------------------------------ |
|
||||
| `functions/index.js` | `https://[subdomain].functions.[region].nhost.run/v1/` |
|
||||
| `functions/users/index.ts` | `https://[subdomain].functions.[region].nhost.run/v1/users` |
|
||||
| `functions/users/active.ts` | `https://[subdomain].functions.[region].nhost.run/v1/users/active` |
|
||||
| `functions/my-company.js` | `https://[subdomain].functions.[region].nhost.run/v1/my-company` |
|
||||
|
||||
You can prepend files and folders with an underscore (`_`) to prevent them from being treated as Serverless Functions and
|
||||
be turned into HTTP endpoints. This is useful if you have, for example, a utils file (`functions/_utils.js`) or a utils-f
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.3.4",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.4.1",
|
||||
"@tsconfig/docusaurus": "^1.0.6",
|
||||
"@tsconfig/docusaurus": "^2.0.0",
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -183,23 +183,6 @@ const sidebars = {
|
||||
dirName: 'reference/docgen/vue/content'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'CLI',
|
||||
link: { type: 'doc', id: 'reference/cli/index' },
|
||||
items: [
|
||||
'reference/cli/init',
|
||||
'reference/cli/up',
|
||||
'reference/cli/down',
|
||||
'reference/cli/link',
|
||||
'reference/cli/login',
|
||||
'reference/cli/logout',
|
||||
'reference/cli/list',
|
||||
'reference/cli/upgrade',
|
||||
'reference/cli/logs',
|
||||
'reference/cli/global-flags'
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @nhost-examples/node-storage
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d54e4cdd4: fix(buckets): allow using custom buckets for upload
|
||||
- @nhost/nhost-js@2.2.12
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -26,5 +26,4 @@ You can use the `.env.example` file as a starting point.
|
||||
pnpm start
|
||||
```
|
||||
|
||||
The example will download a file from a public URL and upload it to your Nhost
|
||||
Storage bucket.
|
||||
The example will run a few upload operations and then exit.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DELETE FROM "storage"."buckets" WHERE "id" = 'custom';
|
||||
@@ -0,0 +1 @@
|
||||
INSERT INTO "storage"."buckets"("presigned_urls_enabled", "download_expiration", "max_upload_file_size", "min_upload_file_size", "cache_control", "id", "created_at", "updated_at") VALUES (true, 30, 30000000, 1, E'max-age=3600', E'custom', E'2023-06-29T14:30:13.859559+00:00', E'2023-06-29T14:30:13.859559+00:00');
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/node-storage",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"description": "This is an example of how to use the Storage with Node.js",
|
||||
"main": "src/index.mjs",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { uploadFile } from './uploadFile.mjs'
|
||||
import { uploadFormData } from './uploadFormData.mjs'
|
||||
import { uploadToBucket } from './uploadToBucket.mjs'
|
||||
|
||||
async function uploadFiles() {
|
||||
await uploadFormData()
|
||||
@@ -7,6 +8,10 @@ async function uploadFiles() {
|
||||
console.info('-----')
|
||||
|
||||
await uploadFile()
|
||||
|
||||
console.info('-----')
|
||||
|
||||
await uploadToBucket()
|
||||
}
|
||||
|
||||
uploadFiles()
|
||||
|
||||
68
examples/node-storage/src/uploadToBucket.mjs
Normal file
68
examples/node-storage/src/uploadToBucket.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from 'fs'
|
||||
import fetch from 'node-fetch'
|
||||
import { createClient } from './client.mjs'
|
||||
|
||||
const client = createClient()
|
||||
|
||||
export async function uploadToBucket() {
|
||||
console.info('Uploading a Single File to a custom bucket...')
|
||||
|
||||
try {
|
||||
// Download image from remote URL
|
||||
const response = await fetch(
|
||||
'https://hips.hearstapps.com/hmg-prod/images/cute-cat-photos-1593441022.jpg?crop=1.00xw:0.753xh;0,0.153xh&resize=1200:*'
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[file-to-bucket]`, 'Image not found!')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
const fileName = 'cat.jpg'
|
||||
|
||||
fs.writeFile(fileName, fileBuffer, async (err) => {
|
||||
if (err) {
|
||||
console.error(`[file-to-bucket]`, err)
|
||||
return
|
||||
}
|
||||
|
||||
const file = fs.createReadStream(fileName)
|
||||
|
||||
const { error: uploadError, fileMetadata } = await client.storage.upload({
|
||||
file,
|
||||
bucketId: 'custom'
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
console.error(`[file-to-bucket]`, uploadError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
console.info(`[file-to-bucket]`, `File has been uploaded successfully!`)
|
||||
console.info(`[file-to-bucket]`, `ID: ${fileMetadata?.id}`)
|
||||
|
||||
console.log(fileMetadata.bucketId)
|
||||
|
||||
// Generate a presigned URL for the uploaded file
|
||||
const { error: presignError, presignedUrl: image } = await client.storage.getPresignedUrl({
|
||||
fileId: fileMetadata.id
|
||||
})
|
||||
|
||||
if (presignError) {
|
||||
console.error(`[file-to-bucket]`, presignError)
|
||||
return
|
||||
}
|
||||
|
||||
console.info(`[file-to-bucket]`, `Presigned URL: ${image.url}`)
|
||||
})
|
||||
|
||||
// Upload file to Nhost Storage
|
||||
} catch (error) {
|
||||
console.error(`[file-to-bucket]`, error.message)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
HASURA_GRAPHQL_ADMIN_SECRET=nhost-admin-secret
|
||||
HASURA_GRAPHQL_JWT_SECRET=oqpdwyffgxncqamwlyebkaifyazvqgso
|
||||
NHOST_WEBHOOK_SECRET=nhost-webhook-secret
|
||||
GRAFANA_ADMIN_PASSWORD=FIXME
|
||||
HASURA_GRAPHQL_ADMIN_SECRET='nhost-admin-secret'
|
||||
HASURA_GRAPHQL_JWT_SECRET='oqpdwyffgxncqamwlyebkaifyazvqgso'
|
||||
NHOST_WEBHOOK_SECRET='nhost-webhook-secret'
|
||||
GRAFANA_ADMIN_PASSWORD='FIXME'
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
buildInputs = with pkgs; [
|
||||
nhost
|
||||
nodejs_18
|
||||
# nodePackages.pnpm
|
||||
nodePackages.pnpm
|
||||
] ++ buildInputs ++ nativeBuildInputs;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @nhost/apollo
|
||||
|
||||
## 5.2.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/nhost-js@2.2.13
|
||||
|
||||
## 5.2.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/nhost-js@2.2.12
|
||||
|
||||
## 5.2.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/apollo",
|
||||
"version": "5.2.13",
|
||||
"version": "5.2.15",
|
||||
"description": "Nhost Apollo Client library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @nhost/react-apollo
|
||||
|
||||
## 5.0.32
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/apollo@5.2.15
|
||||
- @nhost/react@2.0.28
|
||||
|
||||
## 5.0.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/apollo@5.2.14
|
||||
- @nhost/react@2.0.27
|
||||
|
||||
## 5.0.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/react-apollo",
|
||||
"version": "5.0.30",
|
||||
"version": "5.0.32",
|
||||
"description": "Nhost React Apollo client",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @nhost/react-urql
|
||||
|
||||
## 2.0.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react@2.0.28
|
||||
|
||||
## 2.0.28
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react@2.0.27
|
||||
|
||||
## 2.0.27
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/react-urql",
|
||||
"version": "2.0.27",
|
||||
"version": "2.0.29",
|
||||
"description": "Nhost React URQL client",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -629,6 +629,103 @@
|
||||
"title": "Network Traffic",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": "This graph shows when a service was restarted. There are two main reasons why a service may be restarted:\n\n- OOMKilled - This means the service tried to use more memory than it has available and had to be restarted. For more information on resources you can check the [documentation](https://docs.nhost.io/platform/compute).\n- Error - This can show for mainly two reasons; when new configuration needs to be applied the service is terminated and due to limitations this shows as \"Error\" but it is, in fact, part of normal operations. This can also show if your service is misconfigured and/or can't start correctly for some reason. If this error doesn't show constantly it is safe to ignore this error.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"decimals": 2,
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 29
|
||||
},
|
||||
"id": 37,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by(container, reason) (increase(pod_terminated_total[$__rate_interval]))",
|
||||
"hide": false,
|
||||
"interval": "2m",
|
||||
"legendFormat": "{{container}}-{{reason}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Service Restarts",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
|
||||
@@ -76,11 +76,11 @@
|
||||
"husky": "^8.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.7.1",
|
||||
"turbo": "1.10.6",
|
||||
"turbo": "1.10.11",
|
||||
"typedoc": "^0.22.18",
|
||||
"typescript": "4.9.5",
|
||||
"vite": "^4.3.8",
|
||||
"vite-plugin-dts": "^2.3.0",
|
||||
"vite-plugin-dts": "^3.0.0",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^0.32.0"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
HASURA_GRAPHQL_ADMIN_SECRET=nhost-admin-secret
|
||||
HASURA_GRAPHQL_JWT_SECRET=oqpdwyffgxncqamwlyebkaifyazvqgso
|
||||
NHOST_WEBHOOK_SECRET=nhost-webhook-secret
|
||||
GRAFANA_ADMIN_PASSWORD=FIXME
|
||||
HASURA_GRAPHQL_ADMIN_SECRET='nhost-admin-secret'
|
||||
HASURA_GRAPHQL_JWT_SECRET='oqpdwyffgxncqamwlyebkaifyazvqgso'
|
||||
NHOST_WEBHOOK_SECRET='nhost-webhook-secret'
|
||||
GRAFANA_ADMIN_PASSWORD='FIXME'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
HASURA_GRAPHQL_ADMIN_SECRET=nhost-admin-secret
|
||||
HASURA_GRAPHQL_JWT_SECRET=oqpdwyffgxncqamwlyebkaifyazvqgso
|
||||
NHOST_WEBHOOK_SECRET=nhost-webhook-secret
|
||||
GRAFANA_ADMIN_PASSWORD=FIXME
|
||||
HASURA_GRAPHQL_ADMIN_SECRET='nhost-admin-secret'
|
||||
HASURA_GRAPHQL_JWT_SECRET='oqpdwyffgxncqamwlyebkaifyazvqgso'
|
||||
NHOST_WEBHOOK_SECRET='nhost-webhook-secret'
|
||||
GRAFANA_ADMIN_PASSWORD='FIXME'
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @nhost/hasura-storage-js
|
||||
|
||||
## 2.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 300e3f49e: fix(hasura-storage-js): fix file upload formData field
|
||||
|
||||
## 2.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d54e4cdd4: fix(buckets): allow using custom buckets for upload
|
||||
|
||||
## 2.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/hasura-storage-js",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.2",
|
||||
"description": "Hasura-storage client",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -118,7 +118,7 @@ export const createFileUploadMachine = () =>
|
||||
uploadFile: (context, event) => (callback) => {
|
||||
const file = (event.file || context.file)!
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
data.append('file[]', file)
|
||||
|
||||
let currentLoaded = 0
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export const fetchUpload = async (
|
||||
...initialHeaders
|
||||
}
|
||||
if (bucketId) {
|
||||
data.append('bucketId', bucketId)
|
||||
data.append('bucket-id', bucketId)
|
||||
}
|
||||
if (adminSecret) {
|
||||
headers['x-hasura-admin-secret'] = adminSecret
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @nhost/nextjs
|
||||
|
||||
## 1.13.34
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react@2.0.28
|
||||
|
||||
## 1.13.33
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react@2.0.27
|
||||
|
||||
## 1.13.32
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/nextjs",
|
||||
"version": "1.13.32",
|
||||
"version": "1.13.34",
|
||||
"description": "Nhost NextJS library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
HASURA_GRAPHQL_ADMIN_SECRET=nhost-admin-secret
|
||||
HASURA_GRAPHQL_JWT_SECRET=oqpdwyffgxncqamwlyebkaifyazvqgso
|
||||
NHOST_WEBHOOK_SECRET=nhost-webhook-secret
|
||||
GRAFANA_ADMIN_PASSWORD=FIXME
|
||||
HASURA_GRAPHQL_ADMIN_SECRET='nhost-admin-secret'
|
||||
HASURA_GRAPHQL_JWT_SECRET='oqpdwyffgxncqamwlyebkaifyazvqgso'
|
||||
NHOST_WEBHOOK_SECRET='nhost-webhook-secret'
|
||||
GRAFANA_ADMIN_PASSWORD='FIXME'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user