Compare commits
36 Commits
@nhost/nex
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f0910367d | ||
|
|
e31eefae63 | ||
|
|
abb24afad5 | ||
|
|
18a64555ce | ||
|
|
60bcd8f949 | ||
|
|
e28975d6a5 | ||
|
|
33284d3cf0 | ||
|
|
1dbd65eb0e | ||
|
|
6eec78f9c5 | ||
|
|
e3f0732108 | ||
|
|
807b8c049a | ||
|
|
998c0376bf | ||
|
|
cf5423dac6 | ||
|
|
a2efeed36f | ||
|
|
533b74d82d | ||
|
|
42cf86c8f1 | ||
|
|
70e74f2f3d | ||
|
|
a01985466e | ||
|
|
8ea4210582 | ||
|
|
58919ba763 | ||
|
|
86f3f8d505 | ||
|
|
201abb89fd | ||
|
|
cf6b712b20 | ||
|
|
b51986289d | ||
|
|
c640c50c70 | ||
|
|
b3ff6adcc2 | ||
|
|
dbadf59092 | ||
|
|
bedbb82cd7 | ||
|
|
79ce7cae2f | ||
|
|
9c9137f813 | ||
|
|
502abadbae | ||
|
|
b6b67773d1 | ||
|
|
33ce95536d | ||
|
|
11f9ed7507 | ||
|
|
ac6d1b6e01 | ||
|
|
77fba27d12 |
@@ -12,7 +12,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.10.5
|
||||
run_install: false
|
||||
|
||||
@@ -17,5 +17,10 @@ NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url>
|
||||
NEXT_PUBLIC_ANALYTICS_WRITE_KEY=<analytics_write_key>
|
||||
NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET=<nhost_bragi_websocket>
|
||||
|
||||
NEXT_PUBLIC_ZENDESK_URL=
|
||||
NEXT_PUBLIC_ZENDESK_API_KEY=
|
||||
NEXT_PUBLIC_ZENDESK_USER_EMAIL=
|
||||
|
||||
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
CODEGEN_HASURA_ADMIN_SECRET=nhost-admin-secret
|
||||
|
||||
@@ -1,5 +1,68 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 1.24.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- abb24af: chore: add redirect to support page when project is locked
|
||||
- 18a6455: feat: show contact us info and locked reason when project is locked
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e31eefa: fix: include ingresses field when updating run services
|
||||
|
||||
## 1.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 33284d3: fix: don't show double scrollbar in configuration editor
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@12.0.3
|
||||
- @nhost/nextjs@2.1.17
|
||||
|
||||
## 1.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 998c037: fix: align drop-down list in select component
|
||||
- 807b8c0: fix: show city name in region selection for project creation
|
||||
|
||||
## 1.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a2efeed: fix: improve project health error handling, add unknown state and polling interval for health state
|
||||
|
||||
## 1.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8ea4210: fix: error toasts can be closed individually, instead of dismissing all toasts at once
|
||||
- 58919ba: chore: add blink animation when project health service is updating
|
||||
|
||||
## 1.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b519862: fix: get configuration in configuration editor using local development environment
|
||||
|
||||
## 1.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 502abad: feat: add services health checks indicators to the overview page
|
||||
- b3ff6ad: chore: update title text on service status modal
|
||||
- dbadf59: feat: add project configuration TOML editor to the settings page
|
||||
|
||||
## 1.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 77fba27: fix: postgres version validation when activating ai in ai settings page
|
||||
- ac6d1b6: feat: use name instead of awsName
|
||||
|
||||
## 1.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "1.16.3",
|
||||
"version": "1.24.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -21,17 +21,20 @@
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.9.9",
|
||||
"@codemirror/lang-sql": "^6.6.2",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/legacy-modes": "^6.4.0",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@fontsource/inter": "^5.0.17",
|
||||
"@fontsource/roboto-mono": "^5.0.17",
|
||||
"@graphiql/react": "^0.20.3",
|
||||
"@graphiql/react": "^0.22.3",
|
||||
"@graphiql/toolkit": "^0.9.1",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@mui/base": "5.0.0-beta.31",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/system": "^5.15.14",
|
||||
@@ -45,6 +48,8 @@
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.15.3",
|
||||
"@tanstack/react-virtual": "^3.2.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uiw/codemirror-theme-bbedit": "^4.22.2",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"analytics-node": "^6.2.0",
|
||||
@@ -53,7 +58,7 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"framer-motion": "^10.18.0",
|
||||
"generate-password": "^1.7.1",
|
||||
"graphiql": "^3.1.1",
|
||||
"graphiql": "^3.3.1",
|
||||
"graphql": "16.8.1",
|
||||
"graphql-request": "^6.1.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
|
||||
12
dashboard/public/assets/LockedApp.svg
Normal file
12
dashboard/public/assets/LockedApp.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="72" height="73" viewBox="0 0 72 73" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_10501_253)">
|
||||
<path d="M0 8.5C0 4.08172 3.58172 0.5 8 0.5H64C68.4183 0.5 72 4.08172 72 8.5V64.5C72 68.9183 68.4183 72.5 64 72.5H8C3.58172 72.5 0 68.9183 0 64.5V8.5Z" fill="#9C73DF" fill-opacity="0.2"/>
|
||||
<path d="M43.1203 35.5H29.7687C28.7153 35.5 27.8613 36.3954 27.8613 37.5V44.5C27.8613 45.6046 28.7153 46.5 29.7687 46.5H43.1203C44.1737 46.5 45.0276 45.6046 45.0276 44.5V37.5C45.0276 36.3954 44.1737 35.5 43.1203 35.5Z" stroke="#9C73DF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.6758 35.5V31.5C31.6758 30.1739 32.1782 28.9021 33.0724 27.9645C33.9667 27.0268 35.1795 26.5 36.4442 26.5C37.7089 26.5 38.9217 27.0268 39.816 27.9645C40.7102 28.9021 41.2126 30.1739 41.2126 31.5V35.5" stroke="#9C73DF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10501_253">
|
||||
<rect width="72" height="72" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -29,7 +29,7 @@ export default function CountrySelector({
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
className: 'z-[10000] w-[270px]',
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { AccountMenu } from '@/components/layout/AccountMenu';
|
||||
@@ -9,11 +8,9 @@ import { Logo } from '@/components/presentational/Logo';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Chip } from '@/components/ui/v2/Chip';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { GraphiteIcon } from '@/components/ui/v2/icons/GraphiteIcon';
|
||||
import { DevAssistant } from '@/features/ai/DevAssistant';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsCurrentUserOwner } from '@/features/projects/common/hooks/useIsCurrentUserOwner';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
@@ -38,8 +35,6 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
const { currentProject, refetch: refetchProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
|
||||
const isProjectUpdating =
|
||||
currentProject?.appStates[0]?.stateId === ApplicationStatus.Updating;
|
||||
|
||||
@@ -105,25 +100,19 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
</Button>
|
||||
|
||||
{isPlatform && (
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
hideChevron
|
||||
className="rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
|
||||
>
|
||||
Contact us
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<ContactUs
|
||||
className="max-w-md"
|
||||
isTeam={currentProject?.plan?.name === 'Team'}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
<NavLink
|
||||
underline="none"
|
||||
href="/support"
|
||||
className="mr-2 rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
'&:hover': { backgroundColor: 'grey.200' },
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Support
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
|
||||
@@ -45,48 +45,53 @@ export default function SettingsLayout({
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-scroll overflow-x-hidden"
|
||||
className="flex w-full flex-auto flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row place-content-center gap-2"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row place-content-center gap-2"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
</Box>
|
||||
</Box>
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Backdrop } from '@/components/ui/v2/Backdrop';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { SlidersIcon } from '@/components/ui/v2/icons/SlidersIcon';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
@@ -27,12 +28,17 @@ interface SettingsNavLinkProps extends ListItemButtonProps {
|
||||
* @default true
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* Class name passed to the text element.
|
||||
*/
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
function SettingsNavLink({
|
||||
exact = true,
|
||||
href,
|
||||
children,
|
||||
textClassName,
|
||||
...props
|
||||
}: SettingsNavLinkProps) {
|
||||
const router = useRouter();
|
||||
@@ -52,7 +58,7 @@ function SettingsNavLink({
|
||||
selected={active}
|
||||
{...props}
|
||||
>
|
||||
<ListItem.Text>{children}</ListItem.Text>
|
||||
<ListItem.Text className={textClassName}>{children}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
@@ -114,13 +120,13 @@ export default function SettingsSidebar({
|
||||
<Box
|
||||
component="aside"
|
||||
className={twMerge(
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pb-17 pt-2 motion-safe:transition-transform md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
|
||||
'absolute top-0 z-[35] flex h-full w-full flex-col justify-between overflow-auto border-r-1 pb-17 pt-2 motion-safe:transition-transform md:relative md:z-0 md:h-full md:pb-0 md:pt-2.5 md:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<nav aria-label="Settings navigation">
|
||||
<nav aria-label="Settings navigation" className="px-2">
|
||||
<List className="grid gap-2">
|
||||
<SettingsNavLink
|
||||
href="/general"
|
||||
@@ -220,6 +226,20 @@ export default function SettingsSidebar({
|
||||
</SettingsNavLink>
|
||||
</List>
|
||||
</nav>
|
||||
<Box className="border-t">
|
||||
<SettingsNavLink
|
||||
href="/editor"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
className="flex w-full border group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
textClassName="flex w-full justify-center"
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-center space-x-4 py-2.5">
|
||||
<SlidersIcon />
|
||||
<span className="flex">Configuration Editor</span>
|
||||
</div>
|
||||
</SettingsNavLink>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
|
||||
132
dashboard/src/components/presentational/CodeBlock/CodeBlock.tsx
Normal file
132
dashboard/src/components/presentational/CodeBlock/CodeBlock.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ForwardedRef,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { CopyToClipboardButton as CopyToClipboardButtonOriginal } from './CopyToClipboardButton';
|
||||
import { getNodeText } from './getNodeText';
|
||||
|
||||
export interface CodeBlockPropsBase {
|
||||
filename?: string;
|
||||
/**
|
||||
* Color of the filename text and the border underneath it when content is being shown.
|
||||
*/
|
||||
filenameColor?: string;
|
||||
/**
|
||||
* Text of the toast that appears when the code is copied to the clipboard.
|
||||
*/
|
||||
copyToClipboardToastTitle?: string;
|
||||
}
|
||||
|
||||
export type CodeBlockProps = CodeBlockPropsBase &
|
||||
Omit<ComponentPropsWithoutRef<'div'>, keyof CodeBlockPropsBase>;
|
||||
|
||||
/**
|
||||
* Different from CodeGroup because we cannot use Headless UI's Tab component outside a Tab.Group
|
||||
* Styling should look the same though.
|
||||
*/
|
||||
function CodeTabBar({
|
||||
filename,
|
||||
filenameColor,
|
||||
children,
|
||||
}: {
|
||||
filename: string;
|
||||
filenameColor?: string;
|
||||
children?: ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex text-xs leading-6 text-slate-400">
|
||||
<div
|
||||
className="flex flex-none items-center border-b border-t border-t-transparent px-4 py-1"
|
||||
style={{ color: filenameColor, borderBottomColor: filenameColor }}
|
||||
>
|
||||
{filename}
|
||||
</div>
|
||||
<div className="bg-codeblock-tabs flex flex-auto items-center rounded-t border border-slate-500/30">
|
||||
{children && (
|
||||
<div className="flex flex-auto items-center justify-end space-x-4 px-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyToClipboardButtonProps
|
||||
extends Partial<
|
||||
ComponentPropsWithoutRef<typeof CopyToClipboardButtonOriginal>
|
||||
> {
|
||||
filenameColor?: string;
|
||||
tooltipColor?: string;
|
||||
toastTitle?: string;
|
||||
}
|
||||
|
||||
function CopyToClipboardButton({
|
||||
tooltipColor,
|
||||
filenameColor,
|
||||
textToCopy,
|
||||
toastTitle,
|
||||
...props
|
||||
}: CopyToClipboardButtonProps) {
|
||||
return (
|
||||
<CopyToClipboardButtonOriginal
|
||||
textToCopy={textToCopy}
|
||||
title={toastTitle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const CodeBlock = forwardRef(
|
||||
(
|
||||
{
|
||||
filename,
|
||||
filenameColor,
|
||||
children,
|
||||
className,
|
||||
copyToClipboardToastTitle,
|
||||
...props
|
||||
}: CodeBlockProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) => (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.200',
|
||||
}}
|
||||
className={clsx(
|
||||
'not-prose relative mt-5 px-2',
|
||||
filename && 'pt-2',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{filename ? (
|
||||
<CodeTabBar filename={filename} filenameColor={filenameColor}>
|
||||
<CopyToClipboardButton
|
||||
filenameColor={filenameColor}
|
||||
textToCopy={getNodeText(children)}
|
||||
toastTitle={copyToClipboardToastTitle}
|
||||
className="relative"
|
||||
/>
|
||||
</CodeTabBar>
|
||||
) : (
|
||||
<CopyToClipboardButton
|
||||
filenameColor={filenameColor}
|
||||
textToCopy={getNodeText(children)}
|
||||
toastTitle={copyToClipboardToastTitle}
|
||||
className="absolute right-3 top-0"
|
||||
/>
|
||||
)}
|
||||
<pre className="overflow-x-auto">
|
||||
<code className="font-mono">{children}</code>
|
||||
</pre>
|
||||
</Box>
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
type IconButtonProps,
|
||||
} from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { copy } from '@/utils/copy';
|
||||
|
||||
export function CopyToClipboardButton({
|
||||
textToCopy,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
textToCopy: string;
|
||||
title: string;
|
||||
} & IconButtonProps) {
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Hide copy button if the browser does not support it
|
||||
if (typeof window !== 'undefined' && !navigator?.clipboard) {
|
||||
console.error(
|
||||
"The browser's Clipboard API is unavailable. The Clipboard API is only available on HTTPS.",
|
||||
);
|
||||
setDisabled(true);
|
||||
} else {
|
||||
setDisabled(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Hide copy button if you would copy an empty string
|
||||
if (!textToCopy || disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className={clsx('group', className)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
copy(textToCopy, title);
|
||||
}}
|
||||
aria-label={textToCopy}
|
||||
{...props}
|
||||
>
|
||||
<CopyIcon className="top-5 h-4 w-4" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Gets the text from a component as if you selected it with a mouse and copied it.
|
||||
export const getNodeText = (node: any): string => {
|
||||
if (['string', 'number'].includes(typeof node)) {
|
||||
// Convert number into string
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
if (node instanceof Array) {
|
||||
return node.map(getNodeText).join('');
|
||||
}
|
||||
|
||||
if (typeof node === 'object' && node?.props?.children) {
|
||||
return getNodeText(node.props.children);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import {
|
||||
CodeBlock,
|
||||
type CodeBlockProps,
|
||||
type CodeBlockPropsBase,
|
||||
} from './CodeBlock';
|
||||
|
||||
export type { CodeBlockPropsBase, CodeBlockProps };
|
||||
export { CodeBlock };
|
||||
28
dashboard/src/components/ui/v2/Accordion/Accordion.tsx
Normal file
28
dashboard/src/components/ui/v2/Accordion/Accordion.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { AccordionProps as MaterialAccordionProps } from '@mui/material/Accordion';
|
||||
|
||||
import MaterialAccordion, { accordionClasses } from '@mui/material/Accordion';
|
||||
|
||||
export interface AccordionProps extends MaterialAccordionProps {}
|
||||
|
||||
const Accordion = styled(MaterialAccordion)<AccordionProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
borderBottom: `transparent solid 0px`,
|
||||
boxShadow: `none`,
|
||||
[`&.${accordionClasses.disabled}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
[`&.${accordionClasses.root}`]: {
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
[`&.${accordionClasses.expanded}`]: {
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
Accordion.displayName = 'NhostAccordion';
|
||||
|
||||
export default Accordion;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { AccordionActionsProps as MaterialAccordionActionsProps } from '@mui/material/AccordionActions';
|
||||
|
||||
import MaterialAccordionActions from '@mui/material/AccordionActions';
|
||||
|
||||
export interface AccordionActionsProps extends MaterialAccordionActionsProps {}
|
||||
|
||||
const AccordionActions = styled(
|
||||
MaterialAccordionActions,
|
||||
)<AccordionActionsProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
}));
|
||||
|
||||
AccordionActions.displayName = 'NhostAccordionActions';
|
||||
|
||||
export default AccordionActions;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { AccordionDetailsProps as MaterialAccordionDetailsProps } from '@mui/material/AccordionDetails';
|
||||
|
||||
import MaterialAccordionDetails from '@mui/material/AccordionDetails';
|
||||
|
||||
export interface AccordionDetailsProps extends MaterialAccordionDetailsProps {}
|
||||
|
||||
const AccordionDetails = styled(
|
||||
MaterialAccordionDetails,
|
||||
)<AccordionDetailsProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
}));
|
||||
|
||||
AccordionDetails.displayName = 'NhostAccordionDetails';
|
||||
|
||||
export default AccordionDetails;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { AccordionSummaryProps as MaterialAccordionSummaryProps } from '@mui/material/AccordionSummary';
|
||||
|
||||
import MaterialAccordionSummary, {
|
||||
accordionSummaryClasses,
|
||||
} from '@mui/material/AccordionSummary';
|
||||
|
||||
export interface AccordionSummaryProps extends MaterialAccordionSummaryProps {}
|
||||
|
||||
const AccordionSummary = styled(
|
||||
MaterialAccordionSummary,
|
||||
)<AccordionSummaryProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
[`&.${accordionSummaryClasses.disabled}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
opacity: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
AccordionSummary.displayName = 'NhostAccordionSummary';
|
||||
|
||||
export default AccordionSummary;
|
||||
19
dashboard/src/components/ui/v2/Accordion/index.ts
Normal file
19
dashboard/src/components/ui/v2/Accordion/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import AccordionRoot from './Accordion';
|
||||
import AccordionActions from './AccordionActions';
|
||||
import AccordionDetails from './AccordionDetails';
|
||||
import AccordionSummary from './AccordionSummary';
|
||||
|
||||
export { default as BaseAccordion } from './Accordion';
|
||||
export * from './AccordionActions';
|
||||
export { default as AccordionActions } from './AccordionActions';
|
||||
export * from './AccordionDetails';
|
||||
export { default as AccordionDetails } from './AccordionDetails';
|
||||
export * from './AccordionSummary';
|
||||
export { default as AccordionSummary } from './AccordionSummary';
|
||||
|
||||
export const Accordion = {
|
||||
Root: AccordionRoot,
|
||||
Details: AccordionDetails,
|
||||
Summary: AccordionSummary,
|
||||
Actions: AccordionActions,
|
||||
};
|
||||
47
dashboard/src/components/ui/v2/Badge/Badge.tsx
Normal file
47
dashboard/src/components/ui/v2/Badge/Badge.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { BadgeProps as MaterialBadgeProps } from '@mui/material/Badge';
|
||||
import MaterialBadge from '@mui/material/Badge';
|
||||
import type { ElementType } from 'react';
|
||||
|
||||
export interface BadgeProps extends MaterialBadgeProps {
|
||||
/**
|
||||
* Custom component for the root node.
|
||||
*/
|
||||
component?: ElementType;
|
||||
}
|
||||
|
||||
const Badge = styled(MaterialBadge)<BadgeProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
'& .MuiBadge-dot': {
|
||||
minWidth: '0.625rem',
|
||||
minHeight: '0.625rem',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
'& .MuiBadge-standard': {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
minWidth: '0.625rem',
|
||||
height: '0.625rem',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
'& .MuiBadge-colorError': {
|
||||
backgroundColor: theme.palette.error.main,
|
||||
},
|
||||
'& .MuiBadge-colorWarning': {
|
||||
backgroundColor: theme.palette.warning.main,
|
||||
},
|
||||
'& .MuiBadge-colorSuccess': {
|
||||
backgroundColor: theme.palette.success.dark,
|
||||
},
|
||||
'& .MuiBadge-colorSecondary': {
|
||||
backgroundColor: theme.palette.grey[500],
|
||||
},
|
||||
}));
|
||||
|
||||
Badge.displayName = 'NhostBadge';
|
||||
|
||||
export default Badge;
|
||||
2
dashboard/src/components/ui/v2/Badge/index.ts
Normal file
2
dashboard/src/components/ui/v2/Badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Badge';
|
||||
export { default as Badge } from './Badge';
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Backdrop } from '@/components/ui/v2/Backdrop';
|
||||
import type { ButtonProps } from '@/components/ui/v2/Button';
|
||||
import type { DialogTitleProps } from '@/components/ui/v2/Dialog';
|
||||
import { styled } from '@mui/material';
|
||||
import type { DialogProps as MaterialDialogProps } from '@mui/material/Dialog';
|
||||
import MaterialDialog from '@mui/material/Dialog';
|
||||
import type { DialogActionsProps } from '@mui/material/DialogActions';
|
||||
import type { DialogContentProps } from '@mui/material/DialogContent';
|
||||
import type { DialogContentTextProps } from '@mui/material/DialogContentText';
|
||||
import type { DialogTitleProps } from '@mui/material/DialogTitle';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import { test } from 'vitest';
|
||||
import ErrorToast from './ErrorToast';
|
||||
|
||||
const oneMemberByWorkspaceError = {
|
||||
name: 'ApolloError',
|
||||
graphQLErrors: [
|
||||
{
|
||||
message: 'database query error',
|
||||
extensions: {
|
||||
path: '$.selectionSet.insertApp.args.object',
|
||||
code: 'unexpected',
|
||||
internal: {
|
||||
arguments: [],
|
||||
error: {
|
||||
description: null,
|
||||
exec_status: 'FatalError',
|
||||
hint: null,
|
||||
message:
|
||||
'Only one workspace member is allowed for individual plans',
|
||||
status_code: 'P0001',
|
||||
},
|
||||
prepared: false,
|
||||
statement: '.....',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
protocolErrors: [],
|
||||
clientErrors: [],
|
||||
networkError: null,
|
||||
message: 'database query error',
|
||||
};
|
||||
|
||||
const changeNodeInvalidVersionError = {
|
||||
name: 'ApolloError',
|
||||
graphQLErrors: [
|
||||
{
|
||||
message:
|
||||
'failed to resolve config: failed to validate config: config is not valid: #Config.functions.node.version: 2 errors in empty disjunction: (and 2 more errors)',
|
||||
path: ['replaceConfigRawJSON'],
|
||||
},
|
||||
],
|
||||
protocolErrors: [],
|
||||
clientErrors: [],
|
||||
networkError: null,
|
||||
message:
|
||||
'failed to resolve config: failed to validate config: config is not valid: #Config.functions.node.version: 2 errors in empty disjunction: (and 2 more errors)',
|
||||
};
|
||||
|
||||
test('should render the error message when creating a project with an individual plan in a workspace with multiple users', () => {
|
||||
const errorMessage =
|
||||
'An error occurred while creating the project. Please try again.';
|
||||
render(
|
||||
<ErrorToast
|
||||
isVisible
|
||||
errorMessage={errorMessage}
|
||||
error={oneMemberByWorkspaceError}
|
||||
close={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Only one workspace member is allowed for individual plans/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the error message when changing the node version to an invalid value in configuration editor', () => {
|
||||
const errorMessage =
|
||||
'An error occurred while saving configuration. Please try again.';
|
||||
render(
|
||||
<ErrorToast
|
||||
isVisible
|
||||
errorMessage={errorMessage}
|
||||
error={changeNodeInvalidVersionError}
|
||||
close={() => {}}
|
||||
/>,
|
||||
);
|
||||
const regex =
|
||||
/failed to resolve config: failed to validate config: config is not valid: #Config\.functions\.node\.version: 2 errors in empty disjunction: \(and 2 more errors\)/i;
|
||||
|
||||
expect(screen.getByText(regex)).toBeInTheDocument();
|
||||
});
|
||||
@@ -32,7 +32,7 @@ const getInternalErrorMessage = (
|
||||
const graphqlError = error.graphQLErrors?.[0];
|
||||
const graphqlExtensionsError = graphqlError?.extensions?.internal
|
||||
?.error as { message: string };
|
||||
return graphqlError.message || graphqlExtensionsError?.message || null;
|
||||
return graphqlExtensionsError?.message || graphqlError.message || null;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -8,6 +8,17 @@ import { createTheme as createMuiTheme } from '@mui/material/styles';
|
||||
* @param mode - Color mode
|
||||
* @returns Material UI theme
|
||||
*/
|
||||
|
||||
declare module '@mui/material/styles' {
|
||||
interface Palette {
|
||||
beige: Palette['primary'];
|
||||
}
|
||||
|
||||
interface PaletteOptions {
|
||||
beige?: PaletteOptions['primary'];
|
||||
}
|
||||
}
|
||||
|
||||
export default function createTheme(mode: PaletteMode) {
|
||||
return createMuiTheme({
|
||||
shape: {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export default function CommunityIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M5.5 10C7.29493 10 8.75 8.54493 8.75 6.75C8.75 4.95507 7.29493 3.5 5.5 3.5C3.70507 3.5 2.25 4.95507 2.25 6.75C2.25 8.54493 3.70507 10 5.5 10Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
<path
|
||||
d="M9.71338 3.62107C10.1604 3.49513 10.6292 3.46644 11.0882 3.53693C11.5473 3.60743 11.9859 3.77547 12.3745 4.02975C12.7631 4.28403 13.0927 4.61863 13.3411 5.01102C13.5896 5.40342 13.751 5.84449 13.8146 6.30453C13.8782 6.76457 13.8425 7.2329 13.7098 7.67797C13.5772 8.12304 13.3507 8.53452 13.0457 8.8847C12.7406 9.23487 12.364 9.51561 11.9413 9.70799C11.5187 9.90038 11.0596 9.99996 10.5952 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1 12.3373C1.50758 11.6153 2.18143 11.026 2.96466 10.6192C3.74788 10.2124 4.61748 10 5.50005 10C6.38262 9.99997 7.25224 10.2123 8.0355 10.619C8.81875 11.0258 9.49264 11.615 10.0003 12.337"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.5952 10C11.4779 9.99936 12.3477 10.2114 13.131 10.6182C13.9143 11.025 14.5881 11.6146 15.0952 12.337"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CommunityIcon } from './CommunityIcon';
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export default function DiscordIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 14 14"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.40571 1.5H12.5943C13.3691 1.5 14 2.13086 14 2.91257V15.2143L12.5257 13.9114L11.696 13.1434L10.8183 12.3274L11.1817 13.596H3.40571C2.63086 13.596 2 12.9651 2 12.1834V2.91257C2 2.13086 2.63086 1.5 3.40571 1.5ZM9.49486 9.9C9.70057 10.1606 9.94743 10.4554 9.94743 10.4554C11.4629 10.4074 12.0457 9.41314 12.0457 9.41314C12.0457 7.20514 11.0583 5.41543 11.0583 5.41543C10.0709 4.67486 9.13143 4.69543 9.13143 4.69543L9.03543 4.80514C10.2011 5.16171 10.7429 5.676 10.7429 5.676C10.0297 5.28514 9.33029 5.09314 8.67886 5.01771C8.18514 4.96286 7.712 4.97657 7.29371 5.03143C7.2578 5.03143 7.2271 5.03665 7.19251 5.04254C7.18748 5.0434 7.18237 5.04427 7.17714 5.04514C6.93714 5.06571 6.35429 5.15486 5.62057 5.47714C5.36686 5.59371 5.216 5.676 5.216 5.676C5.216 5.676 5.78514 5.13429 7.01943 4.77771L6.95086 4.69543C6.95086 4.69543 6.01143 4.67486 5.024 5.41543C5.024 5.41543 4.03657 7.20514 4.03657 9.41314C4.03657 9.41314 4.61257 10.4074 6.128 10.4554C6.128 10.4554 6.38171 10.1469 6.58743 9.88628C5.71657 9.62571 5.38743 9.07714 5.38743 9.07714C5.38743 9.07714 5.456 9.12514 5.57943 9.19371C5.58629 9.20057 5.59314 9.20743 5.60686 9.21428C5.61714 9.22114 5.62743 9.22628 5.63771 9.23143C5.648 9.23657 5.65829 9.24171 5.66857 9.24857C5.84 9.34457 6.01143 9.42 6.16914 9.48171C6.45029 9.59143 6.78629 9.70114 7.17714 9.77657C7.69143 9.87257 8.29486 9.90686 8.95314 9.78343C9.27543 9.72857 9.60457 9.63257 9.94743 9.48857C10.1874 9.39943 10.4549 9.26914 10.736 9.084C10.736 9.084 10.3931 9.64628 9.49486 9.9ZM6.05942 8.01421C6.05942 7.59593 6.36799 7.25307 6.75885 7.25307C7.1497 7.25307 7.46513 7.59593 7.45827 8.01421C7.45827 8.4325 7.1497 8.77536 6.75885 8.77536C6.37485 8.77536 6.05942 8.4325 6.05942 8.01421ZM8.56227 8.01421C8.56227 7.59593 8.87084 7.25307 9.2617 7.25307C9.65256 7.25307 9.96113 7.59593 9.96113 8.01421C9.96113 8.4325 9.65256 8.77536 9.2617 8.77536C8.8777 8.77536 8.56227 8.4325 8.56227 8.01421Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DiscordIcon } from './DiscordIcon';
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export default function EnvelopeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 14 14"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2 3.5H14V12C14 12.1326 13.9473 12.2598 13.8536 12.3536C13.7598 12.4473 13.6326 12.5 13.5 12.5H2.5C2.36739 12.5 2.24021 12.4473 2.14645 12.3536C2.05268 12.2598 2 12.1326 2 12V3.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 3.5L8 9L2 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EnvelopeIcon } from './EnvelopeIcon';
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
function ExclamationFilledIcon(
|
||||
props: IconProps,
|
||||
ref: ForwardedRef<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="7"
|
||||
height="7"
|
||||
viewBox="0 0 7 7"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Exclamation mark"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7 3.5C7 5.433 5.433 7 3.5 7C1.567 7 0 5.433 0 3.5C0 1.567 1.567 0 3.5 0C5.433 0 7 1.567 7 3.5ZM3.96667 5.36667C3.96667 5.6244 3.75773 5.83333 3.5 5.83333C3.24227 5.83333 3.03333 5.6244 3.03333 5.36667C3.03333 5.10893 3.24227 4.9 3.5 4.9C3.75773 4.9 3.96667 5.10893 3.96667 5.36667ZM3.5 1.16667C3.20564 1.16667 2.97296 1.41615 2.99345 1.70979L3.16724 4.20075C3.17943 4.37554 3.32478 4.51111 3.5 4.51111C3.67522 4.51111 3.82057 4.37554 3.83276 4.20075L4.00655 1.70979C4.02704 1.41615 3.79436 1.16667 3.5 1.16667Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ExclamationFilledIcon.displayName = 'NhostExclamationFilledIcon';
|
||||
|
||||
export default forwardRef(ExclamationFilledIcon);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ExclamationFilledIcon } from './ExclamationFilledIcon';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function PowerOffIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
d="M18.36 6.64A9 9 0 0 1 20.77 15M6.16 6.16a9 9 0 1 0 12.68 12.68M12 2v4M2 2l20 20"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
PowerOffIcon.displayName = 'NhostPowerOffIcon';
|
||||
|
||||
export default PowerOffIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as PowerOffIcon } from './PowerOffIcon';
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function QuestionMarkIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="320"
|
||||
height="512"
|
||||
aria-label="Question mark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 320 512"
|
||||
{...props}
|
||||
>
|
||||
<path d="M80 160c0-35.3 28.7-64 64-64h32c35.3 0 64 28.7 64 64v3.6c0 21.8-11.1 42.1-29.4 53.8l-42.2 27.1c-25.2 16.2-40.4 44.1-40.4 74V320c0 17.7 14.3 32 32 32s32-14.3 32-32v-1.4c0-8.2 4.2-15.8 11-20.2l42.2-27.1c36.6-23.6 58.8-64.1 58.8-107.7V160c0-70.7-57.3-128-128-128H144C73.3 32 16 89.3 16 160c0 17.7 14.3 32 32 32s32-14.3 32-32zm80 320a40 40 0 1 0 0-80 40 40 0 1 0 0 80z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
QuestionMarkIcon.displayName = 'NhostQuestionMarkIcon';
|
||||
|
||||
export default QuestionMarkIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as QuestionMarkIcon } from './QuestionMarkIcon';
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function ServicesOutlinedIcon(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="M15.8521 7.06294C15.8143 7.03192 15.485 6.77435 14.7986 6.73709C14.7878 6.7365 14.7769 6.73597 14.7659 6.73549C14.7223 6.7336 14.6772 6.7326 14.6307 6.7326C14.458 6.73338 14.2857 6.74604 14.1147 6.77049C14.0763 6.77598 14.0379 6.78208 13.9996 6.78876C13.9966 6.76748 13.9934 6.74642 13.9899 6.72556C13.9367 6.41145 13.8247 6.14509 13.6926 5.92522C13.5869 5.74941 13.4683 5.60332 13.3565 5.48631C13.1377 5.25722 12.9451 5.13955 12.9269 5.12843L12.7118 5L12.5703 5.21132C12.4532 5.39906 12.3569 5.59952 12.2832 5.80884C12.2455 5.916 12.2137 6.02549 12.188 6.13679C12.1785 6.17826 12.17 6.21957 12.1626 6.26069C12.0784 6.72436 12.1218 7.16514 12.2887 7.56346C12.3311 7.66471 12.3815 7.76323 12.4399 7.85868C12.0678 8.07332 11.471 8.12616 11.3503 8.13083H1.46954C1.21146 8.1312 1.00204 8.34712 1.00063 8.6143C0.989105 9.51047 1.13576 10.4013 1.43337 11.2429C1.77374 12.1671 2.28012 12.8478 2.93893 13.2644C3.67717 13.7325 4.87658 14 6.23616 14C6.85036 14.0019 7.4634 13.9444 8.06725 13.8281C8.90666 13.6685 9.71439 13.3648 10.457 12.9294C11.0689 12.5625 11.6196 12.0958 12.0879 11.5472C12.8644 10.6371 13.3296 9.62426 13.6755 8.72167C13.6783 8.7144 13.6811 8.70714 13.6839 8.69989H13.8221C14.6792 8.69989 15.2062 8.3448 15.4969 8.04724C15.69 7.85746 15.8408 7.62628 15.9386 7.36986L16 7.18397L15.8521 7.06294ZM11.9029 9.4592C11.6843 9.49512 11.4987 9.51062 11.3978 9.51453L11.374 9.51544H2.35702C2.41226 9.93468 2.51073 10.3477 2.65136 10.7474C2.90985 11.445 3.24967 11.8492 3.60646 12.0748L3.60778 12.0757C4.05853 12.3615 4.98755 12.6153 6.23616 12.6153H6.24016C6.77497 12.6171 7.30873 12.567 7.83441 12.4657L7.83733 12.4652C8.53499 12.3325 9.20535 12.0806 9.82092 11.7205C10.31 11.4265 10.7488 11.0538 11.1211 10.6177M11.9029 9.4592C11.6864 9.86254 11.4322 10.2531 11.1211 10.6177L11.9029 9.4592Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2.38514 7.54505H3.7092C3.77306 7.54505 3.8248 7.49328 3.8248 7.42944V6.25005C3.82516 6.18619 3.77369 6.13415 3.70985 6.13379C3.70964 6.13379 3.70941 6.13379 3.7092 6.13379H2.38514C2.32128 6.13379 2.26953 6.18556 2.26953 6.24939V6.25005V7.42942C2.26953 7.49328 2.32128 7.54505 2.38514 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4.21003 7.54505H5.53409C5.59794 7.54505 5.64969 7.49328 5.64969 7.42944V6.25005C5.65005 6.18619 5.59857 6.13415 5.53472 6.13379C5.53451 6.13379 5.53427 6.13379 5.53407 6.13379H4.21001C4.14579 6.13379 4.09375 6.18583 4.09375 6.25005V7.42942C4.09413 7.49339 4.14606 7.54505 4.21003 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.06192 7.54505H7.38597C7.44983 7.54505 7.50157 7.49328 7.50157 7.42944V6.25005C7.50193 6.18619 7.45046 6.13415 7.3866 6.13379C7.38639 6.13379 7.38616 6.13379 7.38595 6.13379H6.06189C5.99804 6.13379 5.94629 6.18556 5.94629 6.24939V6.25005V7.42942C5.94631 7.49328 5.99808 7.54505 6.06192 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.89295 7.54505H9.21701C9.28097 7.54505 9.33291 7.49341 9.33326 7.42944V6.25005C9.33326 6.18583 9.28122 6.13379 9.21701 6.13379H7.89295C7.82909 6.13379 7.77734 6.18556 7.77734 6.24939V6.25005V7.42942C7.77734 7.49328 7.82911 7.54505 7.89295 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4.21001 5.84874H5.53406C5.59801 5.84839 5.64967 5.79643 5.64967 5.73249V4.55311C5.64967 4.48925 5.5979 4.4375 5.53406 4.4375H4.21001C4.14606 4.4375 4.09411 4.48914 4.09375 4.55311V5.73249C4.09411 5.79656 4.14594 5.84839 4.21001 5.84874Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.06189 5.84874H7.38595C7.4499 5.84839 7.50156 5.79643 7.50156 5.73249V4.55311C7.50156 4.48925 7.44979 4.4375 7.38595 4.4375H6.06189C5.99804 4.4375 5.94629 4.48927 5.94629 4.55311V5.73249C5.94629 5.79643 5.99795 5.84839 6.06189 5.84874Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.89295 5.84874H9.21701C9.28108 5.84839 9.33291 5.79656 9.33326 5.73249V4.55311C9.33291 4.48914 9.28097 4.4375 9.21701 4.4375H7.89295C7.82909 4.4375 7.77734 4.48927 7.77734 4.55311V5.73249C7.77734 5.79643 7.82901 5.84839 7.89295 5.84874Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.89295 4.1515H9.21701C9.28097 4.1515 9.33291 4.09983 9.33326 4.03589V2.85584C9.33291 2.79188 9.28097 2.74023 9.21701 2.74023H7.89295C7.82909 2.74023 7.77734 2.79198 7.77734 2.85584V4.03587C7.77734 4.09973 7.82911 4.1515 7.89295 4.1515Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.73963 7.54505H11.0637C11.1277 7.54505 11.1796 7.49341 11.1799 7.42944V6.25005C11.1799 6.18583 11.1279 6.13379 11.0637 6.13379H9.73963C9.67579 6.13379 9.62402 6.18556 9.62402 6.24939V6.25005V7.42942C9.62402 7.49328 9.67579 7.54505 9.73963 7.54505Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ServicesOutlinedIcon.displayName = 'NhostServicesIcon';
|
||||
|
||||
export default ServicesOutlinedIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServicesOutlinedIcon } from './ServicesOutlinedIcon';
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function SlidersIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Sliders"
|
||||
{...props}
|
||||
>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
SlidersIcon.displayName = 'NhostSlidersIcon';
|
||||
|
||||
export default SlidersIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SlidersIcon } from './SlidersIcon';
|
||||
@@ -63,6 +63,9 @@ export default function getDesignTokens(mode: PaletteMode): PaletteOptions {
|
||||
paper: '#171d26',
|
||||
},
|
||||
divider: '#2f363d',
|
||||
beige: {
|
||||
main: '#362c22',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,5 +128,8 @@ export default function getDesignTokens(mode: PaletteMode): PaletteOptions {
|
||||
paper: '#ffffff',
|
||||
},
|
||||
divider: '#eaedf0',
|
||||
beige: {
|
||||
main: '#e5d1bf',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ArgumentsFormSection({
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
className: 'z-[10000] w-[270px]',
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -32,8 +32,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
import { DisableAIServiceConfirmationDialog } from './DisableAIServiceConfirmationDialog';
|
||||
|
||||
const MIN_POSTGRES_VERSION_SUPPORTING_AI = '14.6-20231018-1';
|
||||
import { isPostgresVersionValidForAI } from '@/features/ai/settings/utils/isPostgresVersionValidForAI';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
version: Yup.object({
|
||||
@@ -165,7 +164,7 @@ export default function AISettings() {
|
||||
]);
|
||||
|
||||
const toggleAIService = async (enabled: boolean) => {
|
||||
if (postgresVersion < MIN_POSTGRES_VERSION_SUPPORTING_AI) {
|
||||
if (!isPostgresVersionValidForAI(postgresVersion)) {
|
||||
toast.error(
|
||||
'In order to enable the AI service you need to update your database version to 14.6-20231018-1 or newer.',
|
||||
{
|
||||
@@ -495,3 +494,4 @@ export default function AISettings() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as isPostgresVersionValidForAI } from './isPostgresVersionValidForAI';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test, vi } from 'vitest';
|
||||
|
||||
import isPostgresVersionValidForAI from './isPostgresVersionValidForAI';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test('greater than minimum version, minor version with two digits, should be valid', () => {
|
||||
const postgresVersion = '14.11-20240515-1';
|
||||
expect(isPostgresVersionValidForAI(postgresVersion)).toBe(true);
|
||||
});
|
||||
|
||||
test('less than minimum version, should be invalid', () => {
|
||||
const postgresVersion = '14.6-20221110-1';
|
||||
expect(isPostgresVersionValidForAI(postgresVersion)).toBe(false);
|
||||
});
|
||||
|
||||
test('equal to minimum version, should be valid', () => {
|
||||
const postgresVersion = '14.6-20231018-1';
|
||||
expect(isPostgresVersionValidForAI(postgresVersion)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Check if the given postgres version is valid for enabling AI in the project
|
||||
*
|
||||
* @param postgresVersion - Postgres version used in the project.
|
||||
* @returns Whether is valid for enabling AI.
|
||||
*/
|
||||
export default function isPostgresVersionValidForAI(
|
||||
postgresVersion: string,
|
||||
): boolean {
|
||||
const MIN_POSTGRES_VERSION_SUPPORTING_AI = '14.6-20231018-1';
|
||||
|
||||
if (/^14\.6-/.test(postgresVersion)) {
|
||||
return postgresVersion >= MIN_POSTGRES_VERSION_SUPPORTING_AI;
|
||||
}
|
||||
|
||||
// Note: No need to account for versions less than 14.6
|
||||
return true;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { OverviewDeployments } from '@/features/projects/overview/components/OverviewDeployments';
|
||||
import { OverviewDocumentation } from '@/features/projects/overview/components/OverviewDocumentation';
|
||||
import { OverviewMetrics } from '@/features/projects/overview/components/OverviewMetrics';
|
||||
import { OverviewProjectHealth } from '@/features/projects/overview/components/OverviewProjectHealth';
|
||||
import { OverviewProjectInfo } from '@/features/projects/overview/components/OverviewProjectInfo';
|
||||
import { OverviewRepository } from '@/features/projects/overview/components/OverviewRepository';
|
||||
import { OverviewTopBar } from '@/features/projects/overview/components/OverviewTopBar';
|
||||
@@ -92,6 +93,8 @@ export default function ApplicationLive({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-flow-row content-start gap-8 lg:col-span-1 lg:gap-12">
|
||||
<OverviewProjectHealth />
|
||||
<Divider />
|
||||
<OverviewProjectInfo />
|
||||
<Divider />
|
||||
<OverviewRepository />
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ApplicationLockedReasonProps {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export default function ApplicationLockedReason({
|
||||
reason,
|
||||
}: ApplicationLockedReasonProps) {
|
||||
return (
|
||||
<Alert severity="warning" className="mx-auto max-w-xs gap-2 p-6 ">
|
||||
<Text className="pb-4 text-left">
|
||||
Your project has been temporarily locked due to the following reason:
|
||||
</Text>
|
||||
<Box
|
||||
className="rounded-md p-2"
|
||||
sx={{
|
||||
backgroundColor: 'beige.main',
|
||||
}}
|
||||
>
|
||||
<Text className="px-2 py-1 font-semibold">{reason}</Text>
|
||||
</Box>
|
||||
<Text className="pt-4 text-left">
|
||||
Please{' '}
|
||||
<Link
|
||||
className="font-semibold underline underline-offset-2"
|
||||
href="/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
contact our support
|
||||
</Link>{' '}
|
||||
team for assistance.
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ApplicationLockedReason } from './ApplicationLockedReason';
|
||||
@@ -2,25 +2,24 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { ApplicationInfo } from '@/features/projects/common/components/ApplicationInfo';
|
||||
import { ApplicationLockedReason } from '@/features/projects/common/components/ApplicationLockedReason';
|
||||
import { ApplicationPausedReason } from '@/features/projects/common/components/ApplicationPausedReason';
|
||||
import { ApplicationPausedSymbol } from '@/features/projects/common/components/ApplicationPausedSymbol';
|
||||
import { ChangePlanModal } from '@/features/projects/common/components/ChangePlanModal';
|
||||
import { RemoveApplicationModal } from '@/features/projects/common/components/RemoveApplicationModal';
|
||||
import { StagingMetadata } from '@/features/projects/common/components/StagingMetadata';
|
||||
import { useAppPausedReason } from '@/features/projects/common/hooks/useAppPausedReason';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsCurrentUserOwner } from '@/features/projects/common/hooks/useIsCurrentUserOwner';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useGetFreeAndActiveProjectsQuery,
|
||||
useUnpauseApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { MAX_FREE_PROJECTS } from '@/utils/constants/common';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ApplicationPaused() {
|
||||
@@ -28,7 +27,6 @@ export default function ApplicationPaused() {
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
const user = useUserData();
|
||||
|
||||
const [showDeletingModal, setShowDeletingModal] = useState(false);
|
||||
const [unpauseApplication, { loading: changingApplicationStateLoading }] =
|
||||
@@ -36,13 +34,8 @@ export default function ApplicationPaused() {
|
||||
refetchQueries: [{ query: GetAllWorkspacesAndProjectsDocument }],
|
||||
});
|
||||
|
||||
const { data, loading } = useGetFreeAndActiveProjectsQuery({
|
||||
variables: { userId: user?.id },
|
||||
skip: !user,
|
||||
});
|
||||
|
||||
const numberOfFreeAndLiveProjects = data?.freeAndActiveProjects.length || 0;
|
||||
const wakeUpDisabled = numberOfFreeAndLiveProjects >= MAX_FREE_PROJECTS;
|
||||
const { isLocked, lockedReason, freeAndLiveProjectsNumberExceeded, loading } =
|
||||
useAppPausedReason();
|
||||
|
||||
async function handleTriggerUnpausing() {
|
||||
await execPromiseWithErrorToast(
|
||||
@@ -77,75 +70,67 @@ export default function ApplicationPaused() {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Container className="mx-auto mt-20 grid max-w-lg grid-flow-row gap-4 text-center">
|
||||
<Container className="mx-auto mt-20 grid max-w-lg grid-flow-row gap-6 text-center">
|
||||
<div className="mx-auto flex w-centImage flex-col text-center">
|
||||
<Image
|
||||
src="/assets/PausedApp.svg"
|
||||
alt="Closed Eye"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
<ApplicationPausedSymbol isLocked={isLocked} />
|
||||
</div>
|
||||
|
||||
<Box className="grid grid-flow-row gap-1">
|
||||
<Box className="grid grid-flow-row gap-6">
|
||||
<Text variant="h3" component="h1">
|
||||
{currentProject.name} is sleeping
|
||||
{currentProject.name} is {isLocked ? 'locked' : 'paused'}
|
||||
</Text>
|
||||
{isLocked ? (
|
||||
<ApplicationLockedReason reason={lockedReason} />
|
||||
) : (
|
||||
<>
|
||||
<ApplicationPausedReason
|
||||
freeAndLiveProjectsNumberExceeded={
|
||||
freeAndLiveProjectsNumberExceeded
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-flow-row gap-4">
|
||||
{isOwner && (
|
||||
<Button
|
||||
className="mx-auto w-full max-w-xs"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0' },
|
||||
maxWidth: 'lg',
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="mx-auto w-full max-w-xs"
|
||||
loading={changingApplicationStateLoading}
|
||||
disabled={
|
||||
changingApplicationStateLoading ||
|
||||
freeAndLiveProjectsNumberExceeded
|
||||
}
|
||||
onClick={handleTriggerUnpausing}
|
||||
>
|
||||
Wake Up
|
||||
</Button>
|
||||
|
||||
<Text>
|
||||
Starter projects stop responding to API calls after 7 days of
|
||||
inactivity. Upgrade to Pro to avoid autosleep.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
{isOwner && (
|
||||
<Button
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0' },
|
||||
maxWidth: 'lg',
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
{isOwner && (
|
||||
<Button
|
||||
color="error"
|
||||
variant="outlined"
|
||||
className="mx-auto w-full max-w-xs"
|
||||
onClick={() => setShowDeletingModal(true)}
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
loading={changingApplicationStateLoading}
|
||||
disabled={changingApplicationStateLoading || wakeUpDisabled}
|
||||
onClick={handleTriggerUnpausing}
|
||||
>
|
||||
Wake Up
|
||||
</Button>
|
||||
|
||||
{wakeUpDisabled && (
|
||||
<Alert severity="warning" className="mx-auto max-w-xs text-left">
|
||||
Note: Only one free project can be active at any given time.
|
||||
Please pause your active free project before unpausing{' '}
|
||||
{currentProject.name}.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<Button
|
||||
color="error"
|
||||
variant="borderless"
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
onClick={() => setShowDeletingModal(true)}
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<StagingMetadata>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
|
||||
interface ApplicationPausedReasonProps {
|
||||
freeAndLiveProjectsNumberExceeded?: boolean;
|
||||
}
|
||||
|
||||
export default function ApplicationPausedReason({
|
||||
freeAndLiveProjectsNumberExceeded,
|
||||
}: ApplicationPausedReasonProps) {
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="mx-auto flex max-w-xs flex-col gap-4 p-6 text-left"
|
||||
>
|
||||
<Text>
|
||||
Starter projects will stop responding to API calls after 7 days of
|
||||
inactivity, so consider
|
||||
<span className="font-semibold"> upgrading to Pro </span>to avoid
|
||||
auto-sleep.
|
||||
</Text>
|
||||
{freeAndLiveProjectsNumberExceeded && (
|
||||
<Text>
|
||||
Additionally, only 1 free project can be active at any given time, so
|
||||
please pause your current active free project before unpausing
|
||||
another.
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ApplicationPausedReason } from './ApplicationPausedReason';
|
||||
@@ -0,0 +1,23 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function ApplicationPausedSymbol({
|
||||
isLocked,
|
||||
}: {
|
||||
isLocked?: boolean;
|
||||
}) {
|
||||
if (isLocked) {
|
||||
return (
|
||||
<Image src="/assets/LockedApp.svg" alt="Lock" width={72} height={72} />
|
||||
);
|
||||
}
|
||||
|
||||
// paused
|
||||
return (
|
||||
<Image
|
||||
src="/assets/PausedApp.svg"
|
||||
alt="Closed Eye"
|
||||
width={72}
|
||||
height={72}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ApplicationPausedSymbol } from './ApplicationPausedSymbol';
|
||||
@@ -0,0 +1,192 @@
|
||||
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
useGetConfigRawJsonQuery,
|
||||
useReplaceConfigRawJsonMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import * as TOML from '@iarna/toml';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { bbedit } from '@uiw/codemirror-theme-bbedit';
|
||||
import { githubDark } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function TOMLEditor() {
|
||||
const theme = useTheme();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const [tomlCode, setTOMLCode] = useState('');
|
||||
const [previousTOMLCode, setPreviousTOMLCode] = useState(''); // used to revert changes
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false); // used to show loading spinner on save
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
|
||||
// fetch the initial TOML code from the server
|
||||
const { data, loading } = useGetConfigRawJsonQuery({
|
||||
variables: {
|
||||
appID: currentProject?.id,
|
||||
},
|
||||
skip: !currentProject,
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const [saveConfigMutation] = useReplaceConfigRawJsonMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const removeTOMLIndentation = (tomlStr: string) => {
|
||||
const trimmedLines = tomlStr.split('\n').map((line) => line.trimStart());
|
||||
return trimmedLines.join('\n');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Load TOML code from the server on initial load
|
||||
if (!loading && data) {
|
||||
const jsonData = JSON.parse(data?.configRawJSON);
|
||||
const tomlStr = TOML.stringify(jsonData);
|
||||
const unindentedTOMLConfig = removeTOMLIndentation(tomlStr);
|
||||
setTOMLCode(unindentedTOMLConfig);
|
||||
setPreviousTOMLCode(unindentedTOMLConfig);
|
||||
}
|
||||
}, [loading, data]);
|
||||
|
||||
const onChange = useCallback((value: string) => {
|
||||
setTOMLCode(value);
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleRevert = () => {
|
||||
setTOMLCode(previousTOMLCode);
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
let jsonEditedConfig;
|
||||
try {
|
||||
jsonEditedConfig = TOML.parse(tomlCode);
|
||||
} catch (error) {
|
||||
const toastStyle = getToastStyleProps();
|
||||
const { line, col } = error;
|
||||
let message = `An error occurred while parsing the TOML file. Please check the syntax.`;
|
||||
if (line !== undefined && col !== undefined) {
|
||||
message = `An error occurred while parsing the TOML file. Please check the syntax at line ${line}, column ${col}.`;
|
||||
}
|
||||
toast.error(message, {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
const rawJSONString = JSON.stringify(jsonEditedConfig);
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const {
|
||||
data: { replaceConfigRawJSON: updatedConfig },
|
||||
} = await saveConfigMutation({
|
||||
variables: {
|
||||
appID: currentProject?.id,
|
||||
rawJSON: rawJSONString,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedConfig) {
|
||||
const jsonUpdatedConfig = JSON.parse(updatedConfig);
|
||||
const updatedTOMLConfig = TOML.stringify(jsonUpdatedConfig);
|
||||
const unindentedTOMLConfig = removeTOMLIndentation(updatedTOMLConfig);
|
||||
setTOMLCode(unindentedTOMLConfig);
|
||||
setPreviousTOMLCode(unindentedTOMLConfig);
|
||||
}
|
||||
|
||||
if (!isPlatform) {
|
||||
openDialog({
|
||||
title: 'Apply your changes',
|
||||
component: <ApplyLocalSettingsDialog />,
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'max-w-2xl',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
setIsDirty(false);
|
||||
setIsSaving(false);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Saving configuration...',
|
||||
successMessage: 'Configuration has been saved successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while saving configuration. Please try again.',
|
||||
onError: () => {
|
||||
setIsSaving(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className="flex w-full flex-col space-y-2 border-b p-4">
|
||||
<Text className="font-semibold">Configuration Editor</Text>
|
||||
</Box>
|
||||
<Box className="h-full overflow-auto">
|
||||
{loading ? (
|
||||
<Box
|
||||
className="h-full w-full animate-pulse"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
/>
|
||||
) : (
|
||||
<CodeMirror
|
||||
value={tomlCode}
|
||||
height="100%"
|
||||
width="100%"
|
||||
theme={theme.palette.mode === 'light' ? bbedit : githubDark}
|
||||
basicSetup={{
|
||||
searchKeymap: false,
|
||||
}}
|
||||
extensions={[StreamLanguage.define(toml)]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box className="grid w-full grid-flow-col justify-end gap-3 place-self-end border-t-1 px-4 py-3 md:justify-between">
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={loading || !isDirty}
|
||||
onClick={handleRevert}
|
||||
color="secondary"
|
||||
>
|
||||
Revert changes
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !isDirty}
|
||||
loading={isSaving}
|
||||
className="justify-self-end"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TOMLEditor } from './TOMLEditor';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useAppPausedReason } from './useAppPausedReason';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { MAX_FREE_PROJECTS } from '@/utils/constants/common';
|
||||
import {
|
||||
useGetFreeAndActiveProjectsQuery,
|
||||
useGetProjectIsLockedQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
|
||||
/**
|
||||
* This hook returns the reason why the application is paused.
|
||||
* It returns the locked reason and if the user has exceeded the number of free and live projects.
|
||||
*/
|
||||
|
||||
export default function useAppPausedReason(): {
|
||||
isLocked: boolean;
|
||||
lockedReason: string | undefined;
|
||||
freeAndLiveProjectsNumberExceeded: boolean;
|
||||
loading: boolean;
|
||||
} {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const user = useUserData();
|
||||
const { data, loading } = useGetFreeAndActiveProjectsQuery({
|
||||
variables: { userId: user?.id },
|
||||
skip: !user,
|
||||
});
|
||||
|
||||
const { data: isLockedData } = useGetProjectIsLockedQuery({
|
||||
variables: { appId: currentProject.id },
|
||||
skip: !currentProject,
|
||||
});
|
||||
|
||||
const isLocked = isLockedData?.app?.isLocked;
|
||||
const lockedReason = isLockedData?.app?.isLockedReason;
|
||||
|
||||
const numberOfFreeAndLiveProjects = data?.freeAndActiveProjects.length || 0;
|
||||
const freeAndLiveProjectsNumberExceeded =
|
||||
numberOfFreeAndLiveProjects >= MAX_FREE_PROJECTS;
|
||||
|
||||
return {
|
||||
isLocked,
|
||||
lockedReason,
|
||||
freeAndLiveProjectsNumberExceeded,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
id: null,
|
||||
countryCode: null,
|
||||
city: null,
|
||||
awsName: null,
|
||||
name: null,
|
||||
domain: null,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useServiceStatus } from './useServiceStatus';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import type { ServiceHealthInfo } from '@/features/projects/overview/health';
|
||||
import {
|
||||
useGetProjectServicesHealthQuery,
|
||||
type GetProjectServicesHealthQuery,
|
||||
type GetProjectServicesHealthQueryVariables,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
import { useVisibilityChange } from '@uidotdev/usehooks';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UseServiceStatusOptions
|
||||
extends QueryHookOptions<
|
||||
GetProjectServicesHealthQuery,
|
||||
GetProjectServicesHealthQueryVariables
|
||||
> {
|
||||
shouldPoll?: boolean;
|
||||
}
|
||||
|
||||
export default function useServiceStatus(
|
||||
options: UseServiceStatusOptions = {},
|
||||
) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const isVisible = useVisibilityChange();
|
||||
|
||||
const { data, loading, refetch, startPolling, stopPolling } =
|
||||
useGetProjectServicesHealthQuery({
|
||||
...options,
|
||||
variables: { ...options.variables, appId: currentProject?.id },
|
||||
skip: !isPlatform || !currentProject,
|
||||
skipPollAttempt: () => !isVisible,
|
||||
});
|
||||
|
||||
// Fetch when mounted
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.shouldPoll) {
|
||||
startPolling(options.pollInterval || 10000);
|
||||
}
|
||||
|
||||
return () => stopPolling();
|
||||
}, [stopPolling, startPolling, options.shouldPoll, options.pollInterval]);
|
||||
|
||||
const serviceMap: { [key: string]: ServiceHealthInfo | undefined } = {};
|
||||
data?.getProjectStatus?.services.forEach((service) => {
|
||||
serviceMap[service.name] = service;
|
||||
});
|
||||
const {
|
||||
'hasura-auth': auth,
|
||||
'hasura-storage': storage,
|
||||
postgres,
|
||||
hasura,
|
||||
ai,
|
||||
...run
|
||||
} = serviceMap;
|
||||
|
||||
return {
|
||||
loading,
|
||||
auth,
|
||||
storage,
|
||||
postgres,
|
||||
hasura,
|
||||
ai,
|
||||
run,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useSoftwareVersionsInfo } from './useSoftwareVersionsInfo';
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
Software_Type_Enum,
|
||||
useGetConfiguredVersionsQuery,
|
||||
useGetRecommendedSoftwareVersionsQuery,
|
||||
type GetConfiguredVersionsQuery,
|
||||
type GetConfiguredVersionsQueryVariables,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
import { useVisibilityChange } from '@uidotdev/usehooks';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UseSoftwareVersionsInfoOptions
|
||||
extends QueryHookOptions<
|
||||
GetConfiguredVersionsQuery,
|
||||
GetConfiguredVersionsQueryVariables
|
||||
> {}
|
||||
|
||||
type ServiceVersionInfo = {
|
||||
configuredVersion: string | undefined;
|
||||
recommendedVersions: string[];
|
||||
isVersionMismatch: boolean;
|
||||
};
|
||||
|
||||
export default function useSoftwareVersionsInfo(
|
||||
options: UseSoftwareVersionsInfoOptions = {},
|
||||
): {
|
||||
loading: boolean;
|
||||
auth: ServiceVersionInfo;
|
||||
storage: ServiceVersionInfo;
|
||||
postgres: ServiceVersionInfo;
|
||||
hasura: ServiceVersionInfo;
|
||||
ai: ServiceVersionInfo;
|
||||
isAIEnabled: boolean;
|
||||
} {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const isVisible = useVisibilityChange();
|
||||
|
||||
// Recommended software versions are not polled by default
|
||||
const { data: recommendedVersionsData, loading: loadingRecommendedVersions } =
|
||||
useGetRecommendedSoftwareVersionsQuery({
|
||||
skip: !isPlatform || !currentProject,
|
||||
});
|
||||
|
||||
const {
|
||||
data: configuredVersionsData,
|
||||
loading: loadingConfiguredVersions,
|
||||
refetch: refetchConfiguredVersions,
|
||||
stopPolling,
|
||||
} = useGetConfiguredVersionsQuery({
|
||||
...options,
|
||||
variables: { ...options.variables, appId: currentProject?.id },
|
||||
skip: !isPlatform || !currentProject,
|
||||
skipPollAttempt: () => !isVisible,
|
||||
pollInterval: options.pollInterval || 10000,
|
||||
});
|
||||
|
||||
// fetch when mounted
|
||||
useEffect(() => {
|
||||
refetchConfiguredVersions();
|
||||
return () => stopPolling();
|
||||
}, [refetchConfiguredVersions, stopPolling]);
|
||||
|
||||
const recommendedVersions = {
|
||||
'hasura-auth': [],
|
||||
'hasura-storage': [],
|
||||
postgres: [],
|
||||
hasura: [],
|
||||
ai: [],
|
||||
};
|
||||
|
||||
recommendedVersionsData?.softwareVersions.forEach(({ software, version }) => {
|
||||
switch (software) {
|
||||
case Software_Type_Enum.Auth:
|
||||
recommendedVersions['hasura-auth'].push(version);
|
||||
break;
|
||||
case Software_Type_Enum.Storage:
|
||||
recommendedVersions['hasura-storage'].push(version);
|
||||
break;
|
||||
case Software_Type_Enum.PostgreSql:
|
||||
recommendedVersions.postgres.push(version);
|
||||
break;
|
||||
case Software_Type_Enum.Hasura:
|
||||
recommendedVersions.hasura.push(version);
|
||||
break;
|
||||
case Software_Type_Enum.Graphite:
|
||||
recommendedVersions.ai.push(version);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const isVersionMismatch = (
|
||||
service: string,
|
||||
configuredVersion: string | undefined,
|
||||
) =>
|
||||
!recommendedVersions[service].some(
|
||||
(version) => version === configuredVersion,
|
||||
);
|
||||
|
||||
// Check if configured version can't be found in recommended versions
|
||||
const isAuthVersionMismatch = isVersionMismatch(
|
||||
'hasura-auth',
|
||||
configuredVersionsData?.config?.auth?.version,
|
||||
);
|
||||
const isStorageVersionMismatch = isVersionMismatch(
|
||||
'hasura-storage',
|
||||
configuredVersionsData?.config?.storage?.version,
|
||||
);
|
||||
const isPostgresVersionMismatch = isVersionMismatch(
|
||||
'postgres',
|
||||
configuredVersionsData?.config?.postgres?.version,
|
||||
);
|
||||
const isHasuraVersionMismatch = isVersionMismatch(
|
||||
'hasura',
|
||||
configuredVersionsData?.config?.hasura?.version,
|
||||
);
|
||||
const isAIVersionMismatch = isVersionMismatch(
|
||||
'ai',
|
||||
configuredVersionsData?.config?.ai?.version,
|
||||
);
|
||||
|
||||
return {
|
||||
loading: loadingConfiguredVersions || loadingRecommendedVersions,
|
||||
auth: {
|
||||
configuredVersion: configuredVersionsData?.config?.auth?.version,
|
||||
recommendedVersions: recommendedVersions['hasura-auth'],
|
||||
isVersionMismatch: isAuthVersionMismatch,
|
||||
},
|
||||
storage: {
|
||||
configuredVersion: configuredVersionsData?.config?.storage?.version,
|
||||
recommendedVersions: recommendedVersions['hasura-storage'],
|
||||
isVersionMismatch: isStorageVersionMismatch,
|
||||
},
|
||||
postgres: {
|
||||
configuredVersion: configuredVersionsData?.config?.postgres?.version,
|
||||
recommendedVersions: recommendedVersions.postgres,
|
||||
isVersionMismatch: isPostgresVersionMismatch,
|
||||
},
|
||||
hasura: {
|
||||
configuredVersion: configuredVersionsData?.config?.hasura?.version,
|
||||
recommendedVersions: recommendedVersions.hasura,
|
||||
isVersionMismatch: isHasuraVersionMismatch,
|
||||
},
|
||||
ai: {
|
||||
configuredVersion: configuredVersionsData?.config?.ai?.version,
|
||||
recommendedVersions: recommendedVersions.ai,
|
||||
isVersionMismatch: isAIVersionMismatch,
|
||||
},
|
||||
isAIEnabled: Boolean(configuredVersionsData?.config?.ai),
|
||||
};
|
||||
}
|
||||
@@ -31,7 +31,7 @@ afterEach(() => {
|
||||
|
||||
const region: ProjectFragment['region'] = {
|
||||
id: '1',
|
||||
awsName: 'eu-west-1',
|
||||
name: 'eu-west-1',
|
||||
domain: 'nhost.run',
|
||||
city: 'Dublin',
|
||||
countryCode: 'IE',
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function generateAppServiceUrl(
|
||||
const constructedDomain = [
|
||||
subdomain,
|
||||
service,
|
||||
region?.awsName,
|
||||
region?.name,
|
||||
region?.domain || 'nhost.run',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -165,7 +165,7 @@ export default function AuthDomain() {
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={auth_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
value={`lb.${currentProject.region.name}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function HasuraDomain() {
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={hasura_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
value={`lb.${currentProject.region.name}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function RunServicePortDomain({
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={runServicePortFQDN}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
value={`lb.${currentProject.region.name}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function ServerlessFunctionsDomain() {
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={functions_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
value={`lb.${currentProject.region.name}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
|
||||
const systemEnvironmentVariables = [
|
||||
{ key: 'NHOST_SUBDOMAIN', value: currentProject.subdomain },
|
||||
{ key: 'NHOST_REGION', value: currentProject.region.awsName },
|
||||
{ key: 'NHOST_REGION', value: currentProject.region.name },
|
||||
{
|
||||
key: 'NHOST_HASURA_URL',
|
||||
value:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { CheckIcon } from '@/components/ui/v2/icons/CheckIcon';
|
||||
import { QuestionMarkIcon } from '@/components/ui/v2/icons/QuestionMarkIcon';
|
||||
import { serviceStateToThemeColor } from '@/features/projects/overview/health';
|
||||
import { ServiceState } from '@/utils/__generated__/graphql';
|
||||
|
||||
interface AccordionHealthBadgeProps {
|
||||
serviceState?: ServiceState;
|
||||
unknownState?: boolean;
|
||||
/*
|
||||
* Blinking animation to indicate that the service is updating.
|
||||
*/
|
||||
blink?: boolean;
|
||||
}
|
||||
|
||||
export default function AccordionHealthBadge({
|
||||
serviceState,
|
||||
unknownState,
|
||||
blink,
|
||||
}: AccordionHealthBadgeProps) {
|
||||
if (unknownState) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className="flex h-2.5 w-2.5 items-center justify-center rounded-full"
|
||||
>
|
||||
<QuestionMarkIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-3/4 w-3/4 stroke-2"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceState === ServiceState.Running) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className="flex h-2.5 w-2.5 items-center justify-center rounded-full"
|
||||
>
|
||||
<CheckIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-3/4 w-3/4 stroke-2"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(serviceState),
|
||||
}}
|
||||
className={`h-2.5 w-2.5 rounded-full ${blink ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AccordionHealthBadge } from './AccordionHealthBadge';
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { AIIcon } from '@/components/ui/v2/icons/AIIcon';
|
||||
import { DatabaseIcon } from '@/components/ui/v2/icons/DatabaseIcon';
|
||||
import { HasuraIcon } from '@/components/ui/v2/icons/HasuraIcon';
|
||||
import { ServicesOutlinedIcon } from '@/components/ui/v2/icons/ServicesOutlinedIcon';
|
||||
import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useServiceStatus } from '@/features/projects/common/hooks/useServiceStatus';
|
||||
import { useSoftwareVersionsInfo } from '@/features/projects/common/hooks/useSoftwareVersionsInfo';
|
||||
import { OverviewProjectHealthModal } from '@/features/projects/overview/components/OverviewProjectHealthModal';
|
||||
import { ProjectHealthCard } from '@/features/projects/overview/components/ProjectHealthCard';
|
||||
import { RunStatusTooltip } from '@/features/projects/overview/components/RunStatusTooltip';
|
||||
import { ServiceVersionTooltip } from '@/features/projects/overview/components/ServiceVersionTooltip';
|
||||
import {
|
||||
baseServices,
|
||||
findHighestImportanceState,
|
||||
} from '@/features/projects/overview/health';
|
||||
|
||||
export default function OverviewProjectHealth() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
const {
|
||||
loading: loadingVersions,
|
||||
auth: authVersionInfo,
|
||||
storage: storageVersionInfo,
|
||||
postgres: postgresVersionInfo,
|
||||
hasura: hasuraVersionInfo,
|
||||
ai: aiVersionInfo,
|
||||
isAIEnabled,
|
||||
} = useSoftwareVersionsInfo();
|
||||
|
||||
const {
|
||||
loading: loadingProjectServicesHealth,
|
||||
auth: authStatus,
|
||||
storage: storageStatus,
|
||||
postgres: postgresStatus,
|
||||
hasura: hasuraStatus,
|
||||
ai: aiStatus,
|
||||
run: runStatus,
|
||||
} = useServiceStatus({
|
||||
shouldPoll: true,
|
||||
});
|
||||
|
||||
if (loadingVersions || loadingProjectServicesHealth) {
|
||||
return (
|
||||
<div className="grid grid-flow-row content-start gap-6">
|
||||
<Text variant="h3">Project Health</Text>
|
||||
<div className="flex flex-row flex-wrap items-center justify-start gap-2 lg:gap-2">
|
||||
<ProjectHealthCard
|
||||
isLoading
|
||||
icon={<UserIcon className="m-1 h-6 w-6" />}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
isLoading
|
||||
icon={<DatabaseIcon className="m-1 h-6 w-6" />}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
isLoading
|
||||
icon={<StorageIcon className="m-1 h-6 w-6" />}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
isLoading
|
||||
icon={<HasuraIcon className="m-1 h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const openHealthModal = async (
|
||||
defaultExpanded: keyof typeof baseServices | 'run',
|
||||
) => {
|
||||
openDialog({
|
||||
component: (
|
||||
<OverviewProjectHealthModal defaultExpanded={defaultExpanded} />
|
||||
),
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-2xl w-full' },
|
||||
titleProps: {
|
||||
onClose: closeDialog,
|
||||
},
|
||||
},
|
||||
title: 'Service State',
|
||||
});
|
||||
};
|
||||
|
||||
const authTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices['hasura-auth'].displayName}
|
||||
serviceKey="hasura-auth"
|
||||
usedVersion={authVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={authVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={authVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={authStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const hasuraTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices.hasura.displayName}
|
||||
serviceKey="hasura"
|
||||
usedVersion={hasuraVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={hasuraVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={hasuraVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={hasuraStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const postgresTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices.postgres.displayName}
|
||||
serviceKey="postgres"
|
||||
usedVersion={postgresVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={postgresVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={postgresVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={postgresStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const storageTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices['hasura-storage'].displayName}
|
||||
serviceKey="hasura-storage"
|
||||
usedVersion={storageVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={storageVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={storageVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={storageStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const aiTooltipElem = (
|
||||
<ServiceVersionTooltip
|
||||
serviceName={baseServices.ai.displayName}
|
||||
serviceKey="ai"
|
||||
usedVersion={aiVersionInfo?.configuredVersion ?? ''}
|
||||
recommendedVersionMismatch={aiVersionInfo?.isVersionMismatch}
|
||||
recommendedVersions={aiVersionInfo?.recommendedVersions}
|
||||
openHealthModal={openHealthModal}
|
||||
state={aiStatus?.state}
|
||||
/>
|
||||
);
|
||||
|
||||
const runServices = Object.values(runStatus).filter((service) =>
|
||||
service?.name?.startsWith('run-'),
|
||||
);
|
||||
|
||||
const runServicesStates = runServices.map((service) => service.state);
|
||||
|
||||
const runServicesState = findHighestImportanceState(runServicesStates);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row content-start gap-6">
|
||||
<Text variant="h3">Project Health</Text>
|
||||
|
||||
{currentProject && (
|
||||
<div className="flex flex-row flex-wrap items-center justify-start gap-2 lg:gap-2">
|
||||
<ProjectHealthCard
|
||||
icon={<UserIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={authTooltipElem}
|
||||
isVersionMismatch={authVersionInfo?.isVersionMismatch}
|
||||
state={authStatus?.state}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
icon={<DatabaseIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={postgresTooltipElem}
|
||||
isVersionMismatch={postgresVersionInfo?.isVersionMismatch}
|
||||
state={postgresStatus?.state}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
icon={<StorageIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={storageTooltipElem}
|
||||
isVersionMismatch={storageVersionInfo?.isVersionMismatch}
|
||||
state={storageStatus?.state}
|
||||
/>
|
||||
<ProjectHealthCard
|
||||
icon={<HasuraIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={hasuraTooltipElem}
|
||||
isVersionMismatch={hasuraVersionInfo?.isVersionMismatch}
|
||||
state={hasuraStatus?.state}
|
||||
/>
|
||||
{isAIEnabled && (
|
||||
<ProjectHealthCard
|
||||
icon={<AIIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={aiTooltipElem}
|
||||
isVersionMismatch={aiVersionInfo?.isVersionMismatch}
|
||||
state={aiStatus?.state}
|
||||
/>
|
||||
)}
|
||||
{Object.values(runServices).length > 0 && (
|
||||
<ProjectHealthCard
|
||||
icon={<ServicesOutlinedIcon className="m-1 h-6 w-6" />}
|
||||
tooltip={
|
||||
<RunStatusTooltip
|
||||
servicesStatusInfo={Object.values(runServices)}
|
||||
openHealthModal={openHealthModal}
|
||||
/>
|
||||
}
|
||||
state={runServicesState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as OverviewProjectHealth } from './OverviewProjectHealth';
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { AIIcon } from '@/components/ui/v2/icons/AIIcon';
|
||||
import { DatabaseIcon } from '@/components/ui/v2/icons/DatabaseIcon';
|
||||
import { HasuraIcon } from '@/components/ui/v2/icons/HasuraIcon';
|
||||
import { ServicesOutlinedIcon } from '@/components/ui/v2/icons/ServicesOutlinedIcon';
|
||||
import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { useServiceStatus } from '@/features/projects/common/hooks/useServiceStatus';
|
||||
import { ServiceAccordion } from '@/features/projects/overview/components/ServiceAccordion';
|
||||
import {
|
||||
findHighestImportanceState,
|
||||
type baseServices,
|
||||
} from '@/features/projects/overview/health';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface OverviewProjectHealthModalProps {
|
||||
defaultExpanded?: keyof typeof baseServices | 'run';
|
||||
}
|
||||
|
||||
export default function OverviewProjectHealthModal({
|
||||
defaultExpanded,
|
||||
}: OverviewProjectHealthModalProps) {
|
||||
const { auth, storage, postgres, hasura, ai, run } = useServiceStatus({
|
||||
fetchPolicy: 'cache-only',
|
||||
shouldPoll: false,
|
||||
});
|
||||
|
||||
const runServices = Object.values(run).filter((service) =>
|
||||
service.name.startsWith('run-'),
|
||||
);
|
||||
|
||||
const isAuthExpandedByDefault = defaultExpanded === 'hasura-auth';
|
||||
const isPostgresExpandedByDefault = defaultExpanded === 'postgres';
|
||||
const isStorageExpandedByDefault = defaultExpanded === 'hasura-storage';
|
||||
const isHasuraExpandedByDefault = defaultExpanded === 'hasura';
|
||||
const isAIExpandedByDefault = defaultExpanded === 'ai';
|
||||
const isRunExpandedByDefault = defaultExpanded === 'run';
|
||||
|
||||
const getServiceInfo = (service) => {
|
||||
const info = removeTypename(service);
|
||||
return JSON.stringify(info, null, 2);
|
||||
};
|
||||
|
||||
const serviceInfo = {
|
||||
auth: getServiceInfo(auth),
|
||||
storage: getServiceInfo(storage),
|
||||
postgres: getServiceInfo(postgres),
|
||||
hasura: getServiceInfo(hasura),
|
||||
ai: getServiceInfo(ai),
|
||||
run: getServiceInfo(Object.values(runServices)),
|
||||
};
|
||||
|
||||
const runServicesState = findHighestImportanceState(
|
||||
Object.values(runServices).map((service) => service.state),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg pt-2 text-left')}>
|
||||
<Box
|
||||
sx={{
|
||||
borderColor: 'text.dark',
|
||||
}}
|
||||
className="grid grid-flow-row"
|
||||
>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<UserIcon className="h-4 w-4" />}
|
||||
serviceName="Auth"
|
||||
serviceInfo={serviceInfo.auth}
|
||||
replicaCount={auth?.replicas?.length}
|
||||
serviceState={auth?.state}
|
||||
defaultExpanded={isAuthExpandedByDefault}
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<DatabaseIcon className="h-4 w-4" />}
|
||||
serviceName="Postgres"
|
||||
serviceInfo={serviceInfo.postgres}
|
||||
replicaCount={postgres?.replicas?.length}
|
||||
serviceState={postgres?.state}
|
||||
defaultExpanded={isPostgresExpandedByDefault}
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<StorageIcon className="h-4 w-4" />}
|
||||
serviceName="Storage"
|
||||
serviceInfo={serviceInfo.storage}
|
||||
replicaCount={storage?.replicas?.length}
|
||||
serviceState={storage?.state}
|
||||
defaultExpanded={isStorageExpandedByDefault}
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<HasuraIcon className="h-4 w-4" />}
|
||||
serviceName="Hasura"
|
||||
serviceInfo={serviceInfo.hasura}
|
||||
replicaCount={hasura?.replicas?.length}
|
||||
serviceState={hasura?.state}
|
||||
defaultExpanded={isHasuraExpandedByDefault}
|
||||
/>
|
||||
{ai ? (
|
||||
<>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<AIIcon className="h-4 w-4" />}
|
||||
serviceName="AI"
|
||||
serviceInfo={serviceInfo.ai}
|
||||
replicaCount={ai?.replicas?.length}
|
||||
serviceState={ai?.state}
|
||||
defaultExpanded={isAIExpandedByDefault}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{runServices && Object.values(runServices).length > 0 ? (
|
||||
<>
|
||||
<Divider />
|
||||
<ServiceAccordion
|
||||
icon={<ServicesOutlinedIcon className="h-4 w-4" />}
|
||||
serviceName="Run"
|
||||
serviceInfo={serviceInfo.run}
|
||||
replicaCount={0}
|
||||
serviceState={runServicesState}
|
||||
defaultExpanded={isRunExpandedByDefault}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as OverviewProjectHealthModal } from './OverviewProjectHealthModal';
|
||||
@@ -7,7 +7,7 @@ export default function OverviewProjectInfo() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { region, subdomain } = currentProject || {};
|
||||
const isRegionAvailable =
|
||||
region?.awsName && region?.countryCode && region?.city;
|
||||
region?.name && region?.countryCode && region?.city;
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row content-start gap-6">
|
||||
@@ -17,7 +17,7 @@ export default function OverviewProjectInfo() {
|
||||
<div className="grid grid-flow-row gap-3">
|
||||
<InfoCard
|
||||
title="Region"
|
||||
value={region?.awsName}
|
||||
value={region?.name}
|
||||
customValue={
|
||||
region?.countryCode &&
|
||||
region?.city && (
|
||||
@@ -30,7 +30,7 @@ export default function OverviewProjectInfo() {
|
||||
/>
|
||||
|
||||
<Text className="truncate text-sm font-medium">
|
||||
{region.city} ({region.awsName})
|
||||
{region.city} ({region.name})
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Badge, type BadgeProps } from '@/components/ui/v2/Badge';
|
||||
import { CheckIcon } from '@/components/ui/v2/icons/CheckIcon';
|
||||
import { ExclamationFilledIcon } from '@/components/ui/v2/icons/ExclamationFilledIcon';
|
||||
import { QuestionMarkIcon } from '@/components/ui/v2/icons/QuestionMarkIcon';
|
||||
|
||||
export interface ProjectHealthBadgeProps extends BadgeProps {
|
||||
badgeVariant?: 'standard' | 'dot';
|
||||
badgeColor?: 'success' | 'error' | 'warning' | 'secondary';
|
||||
unknownState?: boolean;
|
||||
showExclamation?: boolean;
|
||||
showCheckIcon?: boolean;
|
||||
isLoading?: boolean;
|
||||
blink?: boolean;
|
||||
}
|
||||
|
||||
export default function ProjectHealthBadge({
|
||||
badgeColor,
|
||||
badgeVariant,
|
||||
showExclamation,
|
||||
showCheckIcon,
|
||||
unknownState,
|
||||
blink,
|
||||
children,
|
||||
...props
|
||||
}: ProjectHealthBadgeProps) {
|
||||
let innerBadgeContent = null;
|
||||
if (unknownState) {
|
||||
innerBadgeContent = (
|
||||
<QuestionMarkIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-2 w-2 stroke-2"
|
||||
/>
|
||||
);
|
||||
} else if (showCheckIcon) {
|
||||
innerBadgeContent = (
|
||||
<CheckIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.100',
|
||||
}}
|
||||
className="h-2 w-2 stroke-2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!badgeColor) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
if (showExclamation) {
|
||||
return (
|
||||
<Badge
|
||||
variant="standard"
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
badgeContent={
|
||||
<ExclamationFilledIcon
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'grey.600',
|
||||
}}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={badgeColor}
|
||||
variant={badgeVariant}
|
||||
badgeContent={innerBadgeContent}
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.900' : 'text.primary',
|
||||
}}
|
||||
componentsProps={{
|
||||
badge: {
|
||||
className: blink ? 'animate-pulse' : '',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={badgeColor}
|
||||
variant={badgeVariant}
|
||||
badgeContent={innerBadgeContent}
|
||||
componentsProps={{
|
||||
badge: {
|
||||
className: blink ? 'animate-pulse' : '',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProjectHealthBadge } from './ProjectHealthBadge';
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Tooltip, tooltipClasses } from '@/components/ui/v2/Tooltip';
|
||||
import { ProjectHealthBadge } from '@/features/projects/overview/components/ProjectHealthBadge';
|
||||
import { serviceStateToBadgeColor } from '@/features/projects/overview/health';
|
||||
import { ServiceState } from '@/utils/__generated__/graphql';
|
||||
import type { ImageProps } from 'next/image';
|
||||
import Image from 'next/image';
|
||||
import type { ReactElement } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ProjectHealthCardProps extends BoxProps {
|
||||
/**
|
||||
* Label of the card icon.
|
||||
*/
|
||||
alt?: string;
|
||||
/**
|
||||
* Tooltip of the card.
|
||||
*/
|
||||
tooltip?: ReactElement | null;
|
||||
/**
|
||||
* Icon to display on the card.
|
||||
*/
|
||||
icon: string | ReactElement;
|
||||
/**
|
||||
* Light version of the icon. This is used for the dark mode.
|
||||
*/
|
||||
lightIcon?: string | ReactElement;
|
||||
/**
|
||||
* Determines whether the icon should have a background.
|
||||
* @default false
|
||||
*/
|
||||
disableIconBackground?: boolean;
|
||||
/**
|
||||
* Determines whether the icon is a react component.
|
||||
* @default true
|
||||
*/
|
||||
iconIsComponent?: boolean;
|
||||
/**
|
||||
* Props to be passed to the internal components.
|
||||
*/
|
||||
slotProps?: {
|
||||
imgIcon?: Partial<ImageProps>;
|
||||
};
|
||||
/**
|
||||
* State of the service.
|
||||
*/
|
||||
state?: ServiceState;
|
||||
|
||||
/**
|
||||
* Determines whether the version is mismatched with recommended version.
|
||||
*/
|
||||
isVersionMismatch?: boolean;
|
||||
|
||||
/**
|
||||
* Determines whether the card is loading.
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function ProjectHealthCard({
|
||||
alt,
|
||||
tooltip,
|
||||
icon,
|
||||
iconIsComponent = true,
|
||||
className,
|
||||
slotProps = {},
|
||||
isVersionMismatch = false,
|
||||
isLoading = false,
|
||||
state,
|
||||
...props
|
||||
}: ProjectHealthCardProps) {
|
||||
const badgeColor = serviceStateToBadgeColor.get(state);
|
||||
const unknownState = state === undefined;
|
||||
let badgeVariant: 'dot' | 'standard' = 'dot';
|
||||
if (state === ServiceState.Running || unknownState) {
|
||||
badgeVariant = 'standard';
|
||||
}
|
||||
const showCheckIcon = state === ServiceState.Running;
|
||||
const shouldBlink = state === ServiceState.Updating;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={tooltip}
|
||||
slotProps={{
|
||||
popper: {
|
||||
sx: {
|
||||
[`&.${tooltipClasses.popper} .${tooltipClasses.tooltip}`]: {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.100' : 'grey.200',
|
||||
minWidth: '18rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className={twMerge(
|
||||
'grid aspect-square min-w-12 max-w-14 grid-flow-row gap-0 rounded-md p-0',
|
||||
className,
|
||||
)}
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-flow-col items-center justify-center">
|
||||
<ProjectHealthBadge
|
||||
badgeColor={!isLoading ? badgeColor : undefined}
|
||||
badgeVariant={badgeVariant}
|
||||
showCheckIcon={showCheckIcon}
|
||||
showExclamation={isVersionMismatch}
|
||||
unknownState={unknownState}
|
||||
blink={shouldBlink}
|
||||
>
|
||||
{iconIsComponent
|
||||
? icon
|
||||
: typeof icon === 'string' && (
|
||||
<Image
|
||||
src={icon}
|
||||
alt={alt}
|
||||
width={slotProps.imgIcon?.width}
|
||||
height={slotProps.imgIcon?.height}
|
||||
{...slotProps.imgIcon}
|
||||
/>
|
||||
)}
|
||||
</ProjectHealthBadge>
|
||||
</div>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProjectHealthCard } from './ProjectHealthCard';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import {
|
||||
serviceStateToThemeColor,
|
||||
type baseServices,
|
||||
type ServiceHealthInfo,
|
||||
} from '@/features/projects/overview/health';
|
||||
import { ServiceState } from '@/generated/graphql';
|
||||
|
||||
export interface RunStatusTooltipProps {
|
||||
servicesStatusInfo?: Array<ServiceHealthInfo>;
|
||||
openHealthModal?: (
|
||||
defaultExpanded?: keyof typeof baseServices | 'run',
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function RunStatusTooltip({
|
||||
servicesStatusInfo,
|
||||
openHealthModal,
|
||||
}: RunStatusTooltipProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 px-2 py-3">
|
||||
<ol className="m-0 flex flex-col gap-3">
|
||||
{servicesStatusInfo.map((service) => (
|
||||
<li
|
||||
key={service.name}
|
||||
className="flex flex-row items-center gap-4 text-ellipsis text-nowrap leading-5"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: serviceStateToThemeColor.get(service.state),
|
||||
}}
|
||||
className={`h-3 w-3 flex-shrink-0 rounded-full ${
|
||||
service.state === ServiceState.Updating ? 'animate-pulse' : ''
|
||||
}`}
|
||||
/>
|
||||
<Text
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.primary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
className="font-semibold"
|
||||
>
|
||||
{service.name}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Button variant="outlined" onClick={() => openHealthModal('run')}>
|
||||
View state
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RunStatusTooltip } from './RunStatusTooltip';
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CodeBlock } from '@/components/presentational/CodeBlock';
|
||||
import { Accordion } from '@/components/ui/v2/Accordion';
|
||||
import { ChevronDownIcon } from '@/components/ui/v2/icons/ChevronDownIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { AccordionHealthBadge } from '@/features/projects/overview/components/AccordionHealthBadge';
|
||||
import { ServiceState } from '@/utils/__generated__/graphql';
|
||||
import Image from 'next/image';
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
export interface ServiceAccordionProps {
|
||||
serviceName: string;
|
||||
serviceInfo: string;
|
||||
replicaCount: number;
|
||||
serviceState: ServiceState;
|
||||
/**
|
||||
* Icon to display on the accordion.
|
||||
*/
|
||||
icon?: string | ReactElement;
|
||||
/**
|
||||
* Label of the icon.
|
||||
*/
|
||||
alt?: string;
|
||||
iconIsComponent?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export default function ServiceAccordion({
|
||||
serviceName,
|
||||
serviceInfo,
|
||||
replicaCount,
|
||||
serviceState,
|
||||
icon,
|
||||
iconIsComponent = true,
|
||||
alt,
|
||||
defaultExpanded = false,
|
||||
}: ServiceAccordionProps) {
|
||||
const unknownState = serviceState === undefined;
|
||||
|
||||
const replicasLabel = replicaCount === 1 ? 'replica' : 'replicas';
|
||||
|
||||
const blink = serviceState === ServiceState.Updating;
|
||||
|
||||
return (
|
||||
<Accordion.Root defaultExpanded={defaultExpanded}>
|
||||
<Accordion.Summary
|
||||
expandIcon={
|
||||
<ChevronDownIcon
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
aria-controls="panel1-content"
|
||||
id="panel1-header"
|
||||
className="px-6"
|
||||
>
|
||||
<div className="flex flex-row justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{iconIsComponent
|
||||
? icon
|
||||
: typeof icon === 'string' && <Image src={icon} alt={alt} />}
|
||||
<Text
|
||||
sx={{ color: 'text.primary' }}
|
||||
variant="h4"
|
||||
className="font-semibold"
|
||||
>
|
||||
{serviceName}{' '}
|
||||
{!unknownState && replicaCount && replicasLabel ? (
|
||||
<Text
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
component="span"
|
||||
className="font-semibold"
|
||||
>
|
||||
({replicaCount} {replicasLabel})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<AccordionHealthBadge
|
||||
serviceState={serviceState}
|
||||
unknownState={unknownState}
|
||||
blink={blink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Summary>
|
||||
<Accordion.Details>
|
||||
<CodeBlock copyToClipboardToastTitle={`${serviceName} status`}>
|
||||
{serviceInfo}
|
||||
</CodeBlock>
|
||||
</Accordion.Details>
|
||||
</Accordion.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServiceAccordion } from './ServiceAccordion';
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { baseServices } from '@/features/projects/overview/health';
|
||||
import { ServiceState } from '@/utils/__generated__/graphql';
|
||||
import { useTheme } from '@mui/material';
|
||||
|
||||
interface ServiceVersionTooltipProps {
|
||||
serviceName?: string;
|
||||
serviceKey?: keyof typeof baseServices;
|
||||
usedVersion?: string;
|
||||
recommendedVersionMismatch?: boolean;
|
||||
recommendedVersions?: string[];
|
||||
children?: React.ReactNode;
|
||||
openHealthModal?: (
|
||||
defaultExpanded?: keyof typeof baseServices | 'run',
|
||||
) => void;
|
||||
state?: ServiceState;
|
||||
}
|
||||
|
||||
function ServiceVersionTooltip({
|
||||
serviceName,
|
||||
usedVersion,
|
||||
recommendedVersionMismatch,
|
||||
recommendedVersions,
|
||||
children,
|
||||
openHealthModal,
|
||||
state,
|
||||
serviceKey,
|
||||
}: ServiceVersionTooltipProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
let errorMessage = '';
|
||||
if (state === ServiceState.UpdateError) {
|
||||
errorMessage =
|
||||
'degraded or failed to update, click on view state for further details';
|
||||
} else if (state === ServiceState.Error || state === ServiceState.None) {
|
||||
errorMessage =
|
||||
'is offline due to errors, click on view state for further details';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-2 py-3">
|
||||
<div className="flex flex-row justify-between gap-6">
|
||||
<Text
|
||||
sx={{
|
||||
color:
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.secondary'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
variant="h4"
|
||||
component="p"
|
||||
className="text-sm+"
|
||||
>
|
||||
service
|
||||
</Text>
|
||||
<Text
|
||||
sx={{
|
||||
color:
|
||||
theme.palette.mode === 'dark' ? 'text.primary' : 'text.primary',
|
||||
}}
|
||||
variant="h4"
|
||||
component="p"
|
||||
className="text-sm+ font-semibold"
|
||||
>
|
||||
{serviceName}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between gap-6">
|
||||
<Text
|
||||
sx={{
|
||||
color:
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.secondary'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
variant="h4"
|
||||
component="p"
|
||||
className="text-sm+"
|
||||
>
|
||||
version
|
||||
</Text>
|
||||
<Text
|
||||
sx={{
|
||||
color:
|
||||
theme.palette.mode === 'dark' ? 'text.primary' : 'text.primary',
|
||||
}}
|
||||
variant="h4"
|
||||
component="p"
|
||||
className="text-sm+ font-bold"
|
||||
>
|
||||
{usedVersion}
|
||||
</Text>
|
||||
</div>
|
||||
{recommendedVersionMismatch && (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.300',
|
||||
}}
|
||||
className="rounded-md p-2"
|
||||
>
|
||||
<Text
|
||||
sx={{
|
||||
color:
|
||||
theme.palette.mode === 'dark' ? 'text.primary' : 'text.primary',
|
||||
}}
|
||||
variant="body1"
|
||||
component="p"
|
||||
className="text-sm+"
|
||||
>
|
||||
{serviceName} is not using a recommended version. Recommended
|
||||
version(s):
|
||||
</Text>
|
||||
<ul className="list-disc text-sm+">
|
||||
{recommendedVersions.map((version) => (
|
||||
<li className="ml-6 list-item" key={version}>
|
||||
<Text
|
||||
sx={{
|
||||
color:
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.primary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
variant="body1"
|
||||
component="p"
|
||||
>
|
||||
{version}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Box>
|
||||
)}
|
||||
{errorMessage ? (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'dark' ? 'error.dark' : 'error.main',
|
||||
}}
|
||||
className="rounded-md p-2"
|
||||
>
|
||||
<Text
|
||||
variant="body1"
|
||||
component="p"
|
||||
className="text-sm+ font-semibold text-white"
|
||||
>
|
||||
{serviceName} {errorMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Button variant="outlined" onClick={() => openHealthModal(serviceKey)}>
|
||||
View state
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceVersionTooltip;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServiceVersionTooltip } from './ServiceVersionTooltip';
|
||||
80
dashboard/src/features/projects/overview/health/health.ts
Normal file
80
dashboard/src/features/projects/overview/health/health.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
ServiceState,
|
||||
type GetProjectServicesHealthQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
|
||||
export type ServiceHealthInfo =
|
||||
GetProjectServicesHealthQuery['getProjectStatus']['services'][number];
|
||||
|
||||
export const baseServices = {
|
||||
'hasura-auth': {
|
||||
displayName: 'Auth',
|
||||
softwareVersionsName: 'Auth',
|
||||
},
|
||||
hasura: {
|
||||
displayName: 'Hasura',
|
||||
softwareVersionsName: 'Hasura',
|
||||
},
|
||||
postgres: {
|
||||
displayName: 'Postgres',
|
||||
softwareVersionsName: 'PostgreSQL',
|
||||
},
|
||||
'hasura-storage': {
|
||||
displayName: 'Storage',
|
||||
softwareVersionsName: 'Storage',
|
||||
},
|
||||
ai: {
|
||||
displayName: 'Graphite',
|
||||
softwareVersionsName: 'Graphite',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const serviceStateToThemeColor = new Map<ServiceState, string>([
|
||||
[ServiceState.Running, 'success.dark'],
|
||||
[ServiceState.Error, 'error.main'],
|
||||
[ServiceState.UpdateError, 'error.main'],
|
||||
[ServiceState.Updating, 'warning.dark'],
|
||||
[ServiceState.None, 'error.main'],
|
||||
[undefined, 'grey.500'],
|
||||
]);
|
||||
|
||||
export const serviceStateToBadgeColor = new Map<
|
||||
ServiceState,
|
||||
'success' | 'error' | 'warning' | 'secondary'
|
||||
>([
|
||||
[ServiceState.Running, 'success'],
|
||||
[ServiceState.Error, 'error'],
|
||||
[ServiceState.UpdateError, 'error'],
|
||||
[ServiceState.Updating, 'warning'],
|
||||
[ServiceState.None, 'error'],
|
||||
[undefined, 'secondary'], // secondary is used for unknown states
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the highest importance state from a list of service states
|
||||
* Example: [Running, Running, Error] => Error
|
||||
*/
|
||||
export const findHighestImportanceState = (
|
||||
servicesStates: ServiceState[],
|
||||
): ServiceState => {
|
||||
const serviceStateToImportance = new Map([
|
||||
[ServiceState.Running, 0],
|
||||
[ServiceState.Updating, 1],
|
||||
[ServiceState.UpdateError, 2],
|
||||
[ServiceState.Error, 3],
|
||||
[ServiceState.None, 4],
|
||||
]);
|
||||
|
||||
if (servicesStates.length === 0) {
|
||||
return ServiceState.None;
|
||||
}
|
||||
|
||||
return servicesStates.reduce((acc, state) => {
|
||||
if (
|
||||
serviceStateToImportance.get(state) > serviceStateToImportance.get(acc)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return acc;
|
||||
}, ServiceState.Running);
|
||||
};
|
||||
1
dashboard/src/features/projects/overview/health/index.ts
Normal file
1
dashboard/src/features/projects/overview/health/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './health';
|
||||
@@ -31,6 +31,7 @@ import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useInsertRunServiceMutation,
|
||||
@@ -99,38 +100,43 @@ export default function ServiceForm({
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const getFormattedConfig = (values: ServiceFormValues) => {
|
||||
// Remove any __typename property from the values
|
||||
const sanitizedValues = removeTypename(values) as ServiceFormValues;
|
||||
|
||||
const config: ConfigRunServiceConfigInsertInput = {
|
||||
name: values.name,
|
||||
name: sanitizedValues.name,
|
||||
image: {
|
||||
image: values.image,
|
||||
image: sanitizedValues.image,
|
||||
},
|
||||
command: parse(values.command).map((item) => item.toString()),
|
||||
command: parse(sanitizedValues.command).map((item) => item.toString()),
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: values.compute.cpu,
|
||||
memory: values.compute.memory,
|
||||
cpu: sanitizedValues.compute.cpu,
|
||||
memory: sanitizedValues.compute.memory,
|
||||
},
|
||||
storage: values.storage.map((item) => ({
|
||||
storage: sanitizedValues.storage.map((item) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
capacity: item.capacity,
|
||||
})),
|
||||
replicas: values.replicas,
|
||||
replicas: sanitizedValues.replicas,
|
||||
},
|
||||
environment: values.environment.map((item) => ({
|
||||
environment: sanitizedValues.environment.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
})),
|
||||
ports: values.ports.map((item) => ({
|
||||
ports: sanitizedValues.ports.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type,
|
||||
publish: item.publish,
|
||||
ingresses: item.ingresses,
|
||||
})),
|
||||
healthCheck: values.healthCheck
|
||||
healthCheck: sanitizedValues.healthCheck
|
||||
? {
|
||||
port: values.healthCheck?.port,
|
||||
initialDelaySeconds: values.healthCheck?.initialDelaySeconds,
|
||||
probePeriodSeconds: values.healthCheck?.probePeriodSeconds,
|
||||
port: sanitizedValues.healthCheck?.port,
|
||||
initialDelaySeconds:
|
||||
sanitizedValues.healthCheck?.initialDelaySeconds,
|
||||
probePeriodSeconds: sanitizedValues.healthCheck?.probePeriodSeconds,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
@@ -190,7 +196,7 @@ export default function ServiceForm({
|
||||
image:
|
||||
values.image.length > 0
|
||||
? values.image
|
||||
: `registry.${currentProject.region.awsName}.${currentProject.region.domain}/${newServiceID}`,
|
||||
: `registry.${currentProject.region.name}.${currentProject.region.domain}/${newServiceID}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -361,7 +367,7 @@ export default function ServiceForm({
|
||||
{isPlatform && serviceID && serviceImage && (
|
||||
<InfoCard
|
||||
title="Private registry"
|
||||
value={`registry.${currentProject.region.awsName}.${currentProject.region.domain}/${serviceID}`}
|
||||
value={`registry.${currentProject.region.name}.${currentProject.region.domain}/${serviceID}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ export const validationSchema = Yup.object({
|
||||
port: Yup.number().required(),
|
||||
type: Yup.mixed<PortTypes>().oneOf(Object.values(PortTypes)).required(),
|
||||
publish: Yup.boolean().default(false),
|
||||
ingresses: Yup.array()
|
||||
.of(
|
||||
Yup.object().shape({
|
||||
fqdn: Yup.array().of(Yup.string()),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
storage: Yup.array().of(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import { PortTypes } from '@/features/services/components/ServiceForm/components/PortsFormSection/PortsFormSectionTypes';
|
||||
import { type ServiceFormValues } from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { getRunServicePortURL } from '@/utils/helpers';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function PortsFormSection() {
|
||||
@@ -40,14 +41,8 @@ export default function PortsFormSection() {
|
||||
formValues.ports[index]?.type === PortTypes.HTTP &&
|
||||
formValues.ports[index]?.publish;
|
||||
|
||||
const getPortURL = (_port: string | number, subdomain: string) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<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">
|
||||
@@ -69,14 +64,14 @@ export default function PortsFormSection() {
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => append({ port: null, type: null, publish: false })}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -107,7 +102,7 @@ export default function PortsFormSection() {
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
className: 'z-[10000] w-[270px]',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -133,16 +128,18 @@ export default function PortsFormSection() {
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{showURL(index) && (
|
||||
<InfoCard
|
||||
title="URL"
|
||||
value={getPortURL(
|
||||
formValues.ports[index]?.port,
|
||||
formValues.subdomain,
|
||||
value={getRunServicePortURL(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region.name,
|
||||
currentProject?.region.domain,
|
||||
formValues.ports[index],
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import { getRunServicePortURL } from '@/utils/helpers';
|
||||
import type { ConfigRunServicePort } from '@/utils/__generated__/graphql';
|
||||
|
||||
export interface ServiceDetailsDialogProps {
|
||||
@@ -32,11 +33,7 @@ export default function ServiceDetailsDialog({
|
||||
|
||||
const { closeDialog } = useDialog();
|
||||
|
||||
const getPortURL = (_port: string | number) => {
|
||||
const port = Number(_port) > 0 ? Number(_port) : '[port]';
|
||||
|
||||
return `https://${subdomain}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
|
||||
};
|
||||
const publishedPorts = ports.filter((port) => port.publish);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-6 pb-6">
|
||||
@@ -44,22 +41,25 @@ export default function ServiceDetailsDialog({
|
||||
<Text color="secondary">Private registry</Text>
|
||||
<InfoCard
|
||||
title=""
|
||||
value={`registry.${currentProject.region.awsName}.${currentProject.region.domain}/${serviceID}`}
|
||||
value={`registry.${currentProject.region.name}.${currentProject.region.domain}/${serviceID}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ports?.length > 0 && (
|
||||
{publishedPorts?.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text color="secondary">Ports</Text>
|
||||
{ports
|
||||
.filter((port) => port.publish)
|
||||
.map((port) => (
|
||||
<InfoCard
|
||||
key={String(port.port)}
|
||||
title={`${port.type} <--> ${port.port}`}
|
||||
value={getPortURL(port.port)}
|
||||
/>
|
||||
))}
|
||||
{publishedPorts.map((port) => (
|
||||
<InfoCard
|
||||
key={String(port.port)}
|
||||
title={`${port.type} <--> ${port.port}`}
|
||||
value={getRunServicePortURL(
|
||||
subdomain,
|
||||
currentProject?.region.name,
|
||||
currentProject?.region.domain,
|
||||
port,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function ServicesList({
|
||||
port: item.port,
|
||||
type: item.type as PortTypes,
|
||||
publish: item.publish,
|
||||
ingresses: item.ingresses,
|
||||
})),
|
||||
compute: service.config?.resources?.compute ?? {
|
||||
cpu: 62,
|
||||
|
||||
19
dashboard/src/gql/app/getConfiguredVersions.graphql
Normal file
19
dashboard/src/gql/app/getConfiguredVersions.graphql
Normal file
@@ -0,0 +1,19 @@
|
||||
query getConfiguredVersions($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
auth {
|
||||
version
|
||||
}
|
||||
postgres {
|
||||
version
|
||||
}
|
||||
hasura {
|
||||
version
|
||||
}
|
||||
ai {
|
||||
version
|
||||
}
|
||||
storage {
|
||||
version
|
||||
}
|
||||
}
|
||||
}
|
||||
6
dashboard/src/gql/app/getProjectIsLocked.graphql
Normal file
6
dashboard/src/gql/app/getProjectIsLocked.graphql
Normal file
@@ -0,0 +1,6 @@
|
||||
query getProjectIsLocked($appId: uuid!) {
|
||||
app(id: $appId) {
|
||||
isLocked
|
||||
isLockedReason
|
||||
}
|
||||
}
|
||||
20
dashboard/src/gql/app/getProjectServicesHealth.gql
Normal file
20
dashboard/src/gql/app/getProjectServicesHealth.gql
Normal file
@@ -0,0 +1,20 @@
|
||||
query getProjectServicesHealth($appId: String!) {
|
||||
getProjectStatus(appID: $appId) {
|
||||
services {
|
||||
name
|
||||
state
|
||||
replicas {
|
||||
ready
|
||||
date
|
||||
errors {
|
||||
name
|
||||
lastError {
|
||||
reason
|
||||
exitCode
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
dashboard/src/gql/app/settings/getConfigRawJSON.graphql
Normal file
3
dashboard/src/gql/app/settings/getConfigRawJSON.graphql
Normal file
@@ -0,0 +1,3 @@
|
||||
query getConfigRawJSON ($appID: uuid!) {
|
||||
configRawJSON(appID: $appID, resolve: false)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation ReplaceConfigRawJSON($appID: uuid!, $rawJSON: String!) {
|
||||
replaceConfigRawJSON(appID: $appID, rawJSON: $rawJSON)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ fragment Project on apps {
|
||||
region {
|
||||
id
|
||||
countryCode
|
||||
awsName
|
||||
name
|
||||
domain
|
||||
city
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user