diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cf13e16 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +Dockerfile \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14082fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +/packages/api-client/dist/ +build +**/*.sqlite +.idea +dist \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5660f81 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..25bf17f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7695c33 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM ubuntu:22.04 +ENV WC__DATABASE=/var/lib/db.sqlite + +COPY ./build/watchtower-linux /usr/local/bin/watchtower + +RUN chmod +x /usr/local/bin/watchtower + +VOLUME /var/lib + +ENTRYPOINT ["/usr/local/bin/watchtower"] diff --git a/apps/client/.gitignore b/apps/client/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/client/README.md b/apps/client/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/apps/client/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/apps/client/eslint.config.js b/apps/client/eslint.config.js new file mode 100644 index 0000000..c4bc8f3 --- /dev/null +++ b/apps/client/eslint.config.js @@ -0,0 +1,26 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config({ + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + ignores: ['dist'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +}) diff --git a/apps/client/index.html b/apps/client/index.html new file mode 100644 index 0000000..aeb7f91 --- /dev/null +++ b/apps/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Watchtower + + +
+ + + diff --git a/apps/client/package.json b/apps/client/package.json new file mode 100644 index 0000000..36369b0 --- /dev/null +++ b/apps/client/package.json @@ -0,0 +1,62 @@ +{ + "name": "@watchtower/client", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@mantine/charts": "^7.12.1", + "@mantine/core": "^7.12.1", + "@mantine/dates": "^7.12.1", + "@mantine/form": "^7.12.1", + "@mantine/hooks": "^7.12.1", + "@mantine/modals": "^7.12.1", + "@mantine/notifications": "^7.12.1", + "@mantine/tiptap": "^7.12.1", + "@monaco-editor/react": "4.6.0", + "@tabler/icons-react": "^3.12.0", + "@tiptap/extension-link": "^2.6.4", + "@tiptap/pm": "^2.6.4", + "@tiptap/react": "^2.6.4", + "@tiptap/starter-kit": "^2.6.4", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "@types/ms": "^0.7.34", + "@watchtower/api-client": "^0.0.1", + "axios": "^1.7.4", + "date-fns": "^3.6.0", + "dayjs": "^1.11.12", + "dracula-mantine": "^1.1.0", + "fuse.js": "^7.0.0", + "lodash": "^4.17.21", + "mantine-form-zod-resolver": "^1.1.0", + "marked": "^14.0.0", + "monaco-editor": "^0.50.0", + "ms": "^2.1.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-icons": "^5.2.1", + "react-router-dom": "^6.26.0", + "recharts": "^2.12.7", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.2" + }, + "devDependencies": { + "@eslint/js": "^9.8.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^9.8.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "typescript": "^5.5.3", + "typescript-eslint": "^8.0.0", + "vite": "^5.4.0" + } +} diff --git a/apps/client/public/vite.svg b/apps/client/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx new file mode 100644 index 0000000..9b8b2a0 --- /dev/null +++ b/apps/client/src/App.tsx @@ -0,0 +1,147 @@ +import { + ActionIcon, + AppShell, + Burger, + Card, + Group, + Text, + ThemeIcon, + useMantineColorScheme, + useMantineTheme, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { FC } from "react"; +import { FaWatchmanMonitoring } from "react-icons/fa"; +import { + TbActivityHeartbeat, + TbAlertHexagon, + TbCircle, + TbCircleFilled, + TbCircleHalf, + TbShare, +} from "react-icons/tb"; +import { Route, Routes } from "react-router-dom"; +import { AppNavigationButton } from "./components/app-navigation-button.tsx"; +import { AppNavigationLink } from "./components/app-navigation-link.tsx"; +import { MonitorEditPage } from "./pages/monitor-edit.page.tsx"; +import { MonitorPage } from "./pages/monitor.page.tsx"; +import { MonitorsPage } from "./pages/monitors.page.tsx"; + +const navigation = [ + { + path: "/monitors", + label: "Monitoring", + leftSection: , + }, + { + path: "/incidents", + label: "Incidents", + leftSection: , + }, + { + path: "/integrations", + label: "Integrations", + leftSection: , + }, +]; + +export const App: FC = () => { + const theme = useMantineTheme(); + const scheme = useMantineColorScheme(); + const [opened, { toggle }] = useDisclosure(); + + return ( + + + + + + + + + + watchtower + + + + + {navigation.map(({ path, label, leftSection }) => ( + + ))} + + {scheme.colorScheme === "auto" ? ( + + ) : scheme.colorScheme === "light" ? ( + + ) : ( + + )} + + + + + + + + } /> + } /> + } /> + + + + {navigation.map(({ path, label, leftSection }) => ( + + ))} + + + ); +}; diff --git a/apps/client/src/assets/Portentous Distorted.otf b/apps/client/src/assets/Portentous Distorted.otf new file mode 100644 index 0000000..7f55989 Binary files /dev/null and b/apps/client/src/assets/Portentous Distorted.otf differ diff --git a/apps/client/src/assets/index.css b/apps/client/src/assets/index.css new file mode 100644 index 0000000..03550f4 --- /dev/null +++ b/apps/client/src/assets/index.css @@ -0,0 +1,6 @@ +@font-face { + font-family: 'Portentous Distorted'; + src: + local('Portentous Distorted'), + url('./Portentous Distorted.otf') format('opentype'); +} \ No newline at end of file diff --git a/apps/client/src/components/app-navigation-button.tsx b/apps/client/src/components/app-navigation-button.tsx new file mode 100644 index 0000000..b542258 --- /dev/null +++ b/apps/client/src/components/app-navigation-button.tsx @@ -0,0 +1,23 @@ +import { Button } from "@mantine/core"; +import { FC, ReactNode } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +export const AppNavigationButton: FC<{ + path: string; + label: ReactNode; + leftSection: ReactNode; +}> = ({ label, path, leftSection }) => { + const navigate = useNavigate(); + const location = useLocation(); + + return ( + + ); +}; diff --git a/apps/client/src/components/app-navigation-link.tsx b/apps/client/src/components/app-navigation-link.tsx new file mode 100644 index 0000000..ecd1063 --- /dev/null +++ b/apps/client/src/components/app-navigation-link.tsx @@ -0,0 +1,21 @@ +import { NavLink } from "@mantine/core"; +import { FC, ReactNode } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +export const AppNavigationLink: FC<{ + path: string; + label: ReactNode; + leftSection: ReactNode; +}> = ({ label, path, leftSection }) => { + const navigate = useNavigate(); + const location = useLocation(); + + return ( + navigate(path)} + leftSection={leftSection} + label={label} + /> + ); +}; diff --git a/apps/client/src/components/current-status-card.tsx b/apps/client/src/components/current-status-card.tsx new file mode 100644 index 0000000..b872653 --- /dev/null +++ b/apps/client/src/components/current-status-card.tsx @@ -0,0 +1,27 @@ +import { Card, Stack, Text, Title } from "@mantine/core"; +import { useViewportSize } from "@mantine/hooks"; +import { MonitorDto } from "@watchtower/api-client"; +import { FC } from "react"; +import { ViewportBreakpoint } from "../viewport-breakpoint.ts"; +import { HistoryItem } from "./monitor-history.tsx"; +import { MonitorStatusBadge } from "./monitor-status-badge.tsx"; + +export const CurrentStatusCard: FC<{ + monitor: MonitorDto; + history: Array; +}> = ({ monitor, history }) => { + const { width } = useViewportSize(); + + return ( + + + Current Status + ViewportBreakpoint.sm ? "xl" : "lg"} + history={history} + /> + Checking every {monitor.interval}s + + + ); +}; diff --git a/apps/client/src/components/duration-card.tsx b/apps/client/src/components/duration-card.tsx new file mode 100644 index 0000000..f48f2fa --- /dev/null +++ b/apps/client/src/components/duration-card.tsx @@ -0,0 +1,74 @@ +import { AreaChart, Sparkline } from "@mantine/charts"; +import { Card, Group, Stack, Text, Title } from "@mantine/core"; +import { useViewportSize } from "@mantine/hooks"; +import { differenceInMilliseconds } from "date-fns"; +import { max, mean, min } from "lodash"; +import ms from "ms"; +import { FC, useMemo } from "react"; +import { ViewportBreakpoint } from "../viewport-breakpoint.ts"; +import { HistoryItem } from "./monitor-history.tsx"; +export const DurationCard: FC<{ + history: Array; +}> = ({ history }) => { + const { width } = useViewportSize(); + + const historyDurations = useMemo(() => { + const duration = history.map((it) => + differenceInMilliseconds(it.finishedAt, it.startedAt), + ); + const _min = min(duration); + const _max = max(duration); + const _average = mean(duration); + + return { + min: _min ? ms(+_min.toFixed(0)) : "-", + max: _max ? ms(+_max.toFixed(0)) : "-", + average: _average ? ms(+_average.toFixed(0)) : "-", + }; + }, [history]); + + return ( + + + + Duration + + {width > ViewportBreakpoint.sm ? ( + ({ + ...it, + name: it.startedAt.toLocaleTimeString(), + duration: differenceInMilliseconds(it.finishedAt, it.startedAt), + }))} + series={[{ name: "duration", color: "pink" }]} + dataKey={"name"} + yAxisLabel={"Milliseconds"} + /> + ) : ( + + differenceInMilliseconds(it.finishedAt, it.startedAt), + )} + /> + )} + + + {historyDurations.average} + Average + + + {historyDurations.min} + Min + + + {historyDurations.max} + Max + + + + + ); +}; diff --git a/apps/client/src/components/hook-debugger.tsx b/apps/client/src/components/hook-debugger.tsx new file mode 100644 index 0000000..1e0a8ba --- /dev/null +++ b/apps/client/src/components/hook-debugger.tsx @@ -0,0 +1,51 @@ +import { Dialog, List, Stack, Title } from "@mantine/core"; +import { AxiosError, AxiosResponse } from "axios"; +import { FC } from "react"; +import { + TbAlertCircleFilled, + TbCircleCheck, + TbCircleDotted, +} from "react-icons/tb"; +import { KeyedMutator } from "swr"; + +type OrvalHook = { + data: AxiosResponse | undefined; + error: AxiosError | undefined; + mutate: KeyedMutator; + isValidating: boolean; + isLoading: boolean; + swrKey: any; +}; + +export const SWRHookInfo: FC<{ hooks: Array<[string, OrvalHook]> }> = ({ + hooks, +}) => { + return ( + + + SWR Debugger + + + {hooks.map(([name, hook]) => ( + + {hook.isLoading && ( + }> + {name} + + )} + {hook.error && ( + }> + {name} + + )} + {hook.data && ( + }> + {name} + + )} + + ))} + + + ); +}; diff --git a/apps/client/src/components/monitor-card.tsx b/apps/client/src/components/monitor-card.tsx new file mode 100644 index 0000000..6a8513e --- /dev/null +++ b/apps/client/src/components/monitor-card.tsx @@ -0,0 +1,39 @@ +import { Card, Group, Stack, Text } from "@mantine/core"; +import { FC } from "react"; +import { HistoryItem, MonitorHistory } from "./monitor-history.tsx"; +import { MonitorStatusBadge } from "./monitor-status-badge.tsx"; + +export type MonitorCardProps = { + onClick: () => void; + name: string; + type: string; + history: Array; +}; +export const MonitorCard: FC = ({ + name, + type, + history, + onClick, +}) => { + return ( + + + + + + + {name} + + + {type} + + + + + + + + + + ); +}; diff --git a/apps/client/src/components/monitor-history.tsx b/apps/client/src/components/monitor-history.tsx new file mode 100644 index 0000000..eaeb87c --- /dev/null +++ b/apps/client/src/components/monitor-history.tsx @@ -0,0 +1,73 @@ +import { Stack, Text, Tooltip } from "@mantine/core"; +import { useHover } from "@mantine/hooks"; +import { FC, useMemo } from "react"; +import { useStatusColorResolver } from "../hooks/useStatusColorResolver.ts"; +import { useStatusMessageResolver } from "../hooks/useStatusMessageResolver.ts"; + +const MonitorHistoryItem: FC<{ history?: HistoryItem }> = ({ history }) => { + const hover = useHover(); + const getColor = useStatusColorResolver(); + const getStatus = useStatusMessageResolver(); + + return ( + + + {history?.startedAt.toLocaleString()} + + + {getStatus(history?.success)} + + + } + > +
+ + ); +}; + +export type HistoryItem = { + id: number; + success: boolean; + startedAt: Date; + finishedAt: Date; +}; +export type MonitorHistoryProps = { + history: Array; +}; +export const MonitorHistory: FC = ({ history }) => { + const paddedHistory: Array = useMemo( + () => [...new Array(30), ...history].slice(-30), + [history], + ); + + return ( +
+ {paddedHistory.map((history) => ( + + ))} +
+ ); +}; diff --git a/apps/client/src/components/monitor-status-badge.tsx b/apps/client/src/components/monitor-status-badge.tsx new file mode 100644 index 0000000..1f4a46a --- /dev/null +++ b/apps/client/src/components/monitor-status-badge.tsx @@ -0,0 +1,21 @@ +import { Badge, BadgeProps } from "@mantine/core"; +import { FC } from "react"; +import { useStatusColorResolver } from "../hooks/useStatusColorResolver.ts"; +import { useStatusMessageResolver } from "../hooks/useStatusMessageResolver.ts"; +import { HistoryItem } from "./monitor-history.tsx"; + +export const MonitorStatusBadge: FC<{ + size?: BadgeProps["size"]; + history: Array; +}> = ({ history, size }) => { + const isUp = history.slice(-1).pop()?.success; + + const getColor = useStatusColorResolver(); + const getStatus = useStatusMessageResolver(); + + return ( + + {getStatus(isUp)} + + ); +}; diff --git a/apps/client/src/components/monitor-type-docs.tsx b/apps/client/src/components/monitor-type-docs.tsx new file mode 100644 index 0000000..65ad56c --- /dev/null +++ b/apps/client/src/components/monitor-type-docs.tsx @@ -0,0 +1,21 @@ +import { RichTextEditor } from "@mantine/tiptap"; +import { useEditor } from "@tiptap/react"; +import { StarterKit } from "@tiptap/starter-kit"; +import { marked } from "marked"; +import { FC } from "react"; + +export type MonitorTypeDocsProps = { + content: string; +}; +export const MonitorTypeDocs: FC = ({ content }) => { + const editor = useEditor({ + extensions: [StarterKit], + content: marked(content), + }); + + return ( + + + + ); +}; diff --git a/apps/client/src/components/status-card.tsx b/apps/client/src/components/status-card.tsx new file mode 100644 index 0000000..dbb587f --- /dev/null +++ b/apps/client/src/components/status-card.tsx @@ -0,0 +1,20 @@ +import { Card, Stack, Text, Title } from "@mantine/core"; +import { last } from "lodash"; +import { FC } from "react"; +import { HistoryItem, MonitorHistory } from "./monitor-history.tsx"; + +export const StatusCard: FC<{ + history: Array; +}> = ({ history }) => { + return ( + + + Status + + + Last Checked: {last(history)?.startedAt.toLocaleString() || "Never"} + + + + ); +}; diff --git a/apps/client/src/config.ts b/apps/client/src/config.ts new file mode 100644 index 0000000..82327e2 --- /dev/null +++ b/apps/client/src/config.ts @@ -0,0 +1 @@ +export const SWR_DEBUG = false; diff --git a/apps/client/src/forms/edit-monitor-form.tsx b/apps/client/src/forms/edit-monitor-form.tsx new file mode 100644 index 0000000..a06243b --- /dev/null +++ b/apps/client/src/forms/edit-monitor-form.tsx @@ -0,0 +1,169 @@ +import { + ActionIcon, + Alert, + Button, + Group, + NumberInput, + Stack, + Text, + TextInput, + Title, + useComputedColorScheme, +} from "@mantine/core"; +import { useForm, zodResolver } from "@mantine/form"; +import Editor, { Monaco } from "@monaco-editor/react"; +import { monitorTypeControllerValidateSchema } from "@watchtower/api-client"; +import { JSONSchema7 } from "json-schema"; +import { last } from "lodash"; +import { FC, useState } from "react"; +import { TbHelpCircle } from "react-icons/tb"; +import { z } from "zod"; +import { dracula } from "../monco-themes.ts"; + +export const configHttpFormSchema = z.object({ + name: z.string().min(2, { message: "Name should have at least 2 letters" }), + interval: z.number().min(60, { + message: "MonitorType Interval should be greater than 60 seconds", + }), +}); +export type EditMonitorFormValues = z.infer; + +export type EditMonitorFormProps = { + initialConfig: any; + initialValues: EditMonitorFormValues; + monitorTypeSchema: JSONSchema7; + onShowDocs: () => void; + onSubmit: (values: EditMonitorFormValues, config: any) => void; +}; +export const EditMonitorForm: FC = ({ + initialConfig, + onSubmit, + initialValues, + monitorTypeSchema, + onShowDocs, +}) => { + const colorScheme = useComputedColorScheme(); + const form = useForm({ + mode: "uncontrolled", + initialValues, + validate: zodResolver(configHttpFormSchema), + validateInputOnBlur: true, + }); + + const handleEditorWillMount = (monaco: Monaco) => { + monaco.editor.defineTheme("dracula", dracula); + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + allowComments: true, + schemaValidation: "error", + enableSchemaRequest: true, + schemaRequest: "error", + schemas: [ + { + uri: monitorTypeSchema.$id || "", + fileMatch: ["*"], + schema: monitorTypeSchema, + }, + ], + }); + }; + const [config, setConfig] = useState( + JSON.stringify(initialConfig, undefined, 2), + ); + + const [configError, setConfigError] = useState(null); + + const handleSubmit = async (values: EditMonitorFormValues) => { + try { + const parsedConfig = JSON.parse(config); + await monitorTypeControllerValidateSchema( + last(monitorTypeSchema.$id?.split("/")) as string, + parsedConfig, + ); + setConfigError(null); + onSubmit(values, JSON.parse(config)); + } catch (e) { + setConfigError(e); + } + }; + + return ( + <> +
+ + + Properties + + + + + + + Configuration + + Setup your monitor configuration using the editor below, open + the documentation using the Help icon for more information. + + + + + + + + {configError && ( + +
+									{JSON.stringify(
+										configError.response.data.message,
+										undefined,
+										2,
+									)}
+								
+
+ )} + + v && setConfig(v)} + /> +
+ + + +
+
+ + ); +}; diff --git a/apps/client/src/forms/http.schema.ts b/apps/client/src/forms/http.schema.ts new file mode 100644 index 0000000..7fff0a5 --- /dev/null +++ b/apps/client/src/forms/http.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const httpSchema = z.object({ + url: z.string(), + timeout: z.number().min(1).default(30), + statusCodes: z.array(z.enum(["2xx", "3xx", "4xx", "5xx"])).default(["2xx"]), +}); diff --git a/apps/client/src/forms/new-monitor.form.tsx b/apps/client/src/forms/new-monitor.form.tsx new file mode 100644 index 0000000..fe485fe --- /dev/null +++ b/apps/client/src/forms/new-monitor.form.tsx @@ -0,0 +1,52 @@ +import { Button, Group, Select, Stack, TextInput } from "@mantine/core"; +import { useForm, zodResolver } from "@mantine/form"; +import { FC } from "react"; +import { z } from "zod"; + +export const newMonitorFormSchema = z.object({ + name: z.string().min(2, { message: "Name should have at least 2 letters" }), + type: z.string(), +}); + +export type NewMonitorFormValues = z.infer; + +export const NewMonitorForm: FC<{ + onSubmit: (values: NewMonitorFormValues) => void; + types: { id: string; name: string; description: string }[]; +}> = ({ onSubmit, types }) => { + const form = useForm({ + mode: "uncontrolled", + initialValues: { name: "", type: "" }, + validate: zodResolver(newMonitorFormSchema), + validateInputOnBlur: true, + }); + + return ( +
+ + +