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 (
+
+ );
+};
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 (
+ <>
+
+ >
+ );
+};
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 (
+
+ );
+};
diff --git a/apps/client/src/functions/deleteMonitor.ts b/apps/client/src/functions/deleteMonitor.ts
new file mode 100644
index 0000000..5c536dd
--- /dev/null
+++ b/apps/client/src/functions/deleteMonitor.ts
@@ -0,0 +1,28 @@
+import { openConfirmModal } from "@mantine/modals";
+import { monitorControllerDeleteMonitor } from "@watchtower/api-client";
+import { NavigateFunction } from "react-router-dom";
+import { showErrorNotification } from "./showErrorNotification.tsx";
+import { showSuccessNotification } from "./showSuccessNotification.tsx";
+
+const onConfirm = async (id: number, navigate: NavigateFunction) => {
+ try {
+ await monitorControllerDeleteMonitor(id);
+ showSuccessNotification({ message: "Monitor deleted successfully." });
+ navigate("/monitors");
+ } catch (error) {
+ showErrorNotification({ error });
+ }
+};
+
+/**
+ * Opens a confirmation modal to delete a monitor.
+ * If confirmed, the monitor is deleted by calling the monitorControllerDeleteMonitor API.
+ */
+export const deleteMonitor = (id: number, navigate: NavigateFunction) => {
+ openConfirmModal({
+ title: "Delete Monitor",
+ children: "Are you sure you want to delete this monitor?",
+ labels: { confirm: "Delete", cancel: "Cancel" },
+ onConfirm: () => onConfirm(id, navigate),
+ });
+};
diff --git a/apps/client/src/functions/openNewMonitorFormModal.tsx b/apps/client/src/functions/openNewMonitorFormModal.tsx
new file mode 100644
index 0000000..600fa4b
--- /dev/null
+++ b/apps/client/src/functions/openNewMonitorFormModal.tsx
@@ -0,0 +1,46 @@
+import { modals, openModal } from "@mantine/modals";
+import {
+ MonitorTypeDto,
+ monitorControllerAddMonitor,
+} from "@watchtower/api-client";
+import { NavigateFunction } from "react-router-dom";
+import {
+ NewMonitorForm,
+ NewMonitorFormValues,
+} from "../forms/new-monitor.form.tsx";
+import { showErrorNotification } from "./showErrorNotification.tsx";
+import { showSuccessNotification } from "./showSuccessNotification.tsx";
+
+const onSubmit =
+ (navigate: NavigateFunction) =>
+ async ({ name, type }: NewMonitorFormValues) => {
+ try {
+ const monitor = await monitorControllerAddMonitor({
+ name,
+ type,
+ });
+ showSuccessNotification({
+ message: "Monitor created successfully",
+ });
+ modals.closeAll();
+ navigate(`/monitors/${monitor.data.id}`);
+ } catch (e) {
+ showErrorNotification(e);
+ }
+ };
+
+/**
+ * Opens a modal to create a new monitor.
+ * The monitor is created by calling the monitorControllerAddMonitor API.
+ */
+export const openNewMonitorFormModal = (
+ monitorTypes: MonitorTypeDto[],
+ navigate: NavigateFunction,
+) => {
+ openModal({
+ title: "New Monitor",
+ children: (
+
+ ),
+ });
+};
diff --git a/apps/client/src/functions/showErrorNotification.tsx b/apps/client/src/functions/showErrorNotification.tsx
new file mode 100644
index 0000000..a75d489
--- /dev/null
+++ b/apps/client/src/functions/showErrorNotification.tsx
@@ -0,0 +1,16 @@
+import { showNotification } from "@mantine/notifications";
+import { ReactNode } from "react";
+import { TbExclamationCircle } from "react-icons/tb";
+
+export type ErrorNotificationProps = {
+ title?: ReactNode;
+ message?: ReactNode;
+ error: Error;
+};
+export const showErrorNotification = (props: ErrorNotificationProps) =>
+ showNotification({
+ icon: ,
+ title: props.title || props.error.name,
+ message: props.message || props.error.message,
+ color: "red",
+ });
diff --git a/apps/client/src/functions/showSuccessNotification.tsx b/apps/client/src/functions/showSuccessNotification.tsx
new file mode 100644
index 0000000..69c8d54
--- /dev/null
+++ b/apps/client/src/functions/showSuccessNotification.tsx
@@ -0,0 +1,15 @@
+import { showNotification } from "@mantine/notifications";
+import { ReactNode } from "react";
+import { TbCircleCheck } from "react-icons/tb";
+
+export type SuccessNotificationProps = {
+ title?: ReactNode;
+ message?: ReactNode;
+};
+export const showSuccessNotification = (props: SuccessNotificationProps) =>
+ showNotification({
+ icon: ,
+ title: props.title,
+ message: props.message || "Success",
+ color: "green",
+ });
diff --git a/apps/client/src/hooks/useFuse.ts b/apps/client/src/hooks/useFuse.ts
new file mode 100644
index 0000000..52dd429
--- /dev/null
+++ b/apps/client/src/hooks/useFuse.ts
@@ -0,0 +1,23 @@
+import Fuse from "fuse.js";
+import { useMemo, useState } from "react";
+
+export const useFuse = (
+ items: T[],
+ initialSearchTerm = "",
+ keys = ["id", "name", "description"],
+): { search: string; results: T[]; setSearch: (search: string) => void } => {
+ const [search, setSearch] = useState(initialSearchTerm);
+ const fuse = useMemo(
+ () => new Fuse(items, { keys, threshold: 0.6 }),
+ [keys, items],
+ );
+ const results = useMemo(
+ () =>
+ search.length < 2
+ ? items
+ : fuse.search(search).map((result) => result.item),
+ [items, fuse, search],
+ );
+
+ return { search, setSearch, results };
+};
diff --git a/apps/client/src/hooks/useStatusColorResolver.ts b/apps/client/src/hooks/useStatusColorResolver.ts
new file mode 100644
index 0000000..4d82a7e
--- /dev/null
+++ b/apps/client/src/hooks/useStatusColorResolver.ts
@@ -0,0 +1,10 @@
+import { useMantineTheme } from "@mantine/core";
+
+export const useStatusColorResolver = () => {
+ const theme = useMantineTheme();
+
+ return (status?: boolean) =>
+ theme.colors[
+ status === undefined ? "gray" : status ? "success" : "danger"
+ ][6];
+};
diff --git a/apps/client/src/hooks/useStatusMessageResolver.ts b/apps/client/src/hooks/useStatusMessageResolver.ts
new file mode 100644
index 0000000..63fbda8
--- /dev/null
+++ b/apps/client/src/hooks/useStatusMessageResolver.ts
@@ -0,0 +1,4 @@
+export const useStatusMessageResolver = () => {
+ return (status?: boolean) =>
+ status === undefined ? "PENDING" : status ? "UP" : "DOWN";
+};
diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx
new file mode 100644
index 0000000..da0fb33
--- /dev/null
+++ b/apps/client/src/main.tsx
@@ -0,0 +1,28 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "@mantine/core/styles.css";
+import "@mantine/charts/styles.css";
+import "@mantine/dates/styles.css";
+import "@mantine/notifications/styles.css";
+import "@mantine/tiptap/styles.css";
+
+import { MantineProvider } from "@mantine/core";
+import { App } from "./App.tsx";
+import "./assets/index.css";
+import { ModalsProvider } from "@mantine/modals";
+import { Notifications } from "@mantine/notifications";
+import { BrowserRouter } from "react-router-dom";
+import { theme } from "./theme.ts";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+
+
+ ,
+);
diff --git a/apps/client/src/monco-themes.ts b/apps/client/src/monco-themes.ts
new file mode 100644
index 0000000..1f67592
--- /dev/null
+++ b/apps/client/src/monco-themes.ts
@@ -0,0 +1,213 @@
+import { editor } from "monaco-editor";
+
+export const dracula: editor.IStandaloneThemeData = {
+ base: "vs-dark",
+ inherit: true,
+ rules: [
+ {
+ background: "282a36",
+ token: "",
+ },
+ {
+ foreground: "6272a4",
+ token: "comment",
+ },
+ {
+ foreground: "f1fa8c",
+ token: "string",
+ },
+ {
+ foreground: "bd93f9",
+ token: "constant.numeric",
+ },
+ {
+ foreground: "bd93f9",
+ token: "constant.language",
+ },
+ {
+ foreground: "bd93f9",
+ token: "constant.character",
+ },
+ {
+ foreground: "bd93f9",
+ token: "constant.other",
+ },
+ {
+ foreground: "ffb86c",
+ token: "variable.other.readwrite.instance",
+ },
+ {
+ foreground: "ff79c6",
+ token: "constant.character.escaped",
+ },
+ {
+ foreground: "ff79c6",
+ token: "constant.character.escape",
+ },
+ {
+ foreground: "ff79c6",
+ token: "string source",
+ },
+ {
+ foreground: "ff79c6",
+ token: "string source.ruby",
+ },
+ {
+ foreground: "ff79c6",
+ token: "keyword",
+ },
+ {
+ foreground: "ff79c6",
+ token: "storage",
+ },
+ {
+ foreground: "8be9fd",
+ fontStyle: "italic",
+ token: "storage.type",
+ },
+ {
+ foreground: "50fa7b",
+ fontStyle: "underline",
+ token: "entity.name.class",
+ },
+ {
+ foreground: "50fa7b",
+ fontStyle: "italic underline",
+ token: "entity.other.inherited-class",
+ },
+ {
+ foreground: "50fa7b",
+ token: "entity.name.function",
+ },
+ {
+ foreground: "ffb86c",
+ fontStyle: "italic",
+ token: "variable.parameter",
+ },
+ {
+ foreground: "ff79c6",
+ token: "entity.name.tag",
+ },
+ {
+ foreground: "50fa7b",
+ token: "entity.other.attribute-name",
+ },
+ {
+ foreground: "8be9fd",
+ token: "support.function",
+ },
+ {
+ foreground: "6be5fd",
+ token: "support.constant",
+ },
+ {
+ foreground: "66d9ef",
+ fontStyle: " italic",
+ token: "support.type",
+ },
+ {
+ foreground: "66d9ef",
+ fontStyle: " italic",
+ token: "support.class",
+ },
+ {
+ foreground: "f8f8f0",
+ background: "ff79c6",
+ token: "invalid",
+ },
+ {
+ foreground: "f8f8f0",
+ background: "bd93f9",
+ token: "invalid.deprecated",
+ },
+ {
+ foreground: "cfcfc2",
+ token: "meta.structure.dictionary.json string.quoted.double.json",
+ },
+ {
+ foreground: "6272a4",
+ token: "meta.diff",
+ },
+ {
+ foreground: "6272a4",
+ token: "meta.diff.header",
+ },
+ {
+ foreground: "ff79c6",
+ token: "markup.deleted",
+ },
+ {
+ foreground: "50fa7b",
+ token: "markup.inserted",
+ },
+ {
+ foreground: "e6db74",
+ token: "markup.changed",
+ },
+ {
+ foreground: "bd93f9",
+ token: "constant.numeric.line-number.find-in-files - match",
+ },
+ {
+ foreground: "e6db74",
+ token: "entity.name.filename",
+ },
+ {
+ foreground: "f83333",
+ token: "message.error",
+ },
+ {
+ foreground: "eeeeee",
+ token:
+ "punctuation.definition.string.begin.json - meta.structure.dictionary.value.json",
+ },
+ {
+ foreground: "eeeeee",
+ token:
+ "punctuation.definition.string.end.json - meta.structure.dictionary.value.json",
+ },
+ {
+ foreground: "8be9fd",
+ token: "meta.structure.dictionary.json string.quoted.double.json",
+ },
+ {
+ foreground: "f1fa8c",
+ token: "meta.structure.dictionary.value.json string.quoted.double.json",
+ },
+ {
+ foreground: "50fa7b",
+ token:
+ "meta meta meta meta meta meta meta.structure.dictionary.value string",
+ },
+ {
+ foreground: "ffb86c",
+ token: "meta meta meta meta meta meta.structure.dictionary.value string",
+ },
+ {
+ foreground: "ff79c6",
+ token: "meta meta meta meta meta.structure.dictionary.value string",
+ },
+ {
+ foreground: "bd93f9",
+ token: "meta meta meta meta.structure.dictionary.value string",
+ },
+ {
+ foreground: "50fa7b",
+ token: "meta meta meta.structure.dictionary.value string",
+ },
+ {
+ foreground: "ffb86c",
+ token: "meta meta.structure.dictionary.value string",
+ },
+ ],
+ colors: {
+ "editor.foreground": "#f8f8f2",
+ "editor.background": "#282a36",
+ "editor.selectionBackground": "#44475a",
+ "editor.lineHighlightBackground": "#44475a",
+ "editorCursor.foreground": "#f8f8f0",
+ "editorWhitespace.foreground": "#3B3A32",
+ "editorIndentGuide.activeBackground": "#9D550FB0",
+ "editor.selectionHighlightBorder": "#222218",
+ },
+};
diff --git a/apps/client/src/pages/monitor-edit.page.tsx b/apps/client/src/pages/monitor-edit.page.tsx
new file mode 100644
index 0000000..32b359d
--- /dev/null
+++ b/apps/client/src/pages/monitor-edit.page.tsx
@@ -0,0 +1,148 @@
+import {
+ ActionIcon,
+ Container,
+ Drawer,
+ Group,
+ LoadingOverlay,
+ Stack,
+ Title,
+} from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
+import {
+ MonitorDto,
+ monitorConfigControllerUpdateMonitorConfig,
+ monitorControllerUpdateMonitor,
+ useMonitorConfigControllerGetMonitorConfig,
+ useMonitorControllerGetMonitor,
+ useMonitorTypeControllerGetSchema,
+ useMonitorTypeControllerGetSchemaDocs,
+} from "@watchtower/api-client";
+import { JSONSchema7 } from "json-schema";
+import { FC } from "react";
+import { TbArrowLeft } from "react-icons/tb";
+import { useNavigate, useParams } from "react-router-dom";
+import { SWRHookInfo } from "../components/hook-debugger.tsx";
+import { MonitorTypeDocs } from "../components/monitor-type-docs.tsx";
+import { SWR_DEBUG } from "../config.ts";
+import {
+ EditMonitorForm,
+ EditMonitorFormValues,
+} from "../forms/edit-monitor-form.tsx";
+import { showErrorNotification } from "../functions/showErrorNotification.tsx";
+import { showSuccessNotification } from "../functions/showSuccessNotification.tsx";
+
+const _MonitorEditPage: FC<{
+ monitor: MonitorDto;
+ config: any;
+ onShowDocs: () => void;
+ monitorTypeSchema: JSONSchema7;
+ refreshCallback: () => Promise;
+}> = ({ monitor, config, refreshCallback, monitorTypeSchema, onShowDocs }) => {
+ const navigate = useNavigate();
+
+ async function handleSubmit(values: EditMonitorFormValues, config: any) {
+ try {
+ await monitorConfigControllerUpdateMonitorConfig(monitor.id, config);
+ await monitorControllerUpdateMonitor(monitor.id, {
+ name: values.name,
+ interval: values.interval,
+ });
+ showSuccessNotification({ message: "Monitor updated successfully" });
+ await refreshCallback();
+ } catch (error) {
+ showErrorNotification({
+ title: "Error updating monitor",
+ error,
+ });
+ }
+ }
+
+ return (
+
+
+ navigate(`/monitors/${monitor.id}`)}
+ >
+
+
+ Edit {monitor.type} Monitor
+
+
+
+ );
+};
+
+export const MonitorEditPage: FC<{}> = ({}) => {
+ const { id } = useParams();
+
+ const monitor = useMonitorControllerGetMonitor(Number(id || 0));
+ const config = useMonitorConfigControllerGetMonitorConfig(Number(id || 0));
+ const monitorTypeSchema = useMonitorTypeControllerGetSchema(
+ monitor.data?.data.type || "",
+ );
+ const monitorTypeDocs = useMonitorTypeControllerGetSchemaDocs(
+ monitor.data?.data.type || "",
+ );
+
+ const [isDocsDrawerOpen, docsDrawer] = useDisclosure(false);
+
+ return (
+ <>
+
+
+ {monitor.data && monitorTypeSchema.data && monitorTypeDocs.data && (
+ <_MonitorEditPage
+ monitor={monitor.data.data}
+ config={config.data?.data || {}} // Expected to be 404 if new monitor without any config saved yet
+ onShowDocs={docsDrawer.open}
+ monitorTypeSchema={
+ monitorTypeSchema.data.data as unknown as JSONSchema7
+ }
+ refreshCallback={async () => {
+ await monitor.mutate();
+ await config.mutate();
+ await monitorTypeSchema.mutate();
+ await monitorTypeDocs.mutate();
+ }}
+ />
+ )}
+
+
+
+
+ {SWR_DEBUG && (
+
+ )}
+ >
+ );
+};
diff --git a/apps/client/src/pages/monitor.page.tsx b/apps/client/src/pages/monitor.page.tsx
new file mode 100644
index 0000000..9f49855
--- /dev/null
+++ b/apps/client/src/pages/monitor.page.tsx
@@ -0,0 +1,103 @@
+import {
+ ActionIcon,
+ Container,
+ Flex,
+ Group,
+ Menu,
+ Stack,
+ Title,
+} from "@mantine/core";
+import { useViewportSize } from "@mantine/hooks";
+import {
+ MonitorDto,
+ MonitorHistoryDto,
+ useMonitorControllerGetMonitor,
+ useMonitorHistoryControllerGetMonitorHistory,
+} from "@watchtower/api-client";
+import { FC } from "react";
+import { TbArrowLeft, TbDotsVertical, TbEdit, TbTrash } from "react-icons/tb";
+import { useNavigate, useParams } from "react-router-dom";
+import { CurrentStatusCard } from "../components/current-status-card.tsx";
+import { DurationCard } from "../components/duration-card.tsx";
+import { StatusCard } from "../components/status-card.tsx";
+import { deleteMonitor } from "../functions/deleteMonitor.ts";
+import { ViewportBreakpoint } from "../viewport-breakpoint.ts";
+
+const _MonitorPage: FC<{
+ monitor: MonitorDto;
+ history: MonitorHistoryDto[];
+}> = ({ monitor, history }) => {
+ const { width } = useViewportSize();
+ const navigate = useNavigate();
+ const historyItems = history.map((it) => ({
+ ...it,
+ startedAt: new Date(it.startedAt),
+ finishedAt: new Date(it.finishedAt),
+ }));
+
+ return (
+
+
+
+ navigate("/monitors")}>
+
+
+ {monitor.name}
+
+
+
+
+
+ {width < ViewportBreakpoint.sm ? (
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+
+ );
+};
+
+export const MonitorPage: FC<{}> = ({}) => {
+ const { id } = useParams();
+ const monitor = useMonitorControllerGetMonitor(Number(id || 0));
+
+ const history = useMonitorHistoryControllerGetMonitorHistory(Number(id || 0));
+
+ return (
+
+ {monitor.data && (
+ <_MonitorPage
+ monitor={monitor.data.data}
+ history={history.data?.data || []}
+ />
+ )}
+
+ );
+};
diff --git a/apps/client/src/pages/monitors.page.tsx b/apps/client/src/pages/monitors.page.tsx
new file mode 100644
index 0000000..839daa2
--- /dev/null
+++ b/apps/client/src/pages/monitors.page.tsx
@@ -0,0 +1,74 @@
+import { ActionIcon, Container, Flex, Stack, TextInput } from "@mantine/core";
+import {
+ MonitorDto,
+ MonitorTypeDto,
+ useMonitorControllerGetMonitors,
+ useMonitorHistoryControllerGetMonitorHistory,
+ useMonitorTypeControllerGetMonitorTypes,
+} from "@watchtower/api-client";
+import { FC } from "react";
+import { TbPlus } from "react-icons/tb";
+import { useNavigate } from "react-router-dom";
+import { MonitorCard } from "../components/monitor-card.tsx";
+import { openNewMonitorFormModal } from "../functions/openNewMonitorFormModal.tsx";
+import { useFuse } from "../hooks/useFuse.ts";
+
+const _MonitorCard: FC<{ monitor: MonitorDto; type?: MonitorTypeDto }> = ({
+ monitor,
+ type,
+}) => {
+ const navigate = useNavigate();
+ const history = useMonitorHistoryControllerGetMonitorHistory(monitor.id);
+
+ return (
+ navigate(`/monitors/${monitor.id}`)}
+ name={monitor.name}
+ type={type?.name || monitor.type}
+ history={
+ history.data?.data.map((it) => ({
+ ...it,
+ startedAt: new Date(it.startedAt),
+ finishedAt: new Date(it.finishedAt),
+ })) || []
+ }
+ />
+ );
+};
+
+export const MonitorsPage: FC = () => {
+ const navigate = useNavigate();
+ const monitors = useMonitorControllerGetMonitors();
+ const monitorTypes = useMonitorTypeControllerGetMonitorTypes();
+ const { search, setSearch, results } = useFuse(monitors.data?.data || []);
+
+ return (
+
+
+
+ setSearch(event.currentTarget.value)}
+ />
+
+ openNewMonitorFormModal(monitorTypes.data?.data || [], navigate)
+ }
+ size={"lg"}
+ >
+
+
+
+ {results.map((monitor) => (
+ <_MonitorCard
+ key={monitor.id}
+ monitor={monitor}
+ type={monitorTypes.data?.data.find((it) => it.id === monitor.type)}
+ />
+ ))}
+
+
+ );
+};
diff --git a/apps/client/src/theme.ts b/apps/client/src/theme.ts
new file mode 100644
index 0000000..b35bc32
--- /dev/null
+++ b/apps/client/src/theme.ts
@@ -0,0 +1,46 @@
+import { createTheme } from "@mantine/core";
+import { DraculaTheme } from "dracula-mantine";
+
+export const theme = createTheme({
+ ...DraculaTheme,
+ white: "#ffffff",
+ colors: {
+ ...DraculaTheme.colors,
+ dark: [
+ "#e5e5e6",
+ "#c8c8cd",
+ "#a8aab4",
+ "#8e909f",
+ "#45495E",
+ "#3D4052",
+ "#343746",
+ "#282A36",
+ "#1A1B23",
+ "#111217",
+ ],
+ success: [
+ "#e5feee",
+ "#d2f9e0",
+ "#a8f1c0",
+ "#7aea9f",
+ "#53e383",
+ "#3bdf70",
+ "#2bdd66",
+ "#1ac455",
+ "#0caf49",
+ "#00963c",
+ ],
+ danger: [
+ "#ffeaec",
+ "#fdd4d6",
+ "#f4a7ac",
+ "#ec777e",
+ "#e64f57",
+ "#e3353f",
+ "#e22732",
+ "#c91a25",
+ "#b31220",
+ "#9e0419",
+ ],
+ },
+});
diff --git a/apps/client/src/viewport-breakpoint.ts b/apps/client/src/viewport-breakpoint.ts
new file mode 100644
index 0000000..6dd468b
--- /dev/null
+++ b/apps/client/src/viewport-breakpoint.ts
@@ -0,0 +1,7 @@
+export enum ViewportBreakpoint {
+ xs = 0,
+ sm = 576,
+ md = 768,
+ lg = 992,
+ xl = 1200,
+}
diff --git a/apps/client/src/vite-env.d.ts b/apps/client/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/client/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/client/tsconfig.app.json b/apps/client/tsconfig.app.json
new file mode 100644
index 0000000..3e8c9b8
--- /dev/null
+++ b/apps/client/tsconfig.app.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+
+ "useUnknownInCatchVariables": false
+
+ },
+ "include": ["src"]
+}
diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/apps/client/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/apps/client/tsconfig.node.json b/apps/client/tsconfig.node.json
new file mode 100644
index 0000000..0d3d714
--- /dev/null
+++ b/apps/client/tsconfig.node.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts
new file mode 100644
index 0000000..799aa23
--- /dev/null
+++ b/apps/client/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ "/api": "http://127.0.0.1:3000",
+ },
+ },
+});
diff --git a/apps/server/.eslintrc.js b/apps/server/.eslintrc.js
new file mode 100644
index 0000000..259de13
--- /dev/null
+++ b/apps/server/.eslintrc.js
@@ -0,0 +1,25 @@
+module.exports = {
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: 'tsconfig.json',
+ tsconfigRootDir: __dirname,
+ sourceType: 'module',
+ },
+ plugins: ['@typescript-eslint/eslint-plugin'],
+ extends: [
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:prettier/recommended',
+ ],
+ root: true,
+ env: {
+ node: true,
+ jest: true,
+ },
+ ignorePatterns: ['.eslintrc.js'],
+ rules: {
+ '@typescript-eslint/interface-name-prefix': 'off',
+ '@typescript-eslint/explicit-function-return-type': 'off',
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+};
diff --git a/apps/server/.gitignore b/apps/server/.gitignore
new file mode 100644
index 0000000..4b56acf
--- /dev/null
+++ b/apps/server/.gitignore
@@ -0,0 +1,56 @@
+# compiled output
+/dist
+/node_modules
+/build
+
+# Logs
+logs
+*.log
+npm-debug.log*
+pnpm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# OS
+.DS_Store
+
+# Tests
+/coverage
+/.nyc_output
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# temp directory
+.temp
+.tmp
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
diff --git a/apps/server/.prettierrc b/apps/server/.prettierrc
new file mode 100644
index 0000000..dcb7279
--- /dev/null
+++ b/apps/server/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all"
+}
\ No newline at end of file
diff --git a/apps/server/README.md b/apps/server/README.md
new file mode 100644
index 0000000..00a13b1
--- /dev/null
+++ b/apps/server/README.md
@@ -0,0 +1,73 @@
+
+
+
+
+[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
+[circleci-url]: https://circleci.com/gh/nestjs/nest
+
+ A progressive Node.js framework for building efficient and scalable server-side applications.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Description
+
+[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
+
+## Installation
+
+```bash
+$ npm install
+```
+
+## Running the app
+
+```bash
+# development
+$ npm run start
+
+# watch mode
+$ npm run start:dev
+
+# production mode
+$ npm run start:prod
+```
+
+## Test
+
+```bash
+# unit tests
+$ npm run test
+
+# e2e tests
+$ npm run test:e2e
+
+# test coverage
+$ npm run test:cov
+```
+
+## Support
+
+Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
+
+## Stay in touch
+
+- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
+- Website - [https://nestjs.com](https://nestjs.com/)
+- Twitter - [@nestframework](https://twitter.com/nestframework)
+
+## License
+
+Nest is [MIT licensed](LICENSE).
diff --git a/apps/server/nest-cli.json b/apps/server/nest-cli.json
new file mode 100644
index 0000000..4e08dca
--- /dev/null
+++ b/apps/server/nest-cli.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json.schemastore.org/nest-cli",
+ "collection": "@nestjs/schematics",
+ "sourceRoot": "src",
+ "compilerOptions": {
+ "builder": "swc",
+ "typeCheck": true,
+ "deleteOutDir": true
+ }
+}
diff --git a/apps/server/package.json b/apps/server/package.json
new file mode 100644
index 0000000..a3e3a1a
--- /dev/null
+++ b/apps/server/package.json
@@ -0,0 +1,75 @@
+{
+ "name": "@watchtower/server",
+ "version": "0.0.1",
+ "description": "",
+ "author": "",
+ "private": true,
+ "license": "UNLICENSED",
+ "bin": "dist/main.js",
+ "main": "dist/main.js",
+ "scripts": {
+ "build": "nest build",
+ "dev": "nest start --watch",
+ "dev:debug": "nest start --debug --watch",
+ "start": "nest start",
+ "start:prod": "node ."
+ },
+ "dependencies": {
+ "@nestjs/axios": "^3.0.2",
+ "@nestjs/common": "^10.0.0",
+ "@nestjs/core": "^10.0.0",
+ "@nestjs/platform-express": "^10.0.0",
+ "@nestjs/schedule": "^4.1.0",
+ "@nestjs/serve-static": "^4.0.2",
+ "@nestjs/swagger": "^7.4.0",
+ "@nestjs/terminus": "^10.2.3",
+ "@nestjs/typeorm": "^10.0.2",
+ "@watchtower/client": "0.0.1",
+ "axios": "^1.7.3",
+ "better-sqlite3": "^9.6.0",
+ "date-fns": "^3.6.0",
+ "json-schema-md-doc": "^1.0.0",
+ "lodash": "^4.17.21",
+ "reflect-metadata": "^0.2.0",
+ "rxjs": "^7.8.1",
+ "typeorm": "^0.3.20",
+ "zod": "^3.23.8",
+ "zod-to-json-schema": "^3.23.2"
+ },
+ "devDependencies": {
+ "@nestjs/cli": "^10.0.0",
+ "@nestjs/schematics": "^10.0.0",
+ "@nestjs/testing": "^10.0.0",
+ "@types/express": "^4.17.17",
+ "@types/jest": "^29.5.2",
+ "@types/json-schema-md-doc": "^1.0.3",
+ "@types/lodash": "^4.17.7",
+ "@types/node": "^20.3.1",
+ "@types/supertest": "^6.0.0",
+ "glob": "^11.0.0",
+ "jest": "^29.5.0",
+ "json-schema-to-typescript": "^15.0.0",
+ "prettier": "^3.0.0",
+ "source-map-support": "^0.5.21",
+ "supertest": "^7.0.0",
+ "ts-jest": "^29.1.0",
+ "ts-loader": "^9.4.3",
+ "ts-node": "^10.9.1",
+ "tsconfig-paths": "^4.2.0",
+ "typescript": "^5.1.3"
+ },
+ "pkg": {
+ "assets": [
+ "../client/dist/**/*",
+ "../../node_modules/better-sqlite3/**/*"
+ ],
+ "scripts": [
+ "../../node_modules/axios/dist/node/*"
+ ],
+ "targets": [
+ "node18-win-x64",
+ "node18-linux-x64",
+ "node18-macos-x64"
+ ]
+ }
+}
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
new file mode 100644
index 0000000..963e45a
--- /dev/null
+++ b/apps/server/src/app.module.ts
@@ -0,0 +1,65 @@
+import { dirname } from "node:path";
+import { HttpModule } from "@nestjs/axios";
+import { Logger, Module } from "@nestjs/common";
+import { DiscoveryModule } from "@nestjs/core";
+import { ScheduleModule } from "@nestjs/schedule";
+import { ServeStaticModule } from "@nestjs/serve-static";
+import { TerminusModule } from "@nestjs/terminus";
+import { TypeOrmModule } from "@nestjs/typeorm";
+import { HealthController } from "./controllers/health.controller";
+import { MonitorConfigController } from "./controllers/monitor-config.controller";
+import { MonitorHistoryController } from "./controllers/monitor-history.controller";
+import { MonitorTypeController } from "./controllers/monitor-type.controller";
+import { MonitorController } from "./controllers/monitor.controller";
+import { MonitorCheckEntity } from "./entities/monitor-check.entity";
+import { MonitorEntity } from "./entities/monitor.entity";
+import { monitorTypes } from "./monitors";
+import { CheckService } from "./services/check.service";
+import { MonitorConfigService } from "./services/monitor-config.service";
+import { MonitorHistoryService } from "./services/monitor-history.service";
+import { MonitorTypeService } from "./services/monitor-type.service";
+import { MonitorService } from "./services/monitor.service";
+
+const staticRootPath = dirname(
+ require.resolve("@watchtower/client/dist/index.html"),
+);
+
+Logger.debug(`Serving Static content from ${staticRootPath}`);
+
+@Module({
+ imports: [
+ DiscoveryModule,
+ ServeStaticModule.forRoot({
+ rootPath: staticRootPath,
+ }),
+ TerminusModule,
+ HttpModule,
+ ScheduleModule.forRoot(),
+ TypeOrmModule.forRoot({
+ type: "better-sqlite3",
+ database: process.env.WC__DATABASE || "./db.sqlite",
+ synchronize: true,
+ entities: [MonitorEntity, MonitorCheckEntity],
+ // Uncomment these lines to enable logging SQL queries
+ // logger: 'advanced-console',
+ // logging: 'all',
+ }),
+ TypeOrmModule.forFeature([MonitorEntity, MonitorCheckEntity]),
+ ],
+ controllers: [
+ HealthController,
+ MonitorController,
+ MonitorHistoryController,
+ MonitorConfigController,
+ MonitorTypeController,
+ ],
+ providers: [
+ MonitorService,
+ CheckService,
+ MonitorHistoryService,
+ MonitorConfigService,
+ MonitorTypeService,
+ ...monitorTypes,
+ ],
+})
+export class AppModule {}
diff --git a/apps/server/src/controllers/health.controller.ts b/apps/server/src/controllers/health.controller.ts
new file mode 100644
index 0000000..c2b1a92
--- /dev/null
+++ b/apps/server/src/controllers/health.controller.ts
@@ -0,0 +1,13 @@
+import { Controller, Get } from "@nestjs/common";
+import { HealthCheck, HealthCheckService } from "@nestjs/terminus";
+
+@Controller("/api/health")
+export class HealthController {
+ constructor(private health: HealthCheckService) {}
+
+ @Get()
+ @HealthCheck()
+ check() {
+ return this.health.check([]);
+ }
+}
diff --git a/apps/server/src/controllers/monitor-config.controller.ts b/apps/server/src/controllers/monitor-config.controller.ts
new file mode 100644
index 0000000..55a446d
--- /dev/null
+++ b/apps/server/src/controllers/monitor-config.controller.ts
@@ -0,0 +1,51 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Inject,
+ NotFoundException,
+ Param,
+ Put,
+} from "@nestjs/common";
+import { ApiBody, ApiOkResponse, ApiParam } from "@nestjs/swagger";
+import { MonitorConfigService } from "../services/monitor-config.service";
+
+@Controller("/api/monitors/:id/config")
+export class MonitorConfigController {
+ @Inject(MonitorConfigService)
+ monitorConfigService: MonitorConfigService;
+
+ @Get("/")
+ @ApiOkResponse({
+ type: Object,
+ })
+ @ApiParam({ name: "id", type: Number })
+ async getMonitorConfig(@Param("id") id: number): Promise