Initial Release
@@ -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?
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
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 },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CheckCle is an open-source monitoring stack</title>
|
||||
<meta name="description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
||||
<meta name="author" content="Tola Leng" />
|
||||
|
||||
<meta property="og:title" content="checkcle-an-open-source" />
|
||||
<meta property="og:description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@tlengoss" />
|
||||
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- IMPORTANT: DO NOT REMOVE THIS SCRIPT TAG OR THIS VERY COMMENT! -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "vite_react_shadcn_ts",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"pocketbase": "^0.19.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-resizable-panels": "^2.1.3",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.12.7",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,14 @@
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
|
||||
User-agent: Twitterbot
|
||||
Allow: /
|
||||
|
||||
User-agent: facebookexternalhit
|
||||
Allow: /
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#f6d594" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M40.77 31C24.27 31 5.8 21.8 4 1.63A1.4 1.4 0 0 1 5.21.07a1.38 1.38 0 0 1 1.55 1.21c1.6 18.47 18.74 26.89 34 26.89 21.58 0 32.38-13.53 33.52-26.89A1.38 1.38 0 0 1 75.73 0 1.45 1.45 0 0 1 77 1.47C75.34 21.84 57.08 31 40.8 31h-.03Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.84 21.11c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 1.1-2.26 1.68 1.68 0 0 1 1.86.61c.14.2.24.4.3.64.6 2.11 1.87 3.97 3.61 5.28a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.23.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.84 21.11c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.05 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.21 10.21 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.86 7.1 13.19 13.19 0 0 1-8.1 2.65Z" fill="#000"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#fcbc34" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M53.92 35a1.37 1.37 0 0 1-1.26-.83 15.26 15.26 0 0 1 15.67-22.03c3.71.52 7.1 2.39 9.53 5.26a1.48 1.48 0 0 1-1.15 2.09c-.3.04-.61-.02-.88-.17a12.52 12.52 0 0 0-16.54-2.35c-4.01 2.7-7.51 8.54-4.1 16.07a1.48 1.48 0 0 1-.76 1.83 1.3 1.3 0 0 1-.51.13Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.84 21.11c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 1.1-2.26 1.68 1.68 0 0 1 1.86.61c.14.2.24.4.3.64.6 2.11 1.87 3.97 3.61 5.28a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.23.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.84 21.11c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.05 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.21 10.21 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.86 7.1 13.19 13.19 0 0 1-8.1 2.65Z" fill="#000"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#d84be5" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4c10.24 32.68 61.73 31.31 72 0A167.66 167.66 0 0 1 4 4Z" fill="#2B1607"/><path d="M39.68 29h-.64c-17.41-.24-31.54-9.95-36-24.74a1.02 1.02 0 0 1 .2-.95.98.98 0 0 1 .91-.28 165.35 165.35 0 0 0 71.72 0 .91.91 0 0 1 .9.3 1.04 1.04 0 0 1 .19.97C71.93 19.37 57.36 29 39.68 29ZM5.33 5.3c4.73 13.13 17.76 21.54 33.7 21.77C55.6 27.3 69.29 18.8 74.56 5.3a167.24 167.24 0 0 1-69.22 0Z" fill="#000"/><g style="mix-blend-mode:soft-light" opacity=".4"><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" fill="#422715"/><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" stroke="#000" stroke-width="1.58" stroke-miterlimit="10"/></g><path fill-rule="evenodd" clip-rule="evenodd" d="M40.83 45H38.1c-2.67 0-5.24-1.06-7.13-2.94a10 10 0 0 1-2.96-7.1V19.54c0-1.9 2.8-3.94 4.88-3.46a29.03 29.03 0 0 0 13.26 0C48 15.7 51 17.62 51 19.53v15.44a9.98 9.98 0 0 1-6.28 9.29c-1.23.5-2.55.75-3.89.74Z" fill="url(#mouthTongueOut-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M38.27 17c.95 3.75.33 13 1.13 13 .8 0 .53-9.01 1.37-13h-2.5Z" fill="#A00707"/><defs><linearGradient id="mouthTongueOut-a" x1="39.46" y1="45" x2="39.46" y2="16" gradientUnits="userSpaceOnUse"><stop stop-color="#EF0A0A"/><stop offset=".5" stop-color="#ED0A0A"/><stop offset=".67" stop-color="#E60A0A"/><stop offset=".8" stop-color="#DB0A0A"/><stop offset=".9" stop-color="#CA0A0A"/><stop offset=".99" stop-color="#B40A0A"/><stop offset="1" stop-color="#B10A0A"/></linearGradient></defs></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.76 21.94c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 .49-1.92 1.7 1.7 0 0 1 2.76.9c.6 2.12 1.88 3.98 3.62 5.29a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.24.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.76 21.94c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.06 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.22 10.22 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.87 7.1 13.19 13.19 0 0 1-8.08 2.65Z" fill="#000"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#d9915b" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M40.3 36c-.35 0-.68-.15-.92-.4a1.43 1.43 0 0 1-.38-.98c0-.37.14-.72.38-.98s.57-.4.91-.4c3.77 0 5.99-1.38 6.1-3.75.1-2.73-2.52-6.05-6.22-6.43a1.26 1.26 0 0 1-.84-.49 1.5 1.5 0 0 1 .08-1.88c.23-.25.52-.4.84-.44 3.66-.25 6.11-3.25 6.04-5.82 0-2.3-2.3-3.67-5.96-3.67-.27-.1-.5-.28-.67-.53a1.53 1.53 0 0 1 0-1.71c.16-.25.4-.43.67-.53 6.17 0 8.45 3.28 8.55 6.35a8.98 8.98 0 0 1-1.23 4.26 8.22 8.22 0 0 1-3.06 3.02 8.91 8.91 0 0 1 3.18 3.3A9.7 9.7 0 0 1 49 29.48C48.84 32.7 46.48 36 40.3 36Z" fill="#000"/><g fill-rule="evenodd" clip-rule="evenodd"><path d="M107.8 10.27c-.09.5-.2.98-.36 1.46-2.88 10.4-16.3 16.3-23.8 19.9-6.09-6.32-17.4-17.25-15.4-28.6.25-1.51.78-2.96 1.58-4.25 3.97-6.6 13.73-7.44 19.41.96 9.68-6.95 20.37.34 18.57 10.53Z" fill="#CE0F0F"/><path opacity=".2" d="M107.8 10.27c-.09.5-.2.98-.36 1.46-4.52 8.33-15.82 13.3-22.4 16.5C78.87 21.98 67.61 11.1 69.6-.32c0-.29.12-.57.2-.85 3.96-6.58 13.73-7.43 19.4.97 9.7-7.01 20.39.28 18.6 10.47Z" fill="#fff"/></g></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M78.36 25.92h-6.6c-9.72 0-9.72.07-9.72-9.73v-3.3A13.04 13.04 0 0 1 75.06-.13 13.04 13.04 0 0 1 88.1 12.9v3.38c-.03 9.72-.03 9.65-9.73 9.65Z" fill="#000"/><path d="M70.69 9.52a2.28 2.28 0 1 0 0-4.57 2.28 2.28 0 0 0 0 4.57Z" fill="#fff"/><path opacity=".1" d="M74.92 18.31a5.31 5.31 0 1 0 0-10.62 5.31 5.31 0 0 0 0 10.62Z" fill="#fff"/><path d="M17.36 25.92h-6.6c-9.72 0-9.72.07-9.72-9.73v-3.3A13.04 13.04 0 0 1 14.06-.13 13.04 13.04 0 0 1 27.1 12.9v3.38c-.03 9.72-.03 9.65-9.73 9.65Z" fill="#000"/><path d="M9.8 9.53a2.29 2.29 0 1 0 0-4.57 2.29 2.29 0 0 0 0 4.57Z" fill="#fff"/><path opacity=".1" d="M14.03 18.35a5.32 5.32 0 1 0 0-10.65 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#71cf62" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M21 29a19.88 19.88 0 0 1 39 0c-9.72-7.8-30.4-7.44-39 0Z" fill="#2B1607"/><path d="M20.58 29.54c-.1 0-.2-.03-.3-.09a.58.58 0 0 1-.24-.27.56.56 0 0 1-.02-.36c2.48-9.9 10.54-16.22 20.51-16.22 9.98 0 18.01 6.41 20.45 16.28.03.11.02.24-.02.35a.58.58 0 0 1-.23.27.62.62 0 0 1-.7 0c-9.4-7.44-30.24-7.44-39.1 0a.6.6 0 0 1-.35.04Zm19.74-6.8c7.07 0 14.22 1.6 19.11 4.77-1.15-3.98-3.62-7.5-7.03-10a19.88 19.88 0 0 0-11.83-3.82 19.94 19.94 0 0 0-11.82 3.8 19.02 19.02 0 0 0-7.06 9.97c4.58-3.14 11.6-4.71 18.63-4.71Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path fill-rule="evenodd" clip-rule="evenodd" d="M-10.24 2.64c-4.65 4.43-2.22 27.02 7.48 32.71 3.24 1.9 15.66 3.74 21.17 1.63 10.32-3.94 16.86-18.34 16.86-25.23 0-5.08-9.94-10.86-18.17-11.8C9.1-1-6-1.39-10.24 2.64Z" fill="url(#eyesShades-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M98.51 2.64c4.66 4.43 2.24 27.02-7.47 32.71-3.23 1.9-15.66 3.74-21.18 1.63C59.55 33.04 53 18.7 53 11.75 53 6.67 62.95.89 71.18-.05c7.97-.95 23.1-1.33 27.33 2.7Z" fill="url(#eyesShades-b)"/><path d="M109.83-2.7a.62.62 0 0 0-.48-.5c-8.12-1.39-30.47-2.95-42.45-.7A33.37 33.37 0 0 0 56.73-.04l-2.37 1.21A20.61 20.61 0 0 1 44.15 3.5c-3.55.12-7.08-.68-10.23-2.32l-2.37-1.2a33.23 33.23 0 0 0-10.17-3.89C9.4-6.1-12.93-4.59-21.07-3.2a.57.57 0 0 0-.47.5 33.81 33.81 0 0 0 0 9.3.6.6 0 0 0 .44.47s2.95.83 4.01 9.09c.84 6.53 4.21 22.03 17.7 24.5 3.63.6 7.3.92 11 .92 1.62.02 3.24-.07 4.85-.28 14.98-2.18 19.62-15.62 21.86-22.13.3-.86.55-1.58.78-2.16 1.48-3.55 2.64-3.93 5.06-3.93 1.9 0 3.58.3 5.05 3.93.22.58.49 1.32.78 2.16 2.23 6.46 6.89 19.9 21.84 22.13 5.3.5 10.62.28 15.85-.65 13.5-2.46 16.89-17.96 17.68-24.5 1.04-8.25 3.97-9.07 3.98-9.08a.6.6 0 0 0 .47-.47c.44-3.08.45-6.21.03-9.3Zm-9.87 18.58c-.77 6.71-3.66 15.7-9.21 18.96-3.08 1.81-15.26 3.66-20.64 1.6-10.25-3.92-16.52-18.2-16.52-24.7 0-4.42 9.08-10.22 17.69-11.22C74.88.1 78.48-.1 82.1-.1c6.8 0 13.54.8 16.03 3.17 1.78 1.71 2.52 6.86 1.83 12.8Zm-65.27-4.13c0 6.49-6.25 20.77-16.48 24.68-5.42 2.07-17.59.22-20.62-1.6-5.56-3.25-8.43-12.24-9.22-18.95-.69-5.9 0-11.1 1.86-12.8C-7.28.7-.54-.1 6.26-.1c3.61 0 7.22.2 10.81.62 8.55 1 17.62 6.77 17.62 11.23Z" fill="#000"/><defs><linearGradient id="eyesShades-a" x1="11.37" y1="37.92" x2="11.37" y2="-.68" gradientUnits="userSpaceOnUse"><stop offset=".32" stop-color="#121212"/><stop offset="1" stop-color="#474747"/></linearGradient><linearGradient id="eyesShades-b" x1="76.91" y1="-.68" x2="76.91" y2="37.92" gradientUnits="userSpaceOnUse"><stop stop-color="#474747"/><stop offset=".68" stop-color="#121212"/></linearGradient></defs></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#d84be5" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M25 24c4.5 3.87 25.04 4.13 30 0H25Z" fill="#000" style="mix-blend-mode:soft-light" opacity=".4"/><path d="M64.43 18.86h-48.5a1.47 1.47 0 1 1 0-2.79h48.5a1.47 1.47 0 1 1 0 2.8Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path fill-rule="evenodd" clip-rule="evenodd" d="M-10.24 2.64c-4.65 4.43-2.22 27.02 7.48 32.71 3.24 1.9 15.66 3.74 21.17 1.63 10.32-3.94 16.86-18.34 16.86-25.23 0-5.08-9.94-10.86-18.17-11.8C9.1-1-6-1.39-10.24 2.64Z" fill="url(#eyesShades-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M98.51 2.64c4.66 4.43 2.24 27.02-7.47 32.71-3.23 1.9-15.66 3.74-21.18 1.63C59.55 33.04 53 18.7 53 11.75 53 6.67 62.95.89 71.18-.05c7.97-.95 23.1-1.33 27.33 2.7Z" fill="url(#eyesShades-b)"/><path d="M109.83-2.7a.62.62 0 0 0-.48-.5c-8.12-1.39-30.47-2.95-42.45-.7A33.37 33.37 0 0 0 56.73-.04l-2.37 1.21A20.61 20.61 0 0 1 44.15 3.5c-3.55.12-7.08-.68-10.23-2.32l-2.37-1.2a33.23 33.23 0 0 0-10.17-3.89C9.4-6.1-12.93-4.59-21.07-3.2a.57.57 0 0 0-.47.5 33.81 33.81 0 0 0 0 9.3.6.6 0 0 0 .44.47s2.95.83 4.01 9.09c.84 6.53 4.21 22.03 17.7 24.5 3.63.6 7.3.92 11 .92 1.62.02 3.24-.07 4.85-.28 14.98-2.18 19.62-15.62 21.86-22.13.3-.86.55-1.58.78-2.16 1.48-3.55 2.64-3.93 5.06-3.93 1.9 0 3.58.3 5.05 3.93.22.58.49 1.32.78 2.16 2.23 6.46 6.89 19.9 21.84 22.13 5.3.5 10.62.28 15.85-.65 13.5-2.46 16.89-17.96 17.68-24.5 1.04-8.25 3.97-9.07 3.98-9.08a.6.6 0 0 0 .47-.47c.44-3.08.45-6.21.03-9.3Zm-9.87 18.58c-.77 6.71-3.66 15.7-9.21 18.96-3.08 1.81-15.26 3.66-20.64 1.6-10.25-3.92-16.52-18.2-16.52-24.7 0-4.42 9.08-10.22 17.69-11.22C74.88.1 78.48-.1 82.1-.1c6.8 0 13.54.8 16.03 3.17 1.78 1.71 2.52 6.86 1.83 12.8Zm-65.27-4.13c0 6.49-6.25 20.77-16.48 24.68-5.42 2.07-17.59.22-20.62-1.6-5.56-3.25-8.43-12.24-9.22-18.95-.69-5.9 0-11.1 1.86-12.8C-7.28.7-.54-.1 6.26-.1c3.61 0 7.22.2 10.81.62 8.55 1 17.62 6.77 17.62 11.23Z" fill="#000"/><defs><linearGradient id="eyesShades-a" x1="11.37" y1="37.92" x2="11.37" y2="-.68" gradientUnits="userSpaceOnUse"><stop offset=".32" stop-color="#121212"/><stop offset="1" stop-color="#474747"/></linearGradient><linearGradient id="eyesShades-b" x1="76.91" y1="-.68" x2="76.91" y2="37.92" gradientUnits="userSpaceOnUse"><stop stop-color="#474747"/><stop offset=".68" stop-color="#121212"/></linearGradient></defs></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#059ff2" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M75.08 12a1.37 1.37 0 0 1 1.26.83 15.26 15.26 0 0 1-15.67 22.03 15.2 15.2 0 0 1-9.53-5.26 1.48 1.48 0 0 1 1.15-2.09c.3-.04.61.02.88.17a12.52 12.52 0 0 0 16.54 2.35c4.01-2.7 7.51-8.54 4.1-16.07a1.48 1.48 0 0 1 .77-1.83c.16-.08.34-.12.51-.13Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M87.22 13.98c0-7.2-5.82-13.04-13-13.04s-13 5.84-13 13.04v3.92c0 7.2 5.82 13.04 13 13.04s13-5.83 13-13.04v-3.92Z" fill="#000"/><path d="M70 10.48a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.24 19.3a5.32 5.32 0 1 0 0-10.66 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M26.22 13.98c0-7.2-5.82-13.04-13-13.04s-13 5.84-13 13.04v3.92c0 7.2 5.82 13.04 13 13.04s13-5.83 13-13.04v-3.92Z" fill="#000"/><path d="M9 10.48A2.29 2.29 0 1 0 9 5.9a2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.24 19.3a5.32 5.32 0 1 0 0-10.66 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M84.33-5.7H65.45C58.63-5.7 53.1-.2 53.1 6.6v18.8c0 6.79 5.52 12.3 12.34 12.3h18.88c6.81 0 12.34-5.51 12.34-12.3V6.6c0-6.8-5.53-12.3-12.34-12.3Z" fill="url(#eyesGlasses-a)"/><path d="M21.3-5.7H2.42C-4.4-5.7-9.92-.2-9.92 6.6v18.8c0 6.79 5.52 12.3 12.34 12.3H21.3c6.81 0 12.34-5.51 12.34-12.3V6.6c0-6.8-5.53-12.3-12.34-12.3Z" fill="url(#eyesGlasses-b)"/><g fill="#000"><path d="M21.06 40.12H2.18A14.83 14.83 0 0 1-8.2 35.81a14.71 14.71 0 0 1-4.33-10.34V6.6c.02-3.87 1.58-7.59 4.34-10.34A14.85 14.85 0 0 1 2.18-8.06h18.88c3.92.02 7.66 1.58 10.42 4.35 2.76 2.76 4.31 6.5 4.31 10.4v18.7A14.62 14.62 0 0 1 26.71 39c-1.79.74-3.7 1.12-5.65 1.12ZM2.18-3.26A9.96 9.96 0 0 0-7 2.83a9.85 9.85 0 0 0-.76 3.78v18.8a9.85 9.85 0 0 0 9.9 9.86h18.92a9.93 9.93 0 0 0 9.9-9.86V6.6a9.87 9.87 0 0 0-9.9-9.86H2.18ZM84.33 40.12H65.46a14.83 14.83 0 0 1-10.39-4.31 14.71 14.71 0 0 1-4.33-10.34V6.6c.02-3.87 1.58-7.59 4.34-10.34a14.85 14.85 0 0 1 10.38-4.32h18.87c3.9.03 7.65 1.59 10.41 4.35s4.31 6.5 4.32 10.4v18.7c0 3.9-1.56 7.64-4.32 10.4a14.83 14.83 0 0 1-10.41 4.33ZM65.46-3.26a9.93 9.93 0 0 0-9.9 9.87v18.8a9.85 9.85 0 0 0 9.9 9.86h18.87a9.93 9.93 0 0 0 9.9-9.86V6.6a9.85 9.85 0 0 0-9.9-9.86H65.46Z"/><path d="M53.1 10.64H33.4v4.89h19.7v-4.89Z"/></g><defs><linearGradient id="eyesGlasses-a" x1="2332.67" y1="1561.82" x2="3621.21" y2="1561.82" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#969696" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity=".3"/></linearGradient><linearGradient id="eyesGlasses-b" x1="2269.64" y1="1561.82" x2="3558.18" y2="1561.82" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#969696" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity=".3"/></linearGradient></defs></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#f6d594" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M20 28c8-13 29-13 37 0-13-6-24-6-37 0Z" fill="#1E120B"/><path d="M19.37 29a.37.37 0 0 1-.16-.04.4.4 0 0 1-.13-.1.44.44 0 0 1 0-.5C22.76 21.9 30.28 18 38.7 18h.15c8.29 0 15.45 3.9 19.07 10.32a.48.48 0 0 1 0 .52.4.4 0 0 1-.22.13.39.39 0 0 1-.25-.03c-13.42-7.1-24.4-7.1-37.92 0l-.16.06Zm19.17-6.29a39.78 39.78 0 0 1 17.96 4.72c-3.73-5.4-10.27-8.61-17.61-8.64h-.12c-7.56 0-14.37 3.26-18.17 8.64a40.03 40.03 0 0 1 17.94-4.72Z" fill="#000"/><path d="M104.7-9.65 57.64 28.27a32.88 32.88 0 0 0-5.27-2.3 36.9 36.9 0 0 0-5.28-1.56c-1.23-.26-2.48-.5-3.75-.66l53.53-43.12a3.13 3.13 0 0 1 4.4.48l3.92 4.83c.3.37.52.82.61 1.3a3.12 3.12 0 0 1-1.1 3.11Z" fill="#E2E0E0" stroke="#777" stroke-width=".39" stroke-miterlimit="10"/><path opacity=".66" d="M105.8-12.76c-.2.43-.5.8-.88 1.1L56.25 27.56l-.3-.13a32.88 32.88 0 0 0-3.58-1.47 37.79 37.79 0 0 0-5.28-1.55c-1.23-.26-2.48-.5-3.75-.66l53.53-43.12a3.13 3.13 0 0 1 4.4.48l3.92 4.83c.3.37.52.82.61 1.3Z" fill="#fff"/><path d="M96.55-11.11a1.46 1.46 0 0 0-2.08-.22L49.4 24.97c1.14.32 2.25.69 3.37 1.1L96.4-9.03a1.47 1.47 0 0 0 .14-2.08Z" fill="#CECCCC"/><path d="M83.9-.92a1.46 1.46 0 0 0-2.13-.24L49.4 24.97c1.14.32 2.25.69 3.37 1.1l30.9-24.94a1.47 1.47 0 0 0 .24-2.05Z" fill="#EF5656"/><path d="M75.58 3.45a.36.36 0 0 1-.28-.13l-.73-.91a.35.35 0 0 1 0-.49.37.37 0 0 1 .5 0l.73.9a.37.37 0 0 1 0 .5.3.3 0 0 1-.22.14ZM63.17 13.45a.37.37 0 0 1-.28-.13l-.73-.9a.37.37 0 0 1 .32-.55c.09 0 .17.03.24.09l.73.9a.35.35 0 0 1 0 .5.32.32 0 0 1-.28.1ZM51.04 23.28a.34.34 0 0 1-.28-.13l-.73-.91a.35.35 0 0 1 0-.5.37.37 0 0 1 .5 0l.71.9a.35.35 0 0 1 0 .5.34.34 0 0 1-.2.14ZM69.5 8.35a.3.3 0 0 1-.27-.13L67.77 6.4a.36.36 0 0 1 .55-.44l1.47 1.82a.35.35 0 0 1 0 .5.44.44 0 0 1-.3.07ZM56.9 18.54a.37.37 0 0 1-.29-.13l-1.46-1.82a.37.37 0 0 1 0-.5.35.35 0 0 1 .5 0l1.46 1.82a.37.37 0 0 1 0 .5.44.44 0 0 1-.22.13Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M77.41 26.02h-6.59c-9.7 0-9.7.07-9.7-9.7v-4.09A12.2 12.2 0 0 1 73.32.03h1.6a12.2 12.2 0 0 1 12.2 12.2v4.16c-.03 9.7-.03 9.63-9.7 9.63Z" fill="#000"/><path d="M69.87 9.56a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.11 18.37a5.32 5.32 0 1 0 0-10.65 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M1.82 19.02c-.17 0-.34-.03-.5-.09a1.68 1.68 0 0 1-1-.84 1.75 1.75 0 0 1-.13-1.32 13.54 13.54 0 0 1 4.86-7.1 13.1 13.1 0 0 1 8.07-2.65c2.9-.02 5.73.91 8.06 2.67a13.63 13.63 0 0 1 4.86 7.08c.13.44.09.91-.12 1.32a1.72 1.72 0 0 1-2.3.73 1.69 1.69 0 0 1-.84-1.03c-.62-2.13-1.9-4-3.66-5.32a9.92 9.92 0 0 0-6.06-2c-2.17-.01-4.28.7-6.02 2.02a10.26 10.26 0 0 0-3.64 5.3c-.1.35-.32.65-.6.87a1.7 1.7 0 0 1-.98.36Z" fill="#000"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authService } from "@/services/authService";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { LanguageProvider } from "@/contexts/LanguageContext";
|
||||
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ServiceDetail from "./pages/ServiceDetail";
|
||||
import Settings from "./pages/Settings";
|
||||
import Profile from "./pages/Profile";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
// Create a Protected route component
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = authService.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication status when the app loads
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// Just check the auth state
|
||||
authService.isAuthenticated();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-screen bg-background text-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/service/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ServiceDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Settings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Profile />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
// API routes handler
|
||||
import realtime from './realtime';
|
||||
|
||||
/**
|
||||
* Simple API router for client-side application
|
||||
*/
|
||||
const api = {
|
||||
/**
|
||||
* Handle API requests
|
||||
*/
|
||||
async handleRequest(path, method, body) {
|
||||
console.log(`API request: ${method} ${path}`, body);
|
||||
|
||||
// Route to the appropriate handler
|
||||
if (path === '/api/realtime') {
|
||||
console.log("Routing to realtime handler");
|
||||
return await realtime(body);
|
||||
}
|
||||
|
||||
// Return 404 for unknown routes
|
||||
console.error(`Endpoint not found: ${path}`);
|
||||
return {
|
||||
status: 404,
|
||||
json: {
|
||||
ok: false,
|
||||
error_code: 404,
|
||||
description: "Endpoint not found"
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,195 @@
|
||||
|
||||
// This file handles realtime notifications in a client-side environment
|
||||
// In a production app, this would be a server-side endpoint
|
||||
|
||||
console.log("API Realtime endpoint loaded");
|
||||
|
||||
// Simple implementation that simulates sending notifications
|
||||
export default async function handler(req) {
|
||||
try {
|
||||
console.log("Realtime API call received:", JSON.stringify({
|
||||
...req,
|
||||
botToken: req?.botToken ? "[REDACTED]" : undefined
|
||||
}, null, 2));
|
||||
|
||||
// Make sure we're accessing the body correctly
|
||||
const body = req || {};
|
||||
const { type } = body;
|
||||
|
||||
if (!type) {
|
||||
console.error("Missing notification type parameter");
|
||||
return {
|
||||
status: 400,
|
||||
json: {
|
||||
ok: false,
|
||||
error_code: 400,
|
||||
description: "Missing notification type"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Telegram notifications
|
||||
if (type === "telegram") {
|
||||
const { chatId, botToken, message } = body;
|
||||
|
||||
console.log("Telegram notification request details:");
|
||||
console.log("- Chat ID:", chatId);
|
||||
console.log("- Bot token available:", !!botToken);
|
||||
console.log("- Message length:", message?.length || 0);
|
||||
|
||||
if (!chatId || !botToken || !message) {
|
||||
console.error("Missing required parameters for Telegram notification", {
|
||||
hasChatId: !!chatId,
|
||||
hasBotToken: !!botToken,
|
||||
hasMessage: !!message
|
||||
});
|
||||
|
||||
return {
|
||||
status: 400,
|
||||
json: {
|
||||
ok: false,
|
||||
error_code: 400,
|
||||
description: "Missing required Telegram parameters"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Attempting to call real Telegram API");
|
||||
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
console.log("Calling Telegram API:", telegramApiUrl.replace(botToken, "[REDACTED]"));
|
||||
|
||||
const response = await fetch(telegramApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: message,
|
||||
parse_mode: 'HTML'
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`Telegram API error (${response.status}):`, errorText);
|
||||
|
||||
try {
|
||||
// Try to parse error as JSON if possible
|
||||
const errorJson = JSON.parse(errorText);
|
||||
return {
|
||||
status: response.status,
|
||||
json: errorJson
|
||||
};
|
||||
} catch (e) {
|
||||
// If parsing fails, return the raw error
|
||||
return {
|
||||
status: response.status,
|
||||
json: {
|
||||
ok: false,
|
||||
error_code: response.status,
|
||||
description: `Telegram API error: ${errorText}`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Telegram API response:", JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.ok) {
|
||||
console.error("Telegram API error:", result);
|
||||
return {
|
||||
status: response.status,
|
||||
json: result
|
||||
};
|
||||
}
|
||||
|
||||
console.log("Successfully sent message to Telegram!");
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
ok: true,
|
||||
result: result.result,
|
||||
description: "Message sent successfully to Telegram"
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error calling Telegram API:", error);
|
||||
|
||||
// Return detailed error information
|
||||
return {
|
||||
status: 500,
|
||||
json: {
|
||||
ok: false,
|
||||
error_code: 500,
|
||||
description: `Error sending Telegram message: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
// Handle Signal notifications
|
||||
else if (type === "signal") {
|
||||
const { signalNumber, message } = body;
|
||||
|
||||
if (!signalNumber || !message) {
|
||||
console.error("Missing required parameters for Signal notification", {
|
||||
hasSignalNumber: !!signalNumber,
|
||||
hasMessage: !!message
|
||||
});
|
||||
|
||||
return {
|
||||
status: 400,
|
||||
json: {
|
||||
ok: false,
|
||||
error_code: 400,
|
||||
description: "Missing required Signal parameters"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Simulate sending Signal message
|
||||
console.log(`[SIMULATION] Sending Signal message to ${signalNumber}: ${message}`);
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Return success response
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
ok: true,
|
||||
result: {
|
||||
id: Math.floor(Math.random() * 10000),
|
||||
timestamp: Date.now(),
|
||||
delivered: true
|
||||
},
|
||||
description: "Signal message sent successfully (simulated)"
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Return error for unsupported notification type
|
||||
console.error("Unsupported notification type:", type);
|
||||
return {
|
||||
status: 400,
|
||||
json: {
|
||||
ok: false,
|
||||
error_code: 400,
|
||||
description: `Unsupported notification type: ${type}`
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in realtime handler:", error);
|
||||
return {
|
||||
status: 500,
|
||||
json: {
|
||||
ok: false,
|
||||
error_code: 500,
|
||||
description: error.message || "Internal server error"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Service } from "@/services/serviceService";
|
||||
import { StatusCards } from "./StatusCards";
|
||||
import { ServiceFilters } from "./ServiceFilters";
|
||||
import { ServicesTable } from "./ServicesTable";
|
||||
import { AddServiceDialog } from "@/components/services/AddServiceDialog";
|
||||
|
||||
interface DashboardContentProps {
|
||||
services: Service[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
|
||||
|
||||
// Filter services based on search term and type filter
|
||||
const filteredServices = services.filter(service => {
|
||||
const matchesSearch = service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(service.url && service.url.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesFilter = filter === 'all' || service.type.toLowerCase() === filter.toLowerCase();
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
|
||||
<p>Error loading service data.</p>
|
||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-foreground">Overview</h2>
|
||||
<Button
|
||||
className="text-primary-foreground"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> New Service
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<StatusCards services={services} />
|
||||
|
||||
<ServiceFilters
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
servicesCount={filteredServices.length}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col pb-6">
|
||||
<ServicesTable services={filteredServices} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddServiceDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AuthUser } from "@/services/authService";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Moon, PanelLeft, PanelLeftClose, Sun, Globe, FileText, Github, Twitter, MessageSquare, Bell, User, Settings, LogOut, Grid3x3 } from "lucide-react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||
|
||||
interface HeaderProps {
|
||||
currentUser: AuthUser | null;
|
||||
onLogout: () => void;
|
||||
sidebarCollapsed: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({
|
||||
currentUser,
|
||||
onLogout,
|
||||
sidebarCollapsed,
|
||||
toggleSidebar
|
||||
}: HeaderProps) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
const [greeting, setGreeting] = useState<string>("");
|
||||
const { systemName } = useSystemSettings();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Set greeting based on time of day
|
||||
useEffect(() => {
|
||||
const updateGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 5 && hour < 12) {
|
||||
setGreeting(t("goodMorning"));
|
||||
} else if (hour >= 12 && hour < 18) {
|
||||
setGreeting(t("goodAfternoon"));
|
||||
} else {
|
||||
setGreeting(t("goodEvening"));
|
||||
}
|
||||
};
|
||||
updateGreeting();
|
||||
// Update greeting if language changes
|
||||
|
||||
// You could add a timer to update the greeting throughout the day
|
||||
// but that's typically unnecessary since most sessions aren't active across time periods
|
||||
}, [language, t]);
|
||||
|
||||
// Log avatar data for debugging
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
console.log("Avatar URL in Header:", currentUser.avatar);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
// Prepare avatar URL - ensure it displays correctly if it's a local profile image
|
||||
let avatarUrl = '';
|
||||
if (currentUser?.avatar) {
|
||||
// If it's a relative path from the public folder, make sure it's resolved properly
|
||||
if (currentUser.avatar.startsWith('/upload/profile/')) {
|
||||
avatarUrl = currentUser.avatar;
|
||||
} else {
|
||||
avatarUrl = currentUser.avatar;
|
||||
}
|
||||
console.log("Final avatar URL:", avatarUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="relative bg-background border-b border-border px-6 flex justify-between items-center py-[12px] overflow-hidden">
|
||||
{/* Grid Pattern Overlay - Similar to StatusCards */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full backdrop-blur-[1px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header Content */}
|
||||
<div className="flex items-center gap-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
|
||||
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Grid3x3 className="h-5 w-5 text-green-500" />
|
||||
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 z-10">
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border" onClick={toggleTheme}>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<span className="sr-only">{t("language")}</span>
|
||||
<Globe className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
|
||||
{t("english")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
|
||||
{t("khmer")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Documentation */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<span className="sr-only">{t("documentation")}</span>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* GitHub */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<span className="sr-only">GitHub</span>
|
||||
<Github className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* X (Twitter) */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<span className="sr-only">X (Twitter)</span>
|
||||
<Twitter className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Discord (replaced with MessageSquare) */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<span className="sr-only">Discord</span>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Notifications */}
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<span className="sr-only">{t("notifications")}</span>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* User Profile Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Avatar className="h-8 w-8 cursor-pointer ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
|
||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
|
||||
</AvatarFallback>}
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center gap-3 p-2">
|
||||
<Avatar className="h-10 w-10">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
|
||||
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
|
||||
</AvatarFallback>}
|
||||
</Avatar>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="font-medium">{currentUser?.name || 'User'}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate("/profile")}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>{t("profile")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate("/settings")}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>{t("settings")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onLogout} className="text-red-500 focus:text-red-500">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{t("logout")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface ServiceFiltersProps {
|
||||
filter: string;
|
||||
setFilter: (value: string) => void;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
servicesCount: number;
|
||||
}
|
||||
|
||||
export const ServiceFilters = ({
|
||||
filter,
|
||||
setFilter,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
servicesCount
|
||||
}: ServiceFiltersProps) => {
|
||||
return (
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-xl font-semibold mr-2 text-foreground">Currently Monitoring</h3>
|
||||
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
|
||||
{servicesCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="w-40 bg-card border-border">
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="HTTP">HTTP</SelectItem>
|
||||
<SelectItem value="PING">PING</SelectItem>
|
||||
<SelectItem value="TCP">TCP</SelectItem>
|
||||
<SelectItem value="DNS">DNS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-72 bg-card border-border"
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
import { Service } from "@/types/service.types";
|
||||
import { ServicesTableContainer } from "@/components/services/ServicesTableContainer";
|
||||
|
||||
interface ServicesTableProps {
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export const ServicesTable = ({ services }: ServicesTableProps) => {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
<ServicesTableContainer services={services} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
|
||||
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, Settings, User, UserCog, Bell, FileClock, Database, RefreshCw, Info, ChevronDown, BookOpen } from "lucide-react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const Sidebar = ({
|
||||
collapsed
|
||||
}: SidebarProps) => {
|
||||
const {
|
||||
theme
|
||||
} = useTheme();
|
||||
const {
|
||||
t
|
||||
} = useLanguage();
|
||||
const location = useLocation();
|
||||
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
|
||||
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
||||
|
||||
// Update active settings item based on URL
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/settings') {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const panel = params.get('panel');
|
||||
if (panel) {
|
||||
setActiveSettingsItem(panel);
|
||||
}
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const handleSettingsItemClick = (item: string) => {
|
||||
setActiveSettingsItem(item);
|
||||
};
|
||||
|
||||
const getMenuItemClasses = (isActive: boolean) => {
|
||||
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
|
||||
};
|
||||
|
||||
// New larger icon size for the main menu
|
||||
const mainIconSize = "h-6 w-6";
|
||||
|
||||
return <div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}>
|
||||
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
|
||||
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
|
||||
<span className="text-white font-bold">C</span>
|
||||
</div>
|
||||
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
||||
</div>
|
||||
|
||||
<nav className="my-2 mx-1 py-1 px-1">
|
||||
<Link to="/dashboard" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/dashboard' ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<Globe className={`${mainIconSize} text-purple-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("uptimeMonitoring")}</span>}
|
||||
</Link>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<Boxes className={`${mainIconSize} text-blue-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<Radar className={`${mainIconSize} text-cyan-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<Calendar className={`${mainIconSize} text-emerald-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<BarChart2 className={`${mainIconSize} text-amber-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<LineChart className={`${mainIconSize} text-rose-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("reports")}</span>}
|
||||
</div>
|
||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||
<FileText className={`${mainIconSize} text-indigo-400`} />
|
||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("apiDocumentation")}</span>}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{!collapsed && <div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
|
||||
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
|
||||
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
|
||||
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 pr-4">
|
||||
<Link to={`/settings?panel=general`} className={getMenuItemClasses(activeSettingsItem === 'general')} onClick={() => handleSettingsItemClick('general')}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("generalSettings")}</span>
|
||||
</Link>
|
||||
<Link to={`/settings?panel=users`} className={getMenuItemClasses(activeSettingsItem === 'users')} onClick={() => handleSettingsItemClick('users')}>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("userManagement")}</span>
|
||||
</Link>
|
||||
<Link to={`/settings?panel=notifications`} className={getMenuItemClasses(activeSettingsItem === 'notifications')} onClick={() => handleSettingsItemClick('notifications')}>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("notificationSettings")}</span>
|
||||
</Link>
|
||||
<Link to={`/settings?panel=templates`} className={getMenuItemClasses(activeSettingsItem === 'templates')} onClick={() => handleSettingsItemClick('templates')}>
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("alertsTemplates")}</span>
|
||||
</Link>
|
||||
<div className={getMenuItemClasses(false)}>
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("dataRetention")}</span>
|
||||
</div>
|
||||
<Link to={`/settings?panel=about`} className={getMenuItemClasses(activeSettingsItem === 'about')} onClick={() => handleSettingsItemClick('about')}>
|
||||
<Info className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t("aboutSystem")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>}
|
||||
|
||||
{collapsed && <div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
|
||||
<Link to="/settings">
|
||||
<Settings className={`${mainIconSize} text-purple-400`} />
|
||||
</Link>
|
||||
</div>}
|
||||
</div>;
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react";
|
||||
import { Service } from "@/services/serviceService";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface StatusCardsProps {
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export const StatusCards = ({ services }: StatusCardsProps) => {
|
||||
// Count services by status
|
||||
const upServices = services.filter(s => s.status === "up").length;
|
||||
const downServices = services.filter(s => s.status === "down").length;
|
||||
const pausedServices = services.filter(s => s.status === "paused").length;
|
||||
const warningServices = services.filter(s => s.responseTime > 1000).length;
|
||||
|
||||
// Get current theme to adjust card styles
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 w-full">
|
||||
{/* Up Services Card */}
|
||||
<Card
|
||||
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
|
||||
theme === 'dark' ? 'dark-card' : ''
|
||||
} relative z-10`}
|
||||
style={{
|
||||
background: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #43a047 0%, #66bb6a 100%)"
|
||||
}}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white">UP SERVICES</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between relative z-10">
|
||||
<span className="text-5xl font-bold text-white">{upServices}</span>
|
||||
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||
<ArrowUp className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Down Services Card */}
|
||||
<Card
|
||||
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
|
||||
theme === 'dark' ? 'dark-card' : ''
|
||||
} relative z-10`}
|
||||
style={{
|
||||
background: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #e53935 0%, #ef5350 100%)"
|
||||
}}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white">DOWN SERVICES</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between relative z-10">
|
||||
<span className="text-5xl font-bold text-white">{downServices}</span>
|
||||
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||
<ArrowDown className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Paused Services Card */}
|
||||
<Card
|
||||
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
|
||||
theme === 'dark' ? 'dark-card' : ''
|
||||
} relative z-10`}
|
||||
style={{
|
||||
background: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)"
|
||||
}}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white">PAUSED SERVICES</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between relative z-10">
|
||||
<span className="text-5xl font-bold text-white">{pausedServices}</span>
|
||||
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||
<Pause className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Warning Services Card */}
|
||||
<Card
|
||||
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
|
||||
theme === 'dark' ? 'dark-card' : ''
|
||||
} relative z-10`}
|
||||
style={{
|
||||
background: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #ff9800 0%, #ffb74d 100%)"
|
||||
}}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white">WARNING SERVICES</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between relative z-10">
|
||||
<span className="text-5xl font-bold text-white">{warningServices}</span>
|
||||
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||
<AlertTriangle className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { authService } from "@/services/authService";
|
||||
|
||||
// Password change form schema
|
||||
const passwordFormSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string().min(8, "Confirm password is required"),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type PasswordFormValues = z.infer<typeof passwordFormSchema>;
|
||||
|
||||
interface ChangePasswordFormProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<PasswordFormValues>({
|
||||
resolver: zodResolver(passwordFormSchema),
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: PasswordFormValues) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// PocketBase requires the old password along with the new one
|
||||
await pb.collection('users').update(userId, {
|
||||
oldPassword: data.currentPassword,
|
||||
password: data.newPassword,
|
||||
passwordConfirm: data.confirmPassword,
|
||||
});
|
||||
|
||||
// Refresh auth data to ensure token remains valid
|
||||
await authService.refreshUserData();
|
||||
|
||||
toast({
|
||||
title: "Password updated",
|
||||
description: "Your password has been changed successfully.",
|
||||
});
|
||||
|
||||
// Reset the form
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
console.error("Password change error:", error);
|
||||
|
||||
let errorMessage = "Failed to update password. Please try again.";
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Password change failed",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
placeholder="Your current password"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
>
|
||||
{showCurrentPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span className="sr-only">
|
||||
{showCurrentPassword ? "Hide password" : "Show password"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
placeholder="New password"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
>
|
||||
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span className="sr-only">
|
||||
{showNewPassword ? "Hide password" : "Show password"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm new password"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span className="sr-only">
|
||||
{showConfirmPassword ? "Hide password" : "Show password"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Change Password"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { User } from "@/services/userService";
|
||||
import { UserProfileDetails } from "./UserProfileDetails";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ChangePasswordForm } from "./ChangePasswordForm";
|
||||
import { UpdateProfileForm } from "./UpdateProfileForm";
|
||||
|
||||
interface ProfileContentProps {
|
||||
currentUser: User | null;
|
||||
}
|
||||
|
||||
export function ProfileContent({ currentUser }: ProfileContentProps) {
|
||||
const [activeTab, setActiveTab] = useState("details");
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Profile</CardTitle>
|
||||
<CardDescription>Your profile information could not be loaded</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">My Profile</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Left column - Profile summary card */}
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserProfileDetails user={currentUser} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right column - Profile tabs for edit and password change */}
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>My Account</CardTitle>
|
||||
<CardDescription>Manage your account settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsTrigger value="details">Profile Details</TabsTrigger>
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="pt-4">
|
||||
<UpdateProfileForm user={currentUser} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="pt-4">
|
||||
<ChangePasswordForm userId={currentUser.id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
<CardFooter className="text-sm text-muted-foreground">
|
||||
Last updated: {new Date(currentUser.updated).toLocaleString()}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User, userService } from "@/services/userService";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { authService } from "@/services/authService";
|
||||
|
||||
// Profile update form schema
|
||||
const profileFormSchema = z.object({
|
||||
full_name: z.string().min(2, {
|
||||
message: "Name must be at least 2 characters.",
|
||||
}),
|
||||
username: z.string().min(3, {
|
||||
message: "Username must be at least 3 characters.",
|
||||
}),
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
});
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileFormSchema>;
|
||||
|
||||
interface UpdateProfileFormProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Initialize the form with current user data
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileFormSchema),
|
||||
defaultValues: {
|
||||
full_name: user.full_name || "",
|
||||
username: user.username || "",
|
||||
email: user.email || "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: ProfileFormValues) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await userService.updateUser(user.id, {
|
||||
full_name: data.full_name,
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
});
|
||||
|
||||
// Refresh user data in auth context
|
||||
await authService.refreshUserData();
|
||||
|
||||
toast({
|
||||
title: "Profile updated",
|
||||
description: "Your profile information has been updated successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Profile update error:", error);
|
||||
|
||||
let errorMessage = "Failed to update profile. Please try again.";
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="full_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your full name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="your.email@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
|
||||
import { User } from "@/services/userService";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Mail, User as UserIcon } from "lucide-react";
|
||||
|
||||
interface UserProfileDetailsProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function UserProfileDetails({ user }: UserProfileDetailsProps) {
|
||||
// Format dates
|
||||
const createdDate = new Date(user.created).toLocaleDateString();
|
||||
|
||||
// Get avatar or initials
|
||||
const getInitials = () => {
|
||||
if (user.full_name) {
|
||||
return user.full_name.split(' ')
|
||||
.map(name => name[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
return user.username[0].toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Avatar className="h-32 w-32">
|
||||
{user.avatar ? (
|
||||
<AvatarImage src={user.avatar} alt={user.full_name || user.username} />
|
||||
) : (
|
||||
<AvatarFallback className="text-3xl bg-primary/20 text-primary">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
|
||||
<div className="space-y-1 text-center">
|
||||
<h2 className="text-xl font-bold">{user.full_name || user.username}</h2>
|
||||
<div className="flex items-center justify-center text-sm text-muted-foreground gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center text-sm text-muted-foreground gap-1">
|
||||
<UserIcon className="h-3 w-3" />
|
||||
<span>@{user.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full pt-2">
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
||||
{user.role && (
|
||||
<Badge variant="secondary" className="px-2 py-1">
|
||||
{user.role}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={user.isActive ? "default" : "outline"} className="px-2 py-1">
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
{user.verified && (
|
||||
<Badge className="bg-green-600 hover:bg-green-700 px-2 py-1">
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mt-4 pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Member since: {createdDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ServiceForm } from "./ServiceForm";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface AddServiceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) {
|
||||
const handleSuccess = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Create New Service</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Fill in the details to create a new service to monitor.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
|
||||
<div className="pr-2">
|
||||
<ServiceForm onSuccess={handleSuccess} onCancel={handleCancel} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { format, subDays, subHours, subMonths, subWeeks, subYears } from "date-fns";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type DateRangeOption = '60min' | '24h' | '7d' | '30d' | '1y' | 'custom';
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
onRangeChange: (startDate: Date, endDate: Date, option: DateRangeOption) => void;
|
||||
selectedOption?: DateRangeOption;
|
||||
}
|
||||
|
||||
// Define a proper type for the date range
|
||||
interface DateRange {
|
||||
from: Date | undefined;
|
||||
to: Date | undefined;
|
||||
}
|
||||
|
||||
export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateRangeFilterProps) {
|
||||
const [currentOption, setCurrentOption] = useState<DateRangeOption>(selectedOption);
|
||||
const [customDateRange, setCustomDateRange] = useState<DateRange>({
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
});
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
|
||||
const handleOptionChange = (value: string) => {
|
||||
const option = value as DateRangeOption;
|
||||
setCurrentOption(option);
|
||||
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (option) {
|
||||
case '60min':
|
||||
// Ensure we're getting exactly 60 minutes ago
|
||||
startDate = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
console.log(`60min option selected: ${startDate.toISOString()} to ${now.toISOString()}`);
|
||||
break;
|
||||
case '24h':
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '7d':
|
||||
startDate = subDays(now, 7);
|
||||
break;
|
||||
case '30d':
|
||||
startDate = subDays(now, 30);
|
||||
break;
|
||||
case '1y':
|
||||
startDate = subYears(now, 1);
|
||||
break;
|
||||
case 'custom':
|
||||
// Don't trigger onRangeChange for custom until both dates are selected
|
||||
setIsCalendarOpen(true);
|
||||
return;
|
||||
default:
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
console.log(`DateRangeFilter: Option changed to ${option}, date range: ${startDate.toISOString()} to ${now.toISOString()}`);
|
||||
onRangeChange(startDate, now, option);
|
||||
};
|
||||
|
||||
// Handle custom date range selection
|
||||
const handleCustomRangeSelect = (range: DateRange | undefined) => {
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomDateRange(range);
|
||||
|
||||
if (range.from && range.to) {
|
||||
// Ensure that we have both from and to dates before triggering the change
|
||||
const startOfDay = new Date(range.from);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(range.to);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
console.log(`DateRangeFilter: Custom range selected: ${startOfDay.toISOString()} to ${endOfDay.toISOString()}`);
|
||||
onRangeChange(startOfDay, endOfDay, 'custom');
|
||||
setIsCalendarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={currentOption} onValueChange={handleOptionChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="60min">Last 60 minutes</SelectItem>
|
||||
<SelectItem value="24h">Last 24 hours</SelectItem>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||
<SelectItem value="1y">Last year</SelectItem>
|
||||
<SelectItem value="custom">Custom range</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{currentOption === 'custom' && (
|
||||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-[280px] justify-start text-left font-normal",
|
||||
!customDateRange.from && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{customDateRange.from ? (
|
||||
customDateRange.to ? (
|
||||
<>
|
||||
{format(customDateRange.from, "LLL dd, y")} -{" "}
|
||||
{format(customDateRange.to, "LLL dd, y")}
|
||||
</>
|
||||
) : (
|
||||
format(customDateRange.from, "LLL dd, y")
|
||||
)
|
||||
) : (
|
||||
"Pick a date range"
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="center">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={customDateRange}
|
||||
onSelect={handleCustomRangeSelect}
|
||||
initialFocus
|
||||
className="pointer-events-auto"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
import React from "react";
|
||||
import { Clock, TimerOff } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface LastCheckedTimeProps {
|
||||
lastCheckedTime: string;
|
||||
status?: string;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => {
|
||||
// Format the time without seconds to display a static time
|
||||
const formatTimeWithoutSeconds = (timeString: string) => {
|
||||
try {
|
||||
const date = new Date(timeString);
|
||||
|
||||
// Check if it's a valid date
|
||||
if (isNaN(date.getTime())) {
|
||||
// If it's already in HH:MM format, just return it
|
||||
if (timeString.includes(':') && !timeString.includes(':00:')) {
|
||||
return timeString;
|
||||
}
|
||||
return timeString;
|
||||
}
|
||||
|
||||
// Format to only show hours and minutes (HH:MM)
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (e) {
|
||||
return timeString;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the formatted time without creating a new Date for paused services
|
||||
const formattedTime = formatTimeWithoutSeconds(lastCheckedTime);
|
||||
|
||||
// Explicitly prevent real-time updates for paused services
|
||||
const isPaused = status === "paused";
|
||||
|
||||
// Format the interval for display
|
||||
const formatInterval = (seconds: number) => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return `${minutes}min`;
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-400 cursor-help">
|
||||
{isPaused ? (
|
||||
<TimerOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4" />
|
||||
)}
|
||||
<span>
|
||||
{isPaused ? "Paused at " : ""}
|
||||
{formattedTime}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="font-medium">
|
||||
{isPaused ? "Monitoring Paused" : "Last Check Details"}
|
||||
</div>
|
||||
<div>
|
||||
{isPaused ? "No automatic checks" : `Checked at ${formattedTime}`}
|
||||
</div>
|
||||
{interval && !isPaused && (
|
||||
<div>
|
||||
Check interval: {formatInterval(interval)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-gray-400 text-[10px]">
|
||||
{new Date(lastCheckedTime).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
export function LoadingState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-background text-foreground">
|
||||
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
|
||||
<div className="relative flex items-center justify-center mb-4">
|
||||
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
|
||||
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium mb-1">Loading server data</h3>
|
||||
<p className="text-muted-foreground">Please wait while we retrieve your information...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from "recharts";
|
||||
import { UptimeData } from "@/types/service.types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useMemo } from "react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface ResponseTimeChartProps {
|
||||
uptimeData: UptimeData[];
|
||||
}
|
||||
|
||||
export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Format data for the chart with enhanced time formatting
|
||||
const chartData = useMemo(() => {
|
||||
if (!uptimeData || uptimeData.length === 0) return [];
|
||||
|
||||
// Sort by timestamp ascending (oldest to newest)
|
||||
const sortedData = [...uptimeData].sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
// Get time format based on data density
|
||||
const isShortTimeRange = uptimeData.length > 0 &&
|
||||
(new Date(uptimeData[0].timestamp).getTime() - new Date(uptimeData[uptimeData.length - 1].timestamp).getTime()) < 2 * 60 * 60 * 1000;
|
||||
|
||||
return sortedData.map(data => {
|
||||
const timestamp = new Date(data.timestamp);
|
||||
|
||||
return {
|
||||
time: isShortTimeRange
|
||||
? format(timestamp, 'HH:mm:ss') // Include seconds for short time ranges like 60min
|
||||
: format(timestamp, 'HH:mm'),
|
||||
rawTime: timestamp.getTime(),
|
||||
date: format(timestamp, 'MMM dd, yyyy'),
|
||||
value: data.status === "paused" ? null : data.responseTime,
|
||||
status: data.status,
|
||||
};
|
||||
});
|
||||
}, [uptimeData]);
|
||||
|
||||
// Create a custom tooltip for the chart
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
|
||||
// Set background color based on status
|
||||
let statusColor = "bg-emerald-800";
|
||||
let statusText = "Up";
|
||||
|
||||
if (data.status === "down") {
|
||||
statusColor = "bg-red-800";
|
||||
statusText = "Down";
|
||||
} else if (data.status === "warning") {
|
||||
statusColor = "bg-yellow-800";
|
||||
statusText = "Warning";
|
||||
} else if (data.status === "paused") {
|
||||
statusColor = "bg-gray-800";
|
||||
statusText = "Paused";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} p-2 border ${theme === 'dark' ? 'border-gray-700' : 'border-gray-200'} rounded shadow-md`}>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.date}</p>
|
||||
<div className={`flex items-center gap-2 mt-1 ${data.status === "paused" ? "opacity-70" : ""}`}>
|
||||
<div className={`w-3 h-3 rounded-full ${statusColor}`}></div>
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm">
|
||||
{data.status === "paused" ? "Monitoring paused" :
|
||||
data.value !== null ? `${data.value} ms` : "No data"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Compute status segments for different areas
|
||||
const getStatusSegments = () => {
|
||||
const segments = {
|
||||
up: [] as any[],
|
||||
down: [] as any[],
|
||||
warning: [] as any[]
|
||||
};
|
||||
|
||||
chartData.forEach(point => {
|
||||
if (point.status === "paused") return;
|
||||
|
||||
if (point.status === "up") {
|
||||
segments.up.push(point);
|
||||
} else if (point.status === "down") {
|
||||
segments.down.push(point);
|
||||
} else if (point.status === "warning") {
|
||||
segments.warning.push(point);
|
||||
}
|
||||
});
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
const segments = getStatusSegments();
|
||||
|
||||
// Check if we have any data to display - be more lenient by checking raw uptimeData
|
||||
const hasData = uptimeData.length > 0;
|
||||
|
||||
// Get date range for display
|
||||
const dateRange = useMemo(() => {
|
||||
if (!chartData.length) return { start: "", end: "" };
|
||||
|
||||
const sortedData = [...chartData].sort((a, b) => a.rawTime - b.rawTime);
|
||||
return {
|
||||
start: sortedData[0]?.date || "",
|
||||
end: sortedData[sortedData.length - 1]?.date || ""
|
||||
};
|
||||
}, [chartData]);
|
||||
|
||||
// Display date range if different dates
|
||||
const dateRangeDisplay = dateRange.start === dateRange.end
|
||||
? dateRange.start
|
||||
: `${dateRange.start} - ${dateRange.end}`;
|
||||
|
||||
return (
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
|
||||
<span>Response Time History</span>
|
||||
{hasData && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{dateRangeDisplay}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasData ? (
|
||||
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
||||
<p>No data available for the selected time period.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorUp" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0.2}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorDown" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="#ef4444" stopOpacity={0.2}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorWarning" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0.2}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
tick={{ fontSize: 10 }}
|
||||
height={60}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={5}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
|
||||
allowDecimals={false}
|
||||
domain={['dataMin - 10', 'dataMax + 10']}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
{/* Area charts for different statuses */}
|
||||
{segments.up.length > 0 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
data={segments.up}
|
||||
stroke="#10b981"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUp)"
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
|
||||
{segments.down.length > 0 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
data={segments.down}
|
||||
stroke="#ef4444"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorDown)"
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
|
||||
{segments.warning.length > 0 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
data={segments.warning}
|
||||
stroke="#f59e0b"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorWarning)"
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add reference lines for paused periods */}
|
||||
{chartData.map((entry, index) =>
|
||||
entry.status === 'paused' ? (
|
||||
<ReferenceLine
|
||||
key={`ref-${index}`}
|
||||
x={entry.time}
|
||||
stroke="#9ca3af"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface ServiceDeleteDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedService: Service | null;
|
||||
onConfirmDelete: () => Promise<void>;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const ServiceDeleteDialog = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
selectedService,
|
||||
onConfirmDelete,
|
||||
isDeleting = false,
|
||||
}: ServiceDeleteDialogProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!isDeleting) {
|
||||
await onConfirmDelete();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className={`${theme === 'dark' ? 'bg-gray-900 text-white border-gray-800' : 'bg-background text-foreground border-border'}`}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete this service?</AlertDialogTitle>
|
||||
<AlertDialogDescription className={theme === 'dark' ? 'text-gray-400' : 'text-muted-foreground'}>
|
||||
This action cannot be undone. This will permanently delete{' '}
|
||||
<span className={theme === 'dark' ? 'font-semibold text-white' : 'font-semibold text-foreground'}>
|
||||
{selectedService?.name}
|
||||
</span>{' '}
|
||||
and all of its uptime records.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
className={theme === 'dark' ? 'bg-gray-800 text-white border-gray-700 hover:bg-gray-700' : 'bg-secondary'}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={isDeleting}
|
||||
className={theme === 'dark' ? 'bg-red-900 text-white hover:bg-red-800' : 'bg-red-600 text-white hover:bg-red-700'}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
import { ServiceDetailContainer as NewServiceDetailContainer } from "./ServiceDetailContainer/index";
|
||||
|
||||
export const ServiceDetailContainer = () => {
|
||||
return <NewServiceDetailContainer />;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
import React from "react";
|
||||
import { Sidebar } from "@/components/dashboard/Sidebar";
|
||||
import { Header } from "@/components/dashboard/Header";
|
||||
import { LoadingState } from "@/components/services/LoadingState";
|
||||
import { ServiceNotFound } from "@/components/services/ServiceNotFound";
|
||||
import { Service } from "@/types/service.types";
|
||||
|
||||
interface ServiceDetailWrapperProps {
|
||||
children: React.ReactNode;
|
||||
isLoading: boolean;
|
||||
service: Service | null;
|
||||
sidebarCollapsed: boolean;
|
||||
toggleSidebar: () => void;
|
||||
currentUser: any;
|
||||
handleLogout: () => void;
|
||||
}
|
||||
|
||||
export const ServiceDetailWrapper = ({
|
||||
children,
|
||||
isLoading,
|
||||
service,
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
currentUser,
|
||||
handleLogout
|
||||
}: ServiceDetailWrapperProps) => {
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Header
|
||||
currentUser={currentUser}
|
||||
onLogout={handleLogout}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
toggleSidebar={toggleSidebar}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : !service ? (
|
||||
<ServiceNotFound />
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
export * from './useServiceData';
|
||||
export * from './useRealTimeUpdates';
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { Service, UptimeData } from "@/types/service.types";
|
||||
|
||||
interface UseRealTimeUpdatesProps {
|
||||
serviceId: string | undefined;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
setService: React.Dispatch<React.SetStateAction<Service | null>>;
|
||||
setUptimeData: React.Dispatch<React.SetStateAction<UptimeData[]>>;
|
||||
}
|
||||
|
||||
export const useRealTimeUpdates = ({
|
||||
serviceId,
|
||||
startDate,
|
||||
endDate,
|
||||
setService,
|
||||
setUptimeData
|
||||
}: UseRealTimeUpdatesProps) => {
|
||||
// Listen for real-time updates to this service
|
||||
useEffect(() => {
|
||||
if (!serviceId) return;
|
||||
|
||||
console.log(`Setting up real-time updates for service: ${serviceId}`);
|
||||
|
||||
try {
|
||||
// Subscribe to the service record for real-time updates
|
||||
const subscription = pb.collection('services').subscribe(serviceId, function(e) {
|
||||
console.log("Service updated:", e.record);
|
||||
|
||||
// Update our local state with the new data
|
||||
if (e.record) {
|
||||
setService(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
status: e.record.status || prev.status,
|
||||
responseTime: e.record.response_time || e.record.responseTime || prev.responseTime,
|
||||
uptime: e.record.uptime || prev.uptime,
|
||||
lastChecked: e.record.last_checked || e.record.lastChecked || prev.lastChecked,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to uptime data updates
|
||||
const uptimeSubscription = pb.collection('uptime_data').subscribe('*', function(e) {
|
||||
if (e.record && e.record.service_id === serviceId) {
|
||||
console.log("New uptime data:", e.record);
|
||||
|
||||
// Add the new uptime data to our list if it's within the selected date range
|
||||
const timestamp = new Date(e.record.timestamp);
|
||||
if (timestamp >= startDate && timestamp <= endDate) {
|
||||
setUptimeData(prev => {
|
||||
const newData: UptimeData = {
|
||||
id: e.record.id,
|
||||
serviceId: e.record.service_id,
|
||||
timestamp: e.record.timestamp,
|
||||
status: e.record.status,
|
||||
responseTime: e.record.response_time || 0,
|
||||
date: e.record.timestamp, // Adding required date property
|
||||
uptime: e.record.uptime || 0 // Adding required uptime property
|
||||
};
|
||||
|
||||
// Add at the beginning of the array to maintain newest first sorting
|
||||
return [newData, ...prev];
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up the subscriptions
|
||||
return () => {
|
||||
console.log(`Cleaning up subscriptions for service: ${serviceId}`);
|
||||
try {
|
||||
pb.collection('services').unsubscribe(serviceId);
|
||||
pb.collection('uptime_data').unsubscribe('*');
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up subscriptions:", error);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error setting up real-time updates:", error);
|
||||
}
|
||||
}, [serviceId, startDate, endDate, setService, setUptimeData]);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { Service, UptimeData } from "@/types/service.types";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { uptimeService } from "@/services/uptimeService";
|
||||
|
||||
export const useServiceData = (serviceId: string | undefined, startDate: Date, endDate: Date) => {
|
||||
const [service, setService] = useState<Service | null>(null);
|
||||
const [uptimeData, setUptimeData] = useState<UptimeData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Handler for service status changes
|
||||
const handleStatusChange = async (newStatus: "up" | "down" | "paused" | "warning") => {
|
||||
if (!service || !serviceId) return;
|
||||
|
||||
try {
|
||||
// Optimistic UI update
|
||||
setService({ ...service, status: newStatus as Service["status"] });
|
||||
|
||||
// Update the service status in PocketBase
|
||||
await pb.collection('services').update(serviceId, {
|
||||
status: newStatus
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Status updated",
|
||||
description: `Service status changed to ${newStatus}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update service status:", error);
|
||||
// Revert the optimistic update
|
||||
setService(prevService => prevService);
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Update failed",
|
||||
description: "Could not update service status. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Function to fetch uptime data with date filters
|
||||
const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange: string) => {
|
||||
try {
|
||||
console.log(`Fetching uptime data from ${start.toISOString()} to ${end.toISOString()}`);
|
||||
|
||||
// Set appropriate limits based on time range to ensure enough granularity
|
||||
let limit = 200; // default
|
||||
|
||||
// Adjust limits based on selected range
|
||||
if (selectedRange === '60min') {
|
||||
limit = 300; // More points for shorter time ranges
|
||||
} else if (selectedRange === '24h') {
|
||||
limit = 200;
|
||||
} else if (selectedRange === '7d') {
|
||||
limit = 250;
|
||||
} else if (selectedRange === '30d' || selectedRange === '1y') {
|
||||
limit = 300; // More points for longer time ranges
|
||||
}
|
||||
|
||||
console.log(`Using limit ${limit} for range ${selectedRange}`);
|
||||
|
||||
const history = await uptimeService.getUptimeHistory(serviceId, limit, start, end);
|
||||
console.log(`Fetched ${history.length} uptime records for time range ${selectedRange}`);
|
||||
|
||||
if (history.length === 0) {
|
||||
console.log("No data returned from API, checking if we need to fetch with a higher limit");
|
||||
// If no data, try with a higher limit as fallback
|
||||
if (limit < 500) {
|
||||
const extendedHistory = await uptimeService.getUptimeHistory(serviceId, 500, start, end);
|
||||
console.log(`Fallback: Fetched ${extendedHistory.length} uptime records with higher limit`);
|
||||
|
||||
if (extendedHistory.length > 0) {
|
||||
setUptimeData(extendedHistory);
|
||||
return extendedHistory;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort data by timestamp (newest first)
|
||||
const sortedHistory = [...history].sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
setUptimeData(sortedHistory);
|
||||
return sortedHistory;
|
||||
} catch (error) {
|
||||
console.error("Error fetching uptime data:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to load uptime history. Please try again.",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Initial data loading
|
||||
useEffect(() => {
|
||||
const fetchServiceData = async () => {
|
||||
try {
|
||||
if (!serviceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Add a timeout to prevent hanging
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Request timed out")), 10000);
|
||||
});
|
||||
|
||||
const fetchPromise = pb.collection('services').getOne(serviceId);
|
||||
const serviceData = await Promise.race([fetchPromise, timeoutPromise]) as any;
|
||||
|
||||
const formattedService: Service = {
|
||||
id: serviceData.id,
|
||||
name: serviceData.name,
|
||||
url: serviceData.url || "",
|
||||
type: serviceData.service_type || serviceData.type || "HTTP",
|
||||
status: serviceData.status || "paused",
|
||||
responseTime: serviceData.response_time || serviceData.responseTime || 0,
|
||||
uptime: serviceData.uptime || 0,
|
||||
lastChecked: serviceData.last_checked || serviceData.lastChecked || new Date().toLocaleString(),
|
||||
interval: serviceData.heartbeat_interval || serviceData.interval || 60,
|
||||
retries: serviceData.max_retries || serviceData.retries || 3,
|
||||
notificationChannel: serviceData.notification_id,
|
||||
alertTemplate: serviceData.template_id,
|
||||
alerts: serviceData.alerts || "unmuted"
|
||||
};
|
||||
|
||||
setService(formattedService);
|
||||
|
||||
// Fetch uptime history with date range
|
||||
await fetchUptimeData(serviceId, startDate, endDate, '24h');
|
||||
} catch (error) {
|
||||
console.error("Error fetching service:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to load service data. Please try again.",
|
||||
});
|
||||
navigate("/dashboard");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchServiceData();
|
||||
}, [serviceId, navigate, toast]);
|
||||
|
||||
// Update data when date range changes
|
||||
useEffect(() => {
|
||||
if (serviceId && !isLoading) {
|
||||
fetchUptimeData(serviceId, startDate, endDate, '24h');
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
return {
|
||||
service,
|
||||
setService,
|
||||
uptimeData,
|
||||
setUptimeData,
|
||||
isLoading,
|
||||
handleStatusChange,
|
||||
fetchUptimeData
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { DateRangeOption } from "../DateRangeFilter";
|
||||
import { authService } from "@/services/authService";
|
||||
import { ServiceDetailContent } from "../ServiceDetailContent";
|
||||
import { ServiceDetailWrapper } from "./ServiceDetailWrapper";
|
||||
import { useServiceData, useRealTimeUpdates } from "./hooks";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
|
||||
export const ServiceDetailContainer = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Ensure we use exact timestamp for startDate
|
||||
const [startDate, setStartDate] = useState<Date>(() => {
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() - 24);
|
||||
return date;
|
||||
});
|
||||
const [endDate, setEndDate] = useState<Date>(new Date());
|
||||
const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h');
|
||||
|
||||
// State for sidebar collapse functionality (shared with Dashboard)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
// Check if there's a saved preference in localStorage
|
||||
const saved = localStorage.getItem("sidebarCollapsed");
|
||||
return saved ? JSON.parse(saved) : window.innerWidth < 768;
|
||||
});
|
||||
|
||||
// Toggle sidebar and save preference
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarCollapsed(prev => {
|
||||
const newState = !prev;
|
||||
localStorage.setItem("sidebarCollapsed", JSON.stringify(newState));
|
||||
return newState;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Get current user for header
|
||||
const currentUser = authService.getCurrentUser();
|
||||
|
||||
useEffect(() => {
|
||||
// Verify user is authenticated
|
||||
if (!authService.isAuthenticated()) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Authentication required",
|
||||
description: "Please log in to view service details",
|
||||
});
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
// Auto-collapse sidebar on small screens
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 768 && !sidebarCollapsed) {
|
||||
setSidebarCollapsed(true);
|
||||
localStorage.setItem("sidebarCollapsed", JSON.stringify(true));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [navigate, sidebarCollapsed]);
|
||||
|
||||
// Handler for logout (same as Dashboard)
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
// Use our custom hooks
|
||||
const {
|
||||
service,
|
||||
uptimeData,
|
||||
isLoading,
|
||||
handleStatusChange,
|
||||
fetchUptimeData,
|
||||
setService,
|
||||
setUptimeData
|
||||
} = useServiceData(id, startDate, endDate);
|
||||
|
||||
// Set up real-time updates
|
||||
useRealTimeUpdates({
|
||||
serviceId: id,
|
||||
startDate,
|
||||
endDate,
|
||||
setService,
|
||||
setUptimeData
|
||||
});
|
||||
|
||||
// Handle date range filter changes
|
||||
const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => {
|
||||
console.log(`Date range changed: ${start.toISOString()} to ${end.toISOString()}, option: ${option}`);
|
||||
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
setSelectedRange(option);
|
||||
|
||||
// Refetch uptime data with new date range, passing the selected range option
|
||||
if (id) {
|
||||
fetchUptimeData(id, start, end, option);
|
||||
}
|
||||
}, [id, fetchUptimeData]);
|
||||
|
||||
return (
|
||||
<ServiceDetailWrapper
|
||||
isLoading={isLoading}
|
||||
service={service}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
toggleSidebar={toggleSidebar}
|
||||
currentUser={currentUser}
|
||||
handleLogout={handleLogout}
|
||||
>
|
||||
{service && (
|
||||
<ServiceDetailContent
|
||||
service={service}
|
||||
uptimeData={uptimeData}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
onStatusChange={handleStatusChange}
|
||||
selectedDateOption={selectedRange}
|
||||
/>
|
||||
)}
|
||||
</ServiceDetailWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
import { Service, UptimeData } from "@/types/service.types";
|
||||
import { ServiceHeader } from "@/components/services/ServiceHeader";
|
||||
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
|
||||
import { ResponseTimeChart } from "@/components/services/ResponseTimeChart";
|
||||
import { LatestChecksTable } from "@/components/services/incident-history";
|
||||
import { DateRangeFilter, DateRangeOption } from "@/components/services/DateRangeFilter";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface ServiceDetailContentProps {
|
||||
service: Service;
|
||||
uptimeData: UptimeData[];
|
||||
onDateRangeChange: (start: Date, end: Date, option: DateRangeOption) => void;
|
||||
onStatusChange: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
||||
selectedDateOption: DateRangeOption;
|
||||
}
|
||||
|
||||
export const ServiceDetailContent = ({
|
||||
service,
|
||||
uptimeData,
|
||||
onDateRangeChange,
|
||||
onStatusChange,
|
||||
selectedDateOption
|
||||
}: ServiceDetailContentProps) => {
|
||||
// Check if data is available
|
||||
const hasUptimeData = uptimeData && uptimeData.length > 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 pb-0 h-full overflow-auto">
|
||||
<ServiceHeader service={service} onStatusChange={onStatusChange} />
|
||||
<ServiceStatsCards service={service} uptimeData={uptimeData} />
|
||||
|
||||
<div className="mb-4 md:mb-6 mt-6 md:mt-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 md:gap-0">
|
||||
<h2 className="text-lg md:text-xl font-medium">Response Time History</h2>
|
||||
<DateRangeFilter
|
||||
onRangeChange={onDateRangeChange}
|
||||
selectedOption={selectedDateOption}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hasUptimeData && (
|
||||
<Card className="mb-6 md:mb-8">
|
||||
<CardContent className="flex items-center justify-center py-8 md:py-12">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="h-10 w-10 md:h-12 md:w-12 text-amber-500 mx-auto mb-2 md:mb-3 opacity-70" />
|
||||
<h3 className="text-base md:text-lg font-medium mb-1">No uptime data available</h3>
|
||||
<p className="text-muted-foreground max-w-md text-sm md:text-base">
|
||||
There's no monitoring data for this service in the selected time period.
|
||||
This could be because the service was recently added or monitoring is paused.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasUptimeData && <ResponseTimeChart uptimeData={uptimeData} />}
|
||||
|
||||
<div className="pb-6">
|
||||
<LatestChecksTable uptimeData={uptimeData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ServiceForm } from "./ServiceForm";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface ServiceEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
service: Service | null;
|
||||
}
|
||||
|
||||
export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset submission state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
// Invalidate the services query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
setIsSubmitting(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!isSubmitting) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Only render the form if dialog is open and service data exists
|
||||
// This prevents form validation errors when dialog is closed
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(newOpen) => {
|
||||
// Only allow closing if not currently submitting
|
||||
if (!isSubmitting || !newOpen) {
|
||||
onOpenChange(newOpen);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Edit Service</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Update the details of your monitored service.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{open && service && (
|
||||
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
|
||||
<div className="pr-2">
|
||||
<ServiceForm
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
initialData={service}
|
||||
isEdit={true}
|
||||
onSubmitStart={() => setIsSubmitting(true)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { serviceSchema, ServiceFormData } from "./add-service/types";
|
||||
import { ServiceBasicFields } from "./add-service/ServiceBasicFields";
|
||||
import { ServiceTypeField } from "./add-service/ServiceTypeField";
|
||||
import { ServiceConfigFields } from "./add-service/ServiceConfigFields";
|
||||
import { ServiceNotificationFields } from "./add-service/ServiceNotificationFields";
|
||||
import { ServiceFormActions } from "./add-service/ServiceFormActions";
|
||||
import { serviceService } from "@/services/serviceService";
|
||||
import { Service } from "@/types/service.types";
|
||||
|
||||
interface ServiceFormProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Service | null;
|
||||
isEdit?: boolean;
|
||||
onSubmitStart?: () => void;
|
||||
}
|
||||
|
||||
export function ServiceForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
initialData,
|
||||
isEdit = false,
|
||||
onSubmitStart
|
||||
}: ServiceFormProps) {
|
||||
const { toast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Initialize form with default values
|
||||
const form = useForm<ServiceFormData>({
|
||||
resolver: zodResolver(serviceSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
type: "http",
|
||||
url: "",
|
||||
interval: "60",
|
||||
retries: "3",
|
||||
notificationChannel: "",
|
||||
alertTemplate: "",
|
||||
},
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
// Populate form when initialData changes (separate from initialization)
|
||||
useEffect(() => {
|
||||
if (initialData && isEdit) {
|
||||
// Reset the form with initial data values
|
||||
form.reset({
|
||||
name: initialData.name || "",
|
||||
type: (initialData.type || "http").toLowerCase(),
|
||||
url: initialData.url || "",
|
||||
interval: String(initialData.interval || 60),
|
||||
retries: String(initialData.retries || 3),
|
||||
notificationChannel: initialData.notificationChannel || "",
|
||||
alertTemplate: initialData.alertTemplate || "",
|
||||
});
|
||||
|
||||
// Log for debugging
|
||||
console.log("Populating form with URL:", initialData.url);
|
||||
}
|
||||
}, [initialData, isEdit, form]);
|
||||
|
||||
const handleSubmit = async (data: ServiceFormData) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
if (onSubmitStart) onSubmitStart();
|
||||
|
||||
try {
|
||||
console.log("Form data being submitted:", data); // Debug log for submitted data
|
||||
|
||||
if (isEdit && initialData) {
|
||||
// Update existing service
|
||||
await serviceService.updateService(initialData.id, {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
url: data.url,
|
||||
interval: parseInt(data.interval),
|
||||
retries: parseInt(data.retries),
|
||||
notificationChannel: data.notificationChannel === "none" ? "" : data.notificationChannel,
|
||||
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Service updated",
|
||||
description: `${data.name} has been updated successfully.`,
|
||||
});
|
||||
} else {
|
||||
// Create new service
|
||||
await serviceService.createService({
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
url: data.url,
|
||||
interval: parseInt(data.interval),
|
||||
retries: parseInt(data.retries),
|
||||
notificationChannel: data.notificationChannel === "none" ? undefined : data.notificationChannel,
|
||||
alertTemplate: data.alertTemplate === "default" ? undefined : data.alertTemplate,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Service created",
|
||||
description: `${data.name} has been added to monitoring.`,
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
if (!isEdit) {
|
||||
form.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error ${isEdit ? 'updating' : 'creating'} service:`, error);
|
||||
toast({
|
||||
title: `Failed to ${isEdit ? 'update' : 'create'} service`,
|
||||
description: `An error occurred while ${isEdit ? 'updating' : 'creating'} the service.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6">
|
||||
<ServiceBasicFields form={form} />
|
||||
<ServiceTypeField form={form} />
|
||||
<ServiceConfigFields form={form} />
|
||||
<ServiceNotificationFields form={form} />
|
||||
<ServiceFormActions
|
||||
isSubmitting={isSubmitting}
|
||||
onCancel={onCancel}
|
||||
submitLabel={isEdit ? "Update Service" : "Create Service"}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
import { ArrowLeft, Globe, MoreVertical, FileText, Github, Twitter, MessageSquare, Bell } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/services/StatusBadge";
|
||||
import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ServiceHeaderProps {
|
||||
service: Service;
|
||||
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
||||
}
|
||||
|
||||
export function ServiceHeader({ service, onStatusChange }: ServiceHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-4 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold">{service.name}</h1>
|
||||
|
||||
{/* Pulsating Circle Animation */}
|
||||
<div className="relative ml-2 flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-3 w-3 relative",
|
||||
service.status === "up" ? "bg-green-500" :
|
||||
service.status === "down" ? "bg-red-500" :
|
||||
service.status === "warning" ? "bg-yellow-500" :
|
||||
"bg-blue-500",
|
||||
"rounded-full"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"animate-ping absolute h-3 w-3",
|
||||
service.status === "up" ? "bg-green-400" :
|
||||
service.status === "down" ? "bg-red-400" :
|
||||
service.status === "warning" ? "bg-yellow-400" :
|
||||
"bg-blue-400",
|
||||
"rounded-full opacity-75"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{service.url && (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
|
||||
>
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
{service.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<StatusBadge status={service.status} size="lg" />
|
||||
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
||||
<span className="sr-only">More options</span>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { ServiceUptimeHistory } from "@/components/services/ServiceUptimeHistory";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { DateRangeFilter, DateRangeOption } from "@/components/services/DateRangeFilter";
|
||||
|
||||
interface ServiceHistoryDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedService: Service | null;
|
||||
}
|
||||
|
||||
export const ServiceHistoryDialog = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
selectedService,
|
||||
}: ServiceHistoryDialogProps) => {
|
||||
const { theme } = useTheme();
|
||||
const [startDate, setStartDate] = useState<Date>(new Date(Date.now() - 24 * 60 * 60 * 1000)); // Default to 24h ago
|
||||
const [endDate, setEndDate] = useState<Date>(new Date());
|
||||
|
||||
// Reset date range when dialog opens to ensure fresh data
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStartDate(new Date(Date.now() - 24 * 60 * 60 * 1000));
|
||||
setEndDate(new Date());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle date range filter changes
|
||||
const handleDateRangeChange = (start: Date, end: Date, option: DateRangeOption) => {
|
||||
console.log(`ServiceHistoryDialog: Date range changed to ${start.toISOString()} - ${end.toISOString()}`);
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={`${theme === 'dark' ? 'bg-black text-white border-gray-800' : 'bg-background text-foreground border-border'} sm:max-w-[800px]`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{selectedService?.name} - Uptime History
|
||||
</DialogTitle>
|
||||
<DialogDescription className={theme === 'dark' ? 'text-gray-400' : 'text-muted-foreground'}>
|
||||
Showing the most recent uptime checks for this service.
|
||||
{selectedService?.interval && (
|
||||
<span> Checked every {selectedService.interval} seconds.</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mb-4">
|
||||
<DateRangeFilter onRangeChange={handleDateRangeChange} />
|
||||
</div>
|
||||
|
||||
{selectedService && (
|
||||
<ServiceUptimeHistory
|
||||
serviceId={selectedService.id}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { serviceService } from "@/services/serviceService";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { notificationService } from "@/services/notificationService";
|
||||
|
||||
interface ServiceMonitoringButtonProps {
|
||||
service: Service;
|
||||
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
||||
}
|
||||
|
||||
export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMonitoringButtonProps) {
|
||||
const [isMonitoring, setIsMonitoring] = useState(service.status !== "paused");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Update local state when service prop changes
|
||||
useEffect(() => {
|
||||
setIsMonitoring(service.status !== "paused");
|
||||
}, [service.status]);
|
||||
|
||||
const handleToggleMonitoring = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (isMonitoring) {
|
||||
// Pause monitoring
|
||||
console.log(`Pausing monitoring for service ${service.id} (${service.name})`);
|
||||
await serviceService.pauseMonitoring(service.id);
|
||||
setIsMonitoring(false);
|
||||
|
||||
if (onStatusChange) onStatusChange("paused");
|
||||
|
||||
// Send notification for paused status (only here, not in pauseMonitoring.ts)
|
||||
if (service.alerts !== "muted") {
|
||||
console.log("Sending pause notification from UI component");
|
||||
// IMPORTANT: Direct call to the notification service to ensure a message is sent
|
||||
await notificationService.sendNotification({
|
||||
service: service,
|
||||
status: "paused",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Monitoring paused",
|
||||
description: `Monitoring for ${service.name} has been paused.`,
|
||||
});
|
||||
} else {
|
||||
// Start/resume monitoring
|
||||
console.log(`Starting monitoring for service ${service.id} (${service.name})`);
|
||||
|
||||
// First ensure we update the status in the database to not be paused anymore
|
||||
await serviceService.resumeMonitoring(service.id);
|
||||
setIsMonitoring(true);
|
||||
|
||||
// Perform an immediate check
|
||||
await serviceService.startMonitoringService(service.id);
|
||||
|
||||
toast({
|
||||
title: "Monitoring resumed",
|
||||
description: `Monitoring for ${service.name} has been resumed. First check is running now.`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error toggling monitoring:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to change monitoring status. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleToggleMonitoring}
|
||||
disabled={isLoading}
|
||||
className={isMonitoring ? "bg-red-900/20 hover:bg-red-900/30" : "bg-green-900/20 hover:bg-green-900/30"}
|
||||
>
|
||||
{isLoading ? (
|
||||
"Processing..."
|
||||
) : isMonitoring ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Monitoring
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Start Monitoring
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function ServiceNotFound() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-background text-foreground">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-yellow-500" />
|
||||
<h2 className="text-xl font-bold mt-4">Service Not Found</h2>
|
||||
<p className="mt-2 text-muted-foreground">The service you're looking for doesn't exist or has been deleted.</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
|
||||
import React from "react";
|
||||
import { TableRow, TableCell } from "@/components/ui/table";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { UptimeBar } from "./UptimeBar";
|
||||
import { LastCheckedTime } from "./LastCheckedTime";
|
||||
import {
|
||||
ServiceRowActions,
|
||||
ServiceRowHeader,
|
||||
ServiceRowResponseTime
|
||||
} from "./service-row";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface ServiceRowProps {
|
||||
service: Service;
|
||||
onViewDetail: (service: Service) => void;
|
||||
onPauseResume: (service: Service) => Promise<void>;
|
||||
onEdit: (service: Service) => void;
|
||||
onDelete: (service: Service) => void;
|
||||
onMuteAlerts?: (service: Service) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ServiceRow = ({
|
||||
service,
|
||||
onViewDetail,
|
||||
onPauseResume,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMuteAlerts
|
||||
}: ServiceRowProps) => {
|
||||
const { theme } = useTheme();
|
||||
const handleRowClick = () => {
|
||||
onViewDetail(service);
|
||||
};
|
||||
|
||||
// Get the timestamp to display - use only lastChecked since that's what's defined in the Service type
|
||||
const displayTimestamp = service.lastChecked || new Date().toLocaleString();
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={service.id}
|
||||
className={`border-b ${theme === 'dark' ? 'border-gray-800 hover:bg-gray-900/60' : 'border-gray-200 hover:bg-gray-50'} cursor-pointer`}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<TableCell className="font-medium py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<ServiceRowHeader service={service} />
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className={`text-base py-4 ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{service.type}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
|
||||
<StatusBadge status={service.status} size="md" />
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
|
||||
<ServiceRowResponseTime responseTime={service.responseTime} />
|
||||
</TableCell>
|
||||
<TableCell className="w-52 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<UptimeBar uptime={service.uptime} status={service.status} serviceId={service.id} />
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
|
||||
<LastCheckedTime
|
||||
lastCheckedTime={displayTimestamp}
|
||||
status={service.status}
|
||||
interval={service.interval}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
|
||||
<ServiceRowActions
|
||||
service={service}
|
||||
onViewDetail={onViewDetail}
|
||||
onPauseResume={onPauseResume}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onMuteAlerts={onMuteAlerts}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
|
||||
import { Clock, Server, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { Service, UptimeData } from "@/types/service.types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useMemo } from "react";
|
||||
import { formatDistanceStrict } from "date-fns";
|
||||
|
||||
interface ServiceStatsCardsProps {
|
||||
service: Service;
|
||||
uptimeData: UptimeData[];
|
||||
}
|
||||
|
||||
export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) {
|
||||
// Calculate uptime percentage
|
||||
const calculateUptimePercentage = () => {
|
||||
if (uptimeData.length === 0) return 100;
|
||||
|
||||
const totalChecks = uptimeData.length;
|
||||
const upChecks = uptimeData.filter(data => data.status === "up").length;
|
||||
return Math.round((upChecks / totalChecks) * 100);
|
||||
};
|
||||
|
||||
// Calculate total uptime/downtime
|
||||
const uptimeStats = useMemo(() => {
|
||||
if (uptimeData.length === 0) {
|
||||
return {
|
||||
totalUptimeFormatted: "N/A",
|
||||
totalDowntimeFormatted: "N/A",
|
||||
currentStatusDuration: "N/A"
|
||||
};
|
||||
}
|
||||
|
||||
// Sort data by timestamp for chronological analysis
|
||||
const sortedData = [...uptimeData].sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
// Get the status change events
|
||||
const statusChanges = sortedData.reduce((changes, check, index) => {
|
||||
if (index === 0 || (index > 0 && sortedData[index-1].status !== check.status)) {
|
||||
changes.push(check);
|
||||
}
|
||||
return changes;
|
||||
}, [] as UptimeData[]);
|
||||
|
||||
let totalUptime = 0;
|
||||
let totalDowntime = 0;
|
||||
let currentStatus = service.status;
|
||||
let lastChangeTime = new Date();
|
||||
|
||||
// Calculate durations between status changes
|
||||
for (let i = 0; i < statusChanges.length; i++) {
|
||||
const currentChange = statusChanges[i];
|
||||
const nextChange = statusChanges[i + 1];
|
||||
|
||||
if (nextChange) {
|
||||
const duration = new Date(nextChange.timestamp).getTime() - new Date(currentChange.timestamp).getTime();
|
||||
|
||||
if (currentChange.status === "up") {
|
||||
totalUptime += duration;
|
||||
} else {
|
||||
totalDowntime += duration;
|
||||
}
|
||||
} else {
|
||||
// For the last status, calculate time until now
|
||||
lastChangeTime = new Date(currentChange.timestamp);
|
||||
currentStatus = currentChange.status;
|
||||
}
|
||||
}
|
||||
|
||||
// Add time from last change until now for current status
|
||||
const now = new Date();
|
||||
const sinceLastChange = now.getTime() - lastChangeTime.getTime();
|
||||
|
||||
if (currentStatus === "up") {
|
||||
totalUptime += sinceLastChange;
|
||||
} else if (currentStatus === "down") {
|
||||
totalDowntime += sinceLastChange;
|
||||
}
|
||||
|
||||
// Format durations
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return "0s";
|
||||
return formatDistanceStrict(0, ms, { addSuffix: false });
|
||||
};
|
||||
|
||||
return {
|
||||
totalUptimeFormatted: formatDuration(totalUptime),
|
||||
totalDowntimeFormatted: formatDuration(totalDowntime),
|
||||
currentStatusDuration: formatDuration(sinceLastChange)
|
||||
};
|
||||
}, [uptimeData, service.status]);
|
||||
|
||||
// Calculate average response time from recent checks
|
||||
const calculateAverageResponseTime = () => {
|
||||
const upChecks = uptimeData.filter(data => data.status === "up" && data.responseTime > 0);
|
||||
if (upChecks.length === 0) return service.responseTime || 0;
|
||||
|
||||
const sum = upChecks.reduce((total, check) => total + check.responseTime, 0);
|
||||
return Math.round((sum / upChecks.length) * 100) / 100; // Two decimal precision
|
||||
};
|
||||
|
||||
const uptimePercentage = calculateUptimePercentage();
|
||||
const avgResponseTime = calculateAverageResponseTime();
|
||||
// Define upChecks here so we can use it in both the calculation and the JSX
|
||||
const upChecks = uptimeData.filter(data => data.status === "up" && data.responseTime > 0);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<Card className="border-blue-400 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/50 shadow-md">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">Response Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-700 dark:text-white">
|
||||
{service.responseTime}ms
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Last checked at {service.lastChecked}</p>
|
||||
{avgResponseTime > 0 && avgResponseTime !== service.responseTime && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Avg: {avgResponseTime}ms (last {upChecks.length} up checks)</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">Uptime</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-700 dark:text-white">
|
||||
{uptimePercentage}%
|
||||
</div>
|
||||
<p className="text-xs text-green-600 dark:text-green-200 mt-1">Based on last {uptimeData.length} checks</p>
|
||||
<div className="flex flex-col space-y-1 mt-2">
|
||||
<div className="flex items-center text-xs text-green-600 dark:text-green-200">
|
||||
<ArrowUp className="h-3 w-3 mr-1 text-green-600 dark:text-green-400" />
|
||||
<span>Total uptime: {uptimeStats.totalUptimeFormatted}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-xs text-red-600 dark:text-red-200">
|
||||
<ArrowDown className="h-3 w-3 mr-1 text-red-600 dark:text-red-400" />
|
||||
<span>Total downtime: {uptimeStats.totalDowntimeFormatted}</span>
|
||||
</div>
|
||||
{service.status !== "paused" && (
|
||||
<div className="flex items-center text-xs font-medium mt-1">
|
||||
<span className={service.status === "up" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
|
||||
{service.status === "up" ? "Up" : "Down"} for {uptimeStats.currentStatusDuration}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-purple-400 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/50 shadow-md">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">Monitoring Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200">
|
||||
<Clock className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
|
||||
<span>Checked every {service.interval} seconds</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200 mt-1">
|
||||
<Server className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
|
||||
<span>{service.type} monitoring</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { UptimeData } from "@/types/service.types";
|
||||
import { uptimeService } from "@/services/uptimeService";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Check, X, AlertTriangle, Pause } from "lucide-react";
|
||||
|
||||
interface ServiceUptimeHistoryProps {
|
||||
serviceId: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
|
||||
export function ServiceUptimeHistory({
|
||||
serviceId,
|
||||
startDate = new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
endDate = new Date()
|
||||
}: ServiceUptimeHistoryProps) {
|
||||
const { theme } = useTheme();
|
||||
const { data: uptimeHistory, isLoading, error } = useQuery({
|
||||
queryKey: ['uptimeHistory', serviceId, startDate?.toISOString(), endDate?.toISOString()],
|
||||
queryFn: () => uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate),
|
||||
enabled: !!serviceId,
|
||||
refetchInterval: 5000, // Refresh UI every 5 seconds
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="py-4 text-center">
|
||||
<p>Loading uptime history...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-4 text-center">
|
||||
<p>Error loading uptime history.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!uptimeHistory || uptimeHistory.length === 0) {
|
||||
return (
|
||||
<div className="py-4 text-center">
|
||||
<p>No uptime history available for the selected time period.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Function to get appropriate status badge styling
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch(status) {
|
||||
case 'up':
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
className: 'bg-emerald-800 text-white hover:bg-emerald-700',
|
||||
icon: <Check className="h-3 w-3 mr-1" />
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
variant: 'outline' as const,
|
||||
className: 'bg-yellow-800/80 text-yellow-300 border-yellow-700 hover:bg-yellow-800',
|
||||
icon: <AlertTriangle className="h-3 w-3 mr-1" />
|
||||
};
|
||||
case 'paused':
|
||||
return {
|
||||
variant: 'outline' as const,
|
||||
className: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800',
|
||||
icon: <Pause className="h-3 w-3 mr-1" />
|
||||
};
|
||||
default:
|
||||
return {
|
||||
variant: 'destructive' as const,
|
||||
className: '',
|
||||
icon: <X className="h-3 w-3 mr-1" />
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 bg-card rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-border">
|
||||
<TableHead className="text-muted-foreground font-medium">Time</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium">Status</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium">Response Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{uptimeHistory.map((record) => {
|
||||
const statusBadge = getStatusBadge(record.status);
|
||||
const isResponseTimeHigh = record.responseTime >= 1000;
|
||||
|
||||
return (
|
||||
<TableRow key={record.id} className="border-b border-border">
|
||||
<TableCell>
|
||||
{format(parseISO(record.timestamp), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={statusBadge.variant}
|
||||
className={statusBadge.className}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{statusBadge.icon}
|
||||
{record.status === 'up' ? 'Up' :
|
||||
record.status === 'down' ? 'Down' :
|
||||
record.status === 'warning' ? 'Warning' : 'Paused'}
|
||||
</span>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-mono flex items-center gap-1.5">
|
||||
{record.responseTime > 0 ? (
|
||||
<>
|
||||
<span className={isResponseTimeHigh ? "text-amber-500 font-semibold" : ""}>
|
||||
{record.responseTime}ms
|
||||
</span>
|
||||
{isResponseTimeHigh && (
|
||||
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||
)}
|
||||
</>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { ServicesTableView } from "./ServicesTableView";
|
||||
import { ServiceDeleteDialog } from "./ServiceDeleteDialog";
|
||||
import { ServiceHistoryDialog } from "./ServiceHistoryDialog";
|
||||
import { ServiceEditDialog } from "./ServiceEditDialog";
|
||||
import { useServiceActions, useDialogState } from "./hooks";
|
||||
|
||||
interface ServicesTableContainerProps {
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export const ServicesTableContainer = ({ services }: ServicesTableContainerProps) => {
|
||||
const {
|
||||
services: localServices,
|
||||
selectedService,
|
||||
isDeleting,
|
||||
setSelectedService,
|
||||
updateServices,
|
||||
handleViewDetail,
|
||||
handlePauseResume,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
confirmDelete,
|
||||
handleMuteAlerts
|
||||
} = useServiceActions(services);
|
||||
|
||||
const {
|
||||
isHistoryDialogOpen,
|
||||
isDeleteDialogOpen,
|
||||
isEditDialogOpen,
|
||||
setIsHistoryDialogOpen,
|
||||
setIsDeleteDialogOpen,
|
||||
handleEditDialogChange,
|
||||
handleDeleteDialogChange
|
||||
} = useDialogState();
|
||||
|
||||
// Update local services state when props change
|
||||
useEffect(() => {
|
||||
updateServices(services);
|
||||
}, [services]);
|
||||
|
||||
// Handler functions that combine local state management
|
||||
const onEdit = (service: Service) => {
|
||||
const selectedService = handleEdit(service);
|
||||
setTimeout(() => {
|
||||
handleEditDialogChange(true);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const onDelete = (service: Service) => {
|
||||
handleDelete(service);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const openHistoryDialog = (service: Service) => {
|
||||
setSelectedService(service);
|
||||
setIsHistoryDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
<ServicesTableView
|
||||
services={localServices}
|
||||
onViewDetail={handleViewDetail}
|
||||
onPauseResume={handlePauseResume}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onMuteAlerts={handleMuteAlerts}
|
||||
/>
|
||||
|
||||
<ServiceHistoryDialog
|
||||
isOpen={isHistoryDialogOpen}
|
||||
onOpenChange={setIsHistoryDialogOpen}
|
||||
selectedService={selectedService}
|
||||
/>
|
||||
|
||||
<ServiceDeleteDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => handleDeleteDialogChange(open, isDeleting)}
|
||||
selectedService={selectedService}
|
||||
onConfirmDelete={confirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
<ServiceEditDialog
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={handleEditDialogChange}
|
||||
service={selectedService}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { ServiceRow } from "@/components/services/ServiceRow";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface ServicesTableViewProps {
|
||||
services: Service[];
|
||||
onViewDetail: (service: Service) => void;
|
||||
onPauseResume: (service: Service) => Promise<void>;
|
||||
onEdit: (service: Service) => void;
|
||||
onDelete: (service: Service) => void;
|
||||
onMuteAlerts?: (service: Service) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ServicesTableView = ({
|
||||
services,
|
||||
onViewDetail,
|
||||
onPauseResume,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMuteAlerts
|
||||
}: ServicesTableViewProps) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg overflow-hidden border border-border flex-1 flex flex-col shadow-sm`}>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'} sticky top-0 z-10`}>
|
||||
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceName")}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceType")}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceStatus")}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("responseTime")}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("uptime")}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("lastChecked")}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.length > 0 ? (
|
||||
services.map((service) => (
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
onViewDetail={onViewDetail}
|
||||
onPauseResume={onPauseResume}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onMuteAlerts={onMuteAlerts}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className={`text-center py-8 text-base ${theme === 'dark' ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
{t("noServices")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
import React from "react";
|
||||
import { Check, X, Pause, AlertTriangle } from "lucide-react";
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
status: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export const StatusBadge = ({ status, size = "sm" }: StatusBadgeProps) => {
|
||||
// Determine the sizing classes based on the size prop
|
||||
const getSizeClasses = () => {
|
||||
switch (size) {
|
||||
case "lg":
|
||||
return "px-3 py-1.5 text-sm gap-1.5";
|
||||
case "md":
|
||||
return "px-2.5 py-1 text-sm gap-1.5";
|
||||
case "sm":
|
||||
default:
|
||||
return "px-2 py-0.5 text-xs gap-0.5";
|
||||
}
|
||||
};
|
||||
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case "lg":
|
||||
return "h-4 w-4";
|
||||
case "md":
|
||||
return "h-4 w-4";
|
||||
case "sm":
|
||||
default:
|
||||
return "h-3 w-3";
|
||||
}
|
||||
};
|
||||
|
||||
const sizeClasses = getSizeClasses();
|
||||
const iconSize = getIconSize();
|
||||
|
||||
switch (status) {
|
||||
case "up":
|
||||
return (
|
||||
<div className={`flex items-center bg-emerald-950/60 dark:bg-emerald-950/60 text-emerald-400 font-medium rounded-full w-fit ${sizeClasses}`}>
|
||||
<Check className={iconSize} />
|
||||
<span>Up</span>
|
||||
</div>
|
||||
);
|
||||
case "down":
|
||||
return (
|
||||
<div className={`flex items-center bg-red-950/60 dark:bg-red-950/60 text-red-400 font-medium rounded-full w-fit ${sizeClasses}`}>
|
||||
<X className={iconSize} />
|
||||
<span>Down</span>
|
||||
</div>
|
||||
);
|
||||
case "warning":
|
||||
return (
|
||||
<div className={`flex items-center bg-yellow-950/60 dark:bg-yellow-950/60 text-yellow-400 font-medium rounded-full w-fit ${sizeClasses}`}>
|
||||
<AlertTriangle className={iconSize} />
|
||||
<span>Warning</span>
|
||||
</div>
|
||||
);
|
||||
case "paused":
|
||||
return (
|
||||
<div className={`flex items-center bg-gray-200/30 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 font-medium rounded-full w-fit ${sizeClasses}`}>
|
||||
<Pause className={iconSize} />
|
||||
<span>Paused</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Check, X, AlertTriangle, Pause, Clock, Info, RefreshCcw } from "lucide-react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { uptimeService } from "@/services/uptimeService";
|
||||
import { UptimeData } from "@/types/service.types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface UptimeBarProps {
|
||||
uptime: number;
|
||||
status: string;
|
||||
serviceId?: string; // Optional serviceId to fetch history
|
||||
}
|
||||
|
||||
export const UptimeBar = ({ uptime, status, serviceId }: UptimeBarProps) => {
|
||||
const { theme } = useTheme();
|
||||
const [historyItems, setHistoryItems] = useState<UptimeData[]>([]);
|
||||
|
||||
// Fetch real uptime history data if serviceId is provided with improved caching and error handling
|
||||
const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({
|
||||
queryKey: ['uptimeHistory', serviceId],
|
||||
queryFn: () => serviceId ? uptimeService.getUptimeHistory(serviceId, 20) : Promise.resolve([]),
|
||||
enabled: !!serviceId,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
staleTime: 15000, // Consider data fresh for 15 seconds
|
||||
placeholderData: (previousData) => previousData, // Show previous data while refetching
|
||||
retry: 3, // Retry failed requests three times
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff with max 10s
|
||||
});
|
||||
|
||||
// Update history items when data changes
|
||||
useEffect(() => {
|
||||
if (uptimeData && uptimeData.length > 0) {
|
||||
setHistoryItems(uptimeData);
|
||||
} else if (status === "paused" || (uptimeData && uptimeData.length === 0)) {
|
||||
// For paused services with no history, or empty history data, show all as paused
|
||||
const statusValue = (status === "up" || status === "down" || status === "warning" || status === "paused")
|
||||
? status
|
||||
: "paused"; // Default to paused if not a valid status
|
||||
|
||||
const placeholderHistory: UptimeData[] = Array(20).fill(null).map((_, index) => ({
|
||||
id: `placeholder-${index}`,
|
||||
serviceId: serviceId || "",
|
||||
timestamp: new Date().toISOString(),
|
||||
status: statusValue as "up" | "down" | "warning" | "paused",
|
||||
responseTime: 0
|
||||
}));
|
||||
setHistoryItems(placeholderHistory);
|
||||
}
|
||||
}, [uptimeData, serviceId, status]);
|
||||
|
||||
// Get appropriate color classes for each status type
|
||||
const getStatusColor = (itemStatus: string) => {
|
||||
switch(itemStatus) {
|
||||
case "up":
|
||||
return theme === "dark" ? "bg-emerald-500" : "bg-emerald-500";
|
||||
case "down":
|
||||
return theme === "dark" ? "bg-red-500" : "bg-red-500";
|
||||
case "warning":
|
||||
return theme === "dark" ? "bg-yellow-500" : "bg-yellow-500";
|
||||
case "paused":
|
||||
default:
|
||||
return theme === "dark" ? "bg-gray-500" : "bg-gray-400";
|
||||
}
|
||||
};
|
||||
|
||||
// Get status label
|
||||
const getStatusLabel = (itemStatus: string): string => {
|
||||
switch(itemStatus) {
|
||||
case "up": return "Online";
|
||||
case "down": return "Offline";
|
||||
case "warning": return "Degraded";
|
||||
case "paused": return "Paused";
|
||||
default: return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
// Format timestamp for display
|
||||
const formatTimestamp = (timestamp: string): string => {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
// If still loading and no history, show improved loading state
|
||||
if ((isLoading || isFetching) && historyItems.length === 0) {
|
||||
// Show skeleton loading UI instead of text
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<div className="flex items-center space-x-0.5 w-full h-6">
|
||||
{Array(20).fill(0).map((_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}`}
|
||||
className={`h-5 w-1.5 rounded-sm bg-muted animate-pulse`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground w-16 h-4 bg-muted animate-pulse rounded"></span>
|
||||
<span className="text-muted-foreground w-24 h-4 bg-muted animate-pulse rounded"></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If there's an error and no history, show improved error state with retry button
|
||||
if (error && historyItems.length === 0) {
|
||||
// Provide visual error state that matches the design system
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<div className="flex items-center space-x-0.5 w-full h-6">
|
||||
{Array(20).fill(0).map((_, index) => (
|
||||
<div
|
||||
key={`error-${index}`}
|
||||
className={`h-5 w-1.5 rounded-sm bg-gray-700 opacity-40`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{Math.round(uptime)}% uptime</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-xs text-red-400 flex items-center gap-1 hover:text-red-300 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" /> Connection error
|
||||
<RefreshCcw className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we always have 20 items by padding with the last known status
|
||||
const displayItems = [...historyItems];
|
||||
if (displayItems.length < 20) {
|
||||
const lastItem = displayItems.length > 0 ? displayItems[displayItems.length - 1] : null;
|
||||
const lastStatus = lastItem ? lastItem.status :
|
||||
(status === "up" || status === "down" || status === "warning" || status === "paused") ?
|
||||
status as "up" | "down" | "warning" | "paused" : "paused";
|
||||
|
||||
const paddingItems: UptimeData[] = Array(20 - displayItems.length).fill(null).map((_, index) => ({
|
||||
id: `padding-${index}`,
|
||||
serviceId: serviceId || "",
|
||||
timestamp: new Date().toISOString(),
|
||||
status: lastStatus,
|
||||
responseTime: 0
|
||||
}));
|
||||
displayItems.push(...paddingItems);
|
||||
}
|
||||
|
||||
// Limit to 20 items for display
|
||||
const limitedItems = displayItems.slice(0, 20);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<div className="flex items-center space-x-0.5 w-full h-6">
|
||||
{limitedItems.map((item, index) => (
|
||||
<Tooltip key={item.id || `status-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-5 w-1.5 rounded-sm ${getStatusColor(item.status)} cursor-pointer hover:opacity-80 transition-opacity`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="font-medium">{getStatusLabel(item.status)}</div>
|
||||
<div>
|
||||
{item.status !== "paused" && item.status !== "down" ?
|
||||
`${item.responseTime}ms` :
|
||||
"No response"}
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
{formatTimestamp(item.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{Math.round(uptime)}% uptime
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last 20 checks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { ServiceFormData } from "./types";
|
||||
|
||||
interface ServiceBasicFieldsProps {
|
||||
form: UseFormReturn<ServiceFormData>;
|
||||
}
|
||||
|
||||
export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Service Name"
|
||||
className="bg-black border-gray-700"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com"
|
||||
className="bg-black border-gray-700"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
console.log("URL field changed:", e.target.value);
|
||||
field.onChange(e);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { ServiceFormData } from "./types";
|
||||
|
||||
interface ServiceConfigFieldsProps {
|
||||
form: UseFormReturn<ServiceFormData>;
|
||||
}
|
||||
|
||||
export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Heartbeat Interval</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="60"
|
||||
className="bg-black border-gray-700"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retries"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Maximum Retries</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3"
|
||||
className="bg-black border-gray-700"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { serviceSchema, ServiceFormData } from "./types";
|
||||
import { ServiceBasicFields } from "./ServiceBasicFields";
|
||||
import { ServiceTypeField } from "./ServiceTypeField";
|
||||
import { ServiceConfigFields } from "./ServiceConfigFields";
|
||||
import { ServiceNotificationFields } from "./ServiceNotificationFields";
|
||||
import { ServiceFormActions } from "./ServiceFormActions";
|
||||
import { serviceService } from "@/services/serviceService";
|
||||
import { Service } from "@/types/service.types";
|
||||
|
||||
interface ServiceFormProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Service | null;
|
||||
isEdit?: boolean;
|
||||
onSubmitStart?: () => void;
|
||||
}
|
||||
|
||||
export function ServiceForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
initialData,
|
||||
isEdit = false,
|
||||
onSubmitStart
|
||||
}: ServiceFormProps) {
|
||||
const { toast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Initialize form with default values
|
||||
const form = useForm<ServiceFormData>({
|
||||
resolver: zodResolver(serviceSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
type: "http",
|
||||
url: "",
|
||||
interval: "60",
|
||||
retries: "3",
|
||||
notificationChannel: "",
|
||||
alertTemplate: "",
|
||||
},
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
// Populate form when initialData changes (separate from initialization)
|
||||
useEffect(() => {
|
||||
if (initialData && isEdit) {
|
||||
// Reset the form with initial data values
|
||||
form.reset({
|
||||
name: initialData.name || "",
|
||||
type: (initialData.type || "http").toLowerCase(),
|
||||
url: initialData.url || "",
|
||||
interval: String(initialData.interval || 60),
|
||||
retries: String(initialData.retries || 3),
|
||||
notificationChannel: initialData.notificationChannel || "",
|
||||
alertTemplate: initialData.alertTemplate || "",
|
||||
});
|
||||
|
||||
// Log for debugging
|
||||
console.log("Populating form with URL:", initialData.url);
|
||||
}
|
||||
}, [initialData, isEdit, form]);
|
||||
|
||||
const handleSubmit = async (data: ServiceFormData) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
if (onSubmitStart) onSubmitStart();
|
||||
|
||||
try {
|
||||
console.log("Form data being submitted:", data); // Debug log for submitted data
|
||||
|
||||
if (isEdit && initialData) {
|
||||
// Update existing service
|
||||
await serviceService.updateService(initialData.id, {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
url: data.url, // Ensure URL is included here
|
||||
interval: parseInt(data.interval),
|
||||
retries: parseInt(data.retries),
|
||||
notificationChannel: data.notificationChannel || undefined,
|
||||
alertTemplate: data.alertTemplate || undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Service updated",
|
||||
description: `${data.name} has been updated successfully.`,
|
||||
});
|
||||
} else {
|
||||
// Create new service
|
||||
await serviceService.createService({
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
url: data.url, // Ensure URL is included here
|
||||
interval: parseInt(data.interval),
|
||||
retries: parseInt(data.retries),
|
||||
notificationChannel: data.notificationChannel || undefined,
|
||||
alertTemplate: data.alertTemplate || undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Service created",
|
||||
description: `${data.name} has been added to monitoring.`,
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
if (!isEdit) {
|
||||
form.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error ${isEdit ? 'updating' : 'creating'} service:`, error);
|
||||
toast({
|
||||
title: `Failed to ${isEdit ? 'update' : 'create'} service`,
|
||||
description: `An error occurred while ${isEdit ? 'updating' : 'creating'} the service.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-4">
|
||||
<ServiceBasicFields form={form} />
|
||||
<ServiceTypeField form={form} />
|
||||
<ServiceConfigFields form={form} />
|
||||
<ServiceNotificationFields form={form} />
|
||||
<ServiceFormActions
|
||||
isSubmitting={isSubmitting}
|
||||
onCancel={onCancel}
|
||||
submitLabel={isEdit ? "Update Service" : "Create Service"}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { MouseEvent } from "react";
|
||||
|
||||
interface ServiceFormActionsProps {
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export function ServiceFormActions({
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
submitLabel = "Create Service"
|
||||
}: ServiceFormActionsProps) {
|
||||
const handleCancel = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!isSubmitting) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
|
||||
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { ServiceFormData } from "./types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { templateService } from "@/services/templateService";
|
||||
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface ServiceNotificationFieldsProps {
|
||||
form: UseFormReturn<ServiceFormData>;
|
||||
}
|
||||
|
||||
export function ServiceNotificationFields({ form }: ServiceNotificationFieldsProps) {
|
||||
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
|
||||
|
||||
// Get the current form values for debugging
|
||||
const notificationChannel = form.watch("notificationChannel");
|
||||
const alertTemplate = form.watch("alertTemplate");
|
||||
|
||||
console.log("Current notification values:", {
|
||||
notificationChannel,
|
||||
alertTemplate
|
||||
});
|
||||
|
||||
// Fetch alert configurations for notification channels
|
||||
const { data: alertConfigsData } = useQuery({
|
||||
queryKey: ['alertConfigs'],
|
||||
queryFn: () => alertConfigService.getAlertConfigurations(),
|
||||
});
|
||||
|
||||
// Fetch templates for template selection
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ['templates'],
|
||||
queryFn: () => templateService.getTemplates(),
|
||||
});
|
||||
|
||||
// Update alert configs when data is loaded
|
||||
useEffect(() => {
|
||||
if (alertConfigsData) {
|
||||
setAlertConfigs(alertConfigsData);
|
||||
|
||||
// Debug log to check what alert configs are loaded
|
||||
console.log("Loaded alert configurations:", alertConfigsData);
|
||||
}
|
||||
}, [alertConfigsData]);
|
||||
|
||||
// Log when form values change to debug
|
||||
useEffect(() => {
|
||||
console.log("Notification values changed:", {
|
||||
notificationChannel: form.getValues("notificationChannel"),
|
||||
alertTemplate: form.getValues("alertTemplate")
|
||||
});
|
||||
}, [form.watch("notificationChannel"), form.watch("alertTemplate")]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notificationChannel"
|
||||
render={({ field }) => {
|
||||
// Important: We need to preserve the actual value for notification channel
|
||||
const fieldValue = field.value || "";
|
||||
const displayValue = fieldValue === "" ? "none" : fieldValue;
|
||||
|
||||
console.log("Rendering notification channel field with value:", {
|
||||
fieldValue,
|
||||
displayValue
|
||||
});
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Notification Channel</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
console.log("Notification channel changed to:", value);
|
||||
field.onChange(value === "none" ? "" : value);
|
||||
}}
|
||||
value={displayValue}
|
||||
>
|
||||
<SelectTrigger className="bg-black border-gray-700">
|
||||
<SelectValue placeholder="Select a notification channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 text-white border-gray-700">
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{alertConfigs.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id || ""}>
|
||||
{config.notify_name} ({config.notification_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="alertTemplate"
|
||||
render={({ field }) => {
|
||||
// Don't convert existing values to "default"
|
||||
const displayValue = field.value || "default";
|
||||
console.log("Rendering alert template field with value:", displayValue);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Alert Template</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
console.log("Alert template changed to:", value);
|
||||
field.onChange(value === "default" ? "" : value);
|
||||
}}
|
||||
value={displayValue}
|
||||
>
|
||||
<SelectTrigger className="bg-black border-gray-700">
|
||||
<SelectValue placeholder="Select an alert template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 text-white border-gray-700">
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
{templates?.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
|
||||
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Globe } from "lucide-react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { ServiceFormData } from "./types";
|
||||
|
||||
interface ServiceTypeFieldProps {
|
||||
form: UseFormReturn<ServiceFormData>;
|
||||
}
|
||||
|
||||
export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger className="bg-black border-gray-700">
|
||||
<SelectValue>
|
||||
{field.value === "http" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>HTTP/S</span>
|
||||
</div>
|
||||
)}
|
||||
{field.value !== "http" && "Select a service type"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 text-white border-gray-700">
|
||||
<SelectItem value="http">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>HTTP/S</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Monitor websites and REST APIs with HTTP/HTTPS protocol
|
||||
</p>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="ping">PING</SelectItem>
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="dns">DNS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
export * from "./ServiceForm";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const serviceSchema = z.object({
|
||||
name: z.string().min(1, "Service name is required"),
|
||||
type: z.string().min(1, "Service type is required"),
|
||||
url: z.string().min(1, "Service URL is required"),
|
||||
interval: z.string().min(1, "Heartbeat interval is required"),
|
||||
retries: z.string().min(1, "Maximum retries is required"),
|
||||
notificationChannel: z.string().optional(),
|
||||
alertTemplate: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ServiceFormData = z.infer<typeof serviceSchema>;
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
export * from './useServiceActions';
|
||||
export * from './useDialogState';
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function useDialogState() {
|
||||
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
||||
const handleEditDialogChange = (open: boolean) => {
|
||||
setIsEditDialogOpen(open);
|
||||
};
|
||||
|
||||
const handleDeleteDialogChange = (open: boolean, isDeleting: boolean = false) => {
|
||||
// Only allow closing if not currently deleting
|
||||
if (!isDeleting || !open) {
|
||||
setIsDeleteDialogOpen(open);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isHistoryDialogOpen,
|
||||
isDeleteDialogOpen,
|
||||
isEditDialogOpen,
|
||||
setIsHistoryDialogOpen,
|
||||
setIsDeleteDialogOpen,
|
||||
setIsEditDialogOpen,
|
||||
handleEditDialogChange,
|
||||
handleDeleteDialogChange
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { serviceService } from "@/services/serviceService";
|
||||
import { recordMuteStatusChange } from "@/services/monitoring/utils/notificationUtils";
|
||||
|
||||
export function useServiceActions(initialServices: Service[]) {
|
||||
const [services, setServices] = useState<Service[]>(initialServices);
|
||||
const [selectedService, setSelectedService] = useState<Service | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Update services state when props change
|
||||
const updateServices = (newServices: Service[]) => {
|
||||
if (JSON.stringify(services) !== JSON.stringify(newServices)) {
|
||||
setServices(newServices);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = (service: Service) => {
|
||||
navigate(`/service/${service.id}`);
|
||||
};
|
||||
|
||||
const handlePauseResume = async (service: Service) => {
|
||||
try {
|
||||
if (service.status === "paused") {
|
||||
// Resume monitoring
|
||||
await serviceService.startMonitoringService(service.id);
|
||||
toast({
|
||||
title: "Service resumed",
|
||||
description: `${service.name} monitoring has been resumed successfully.`,
|
||||
});
|
||||
|
||||
// Update local state - ensure status is properly typed as "up"
|
||||
const updatedServices = services.map(s =>
|
||||
s.id === service.id ? { ...s, status: "up" as const } : s
|
||||
);
|
||||
setServices(updatedServices);
|
||||
} else {
|
||||
// Pause monitoring
|
||||
await serviceService.pauseMonitoring(service.id);
|
||||
|
||||
// Get the pause time and update local state
|
||||
const pauseTime = new Date().toISOString();
|
||||
const updatedServices = services.map(s =>
|
||||
s.id === service.id ? { ...s, status: "paused" as const, lastChecked: pauseTime } : s
|
||||
);
|
||||
setServices(updatedServices);
|
||||
|
||||
toast({
|
||||
title: "Service paused",
|
||||
description: `${service.name} monitoring has been paused successfully.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate the services query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
} catch (error) {
|
||||
console.error("Error updating service status:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to update service status. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (service: Service) => {
|
||||
setSelectedService({...service}); // Create a copy to avoid reference issues
|
||||
return service;
|
||||
};
|
||||
|
||||
const handleDelete = (service: Service) => {
|
||||
setSelectedService(service);
|
||||
return service;
|
||||
};
|
||||
|
||||
// Modified to return Promise<void> instead of Promise<boolean>
|
||||
const confirmDelete = async (): Promise<void> => {
|
||||
if (!selectedService || isDeleting) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
// First try to pause monitoring for this service to prevent any concurrency issues
|
||||
if (selectedService.status !== "paused") {
|
||||
await serviceService.pauseMonitoring(selectedService.id);
|
||||
}
|
||||
|
||||
// Set a timeout to prevent hanging UI
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Delete request timed out")), 10000);
|
||||
});
|
||||
|
||||
const deletePromise = pb.collection('services').delete(selectedService.id);
|
||||
await Promise.race([deletePromise, timeoutPromise]);
|
||||
|
||||
toast({
|
||||
title: "Service deleted",
|
||||
description: `${selectedService.name} has been deleted successfully.`,
|
||||
});
|
||||
|
||||
// Update local state
|
||||
const updatedServices = services.filter(s => s.id !== selectedService.id);
|
||||
setServices(updatedServices);
|
||||
|
||||
// Invalidate the services query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
|
||||
setSelectedService(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting service:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to delete service. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMuteAlerts = async (service: Service) => {
|
||||
try {
|
||||
// Check alerts status - check both fields for backward compatibility
|
||||
const isMuted = service.alerts === "muted" || service.muteAlerts === true;
|
||||
|
||||
// Toggle the mute alerts status for this specific service
|
||||
const newMuteStatus = !isMuted;
|
||||
|
||||
console.log(`${newMuteStatus ? "Muting" : "Unmuting"} alerts for service ${service.id} (${service.name})`);
|
||||
|
||||
// First update the local state immediately for better UI responsiveness
|
||||
// Using proper type casting to ensure TypeScript knows we're creating valid Service objects
|
||||
const updatedServices = services.map(s => {
|
||||
if (s.id === service.id) {
|
||||
return {
|
||||
...s,
|
||||
muteAlerts: newMuteStatus,
|
||||
alerts: newMuteStatus ? "muted" as const : "unmuted" as const
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
setServices(updatedServices);
|
||||
|
||||
// Record the mute status change (this will also update the service record)
|
||||
await recordMuteStatusChange(service.id, service.name, newMuteStatus);
|
||||
|
||||
// Show a toast message
|
||||
toast({
|
||||
title: newMuteStatus ? "Alerts muted" : "Alerts unmuted",
|
||||
description: `Notifications for ${service.name} are now ${newMuteStatus ? "muted" : "enabled"}.`,
|
||||
});
|
||||
|
||||
// Immediately invalidate the services query to trigger a refetch
|
||||
// This ensures our local state matches the database state
|
||||
await queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error updating alert settings:", error);
|
||||
|
||||
// Revert the local state change if the server update failed
|
||||
const revertedServices = services.map(s => {
|
||||
if (s.id === service.id) {
|
||||
return {
|
||||
...s,
|
||||
muteAlerts: service.muteAlerts,
|
||||
alerts: service.alerts
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
setServices(revertedServices);
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: `Failed to ${!service.muteAlerts ? "mute" : "unmute"} alerts for ${service.name}. Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
services,
|
||||
selectedService,
|
||||
isDeleting,
|
||||
setSelectedService,
|
||||
updateServices,
|
||||
handleViewDetail,
|
||||
handlePauseResume,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
confirmDelete,
|
||||
handleMuteAlerts
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
interface EmptyStateProps {
|
||||
statusFilter: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ statusFilter }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{statusFilter === "all"
|
||||
? "No incidents recorded in selected time period"
|
||||
: `No ${
|
||||
statusFilter === "up" ? "uptime" :
|
||||
statusFilter === "down" ? "downtime" :
|
||||
statusFilter === "warning" ? "warning" :
|
||||
"paused"
|
||||
} incidents recorded in selected time period`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { UptimeData } from "@/types/service.types";
|
||||
import { getStatusInfo } from "./utils";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface IncidentTableProps {
|
||||
incidents: UptimeData[];
|
||||
}
|
||||
|
||||
export function IncidentTable({ incidents }: IncidentTableProps) {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
|
||||
if (incidents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table className={theme === 'dark' ? 'text-gray-200' : 'text-gray-700'}>
|
||||
<TableHeader className={theme === 'dark' ? 'bg-gray-800/50' : 'bg-gray-50'}>
|
||||
<TableRow className={theme === 'dark' ? 'border-gray-700' : 'border-gray-200'}>
|
||||
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
|
||||
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
|
||||
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{incidents.map((check, index) => {
|
||||
const statusInfo = getStatusInfo(check.status);
|
||||
const timestamp = new Date(check.timestamp);
|
||||
|
||||
return (
|
||||
<TableRow key={check.id || `incident-${index}`} className={theme === 'dark' ? 'border-gray-800 hover:bg-gray-800/30' : 'border-gray-200 hover:bg-gray-50'}>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span>{format(timestamp, 'MMM dd, yyyy')}</span>
|
||||
<span className={`text-xs ${theme === 'dark' ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{format(timestamp, 'h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
{statusInfo.badge}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`font-mono ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{check.status !== "paused" && check.responseTime > 0
|
||||
? `${check.responseTime}ms`
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { UptimeData } from "@/types/service.types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { StatusFilterTabs } from "./StatusFilterTabs";
|
||||
import { TablePagination } from "./TablePagination";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { IncidentTable } from "./IncidentTable";
|
||||
import { StatusFilter, PageSize } from "./types";
|
||||
import { getStatusChangeEvents } from "./utils";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] }) {
|
||||
// Get current theme
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Filter state
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState<PageSize>("25");
|
||||
|
||||
// Reset to first page when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [statusFilter, pageSize]);
|
||||
|
||||
// Filter incidents by status
|
||||
const incidents = useMemo(() => {
|
||||
const statusChanges = getStatusChangeEvents(uptimeData);
|
||||
console.log(`Total status changes: ${statusChanges.length}`);
|
||||
console.log(`Status types in incidents: ${[...new Set(statusChanges.map(i => i.status))].join(', ')}`);
|
||||
|
||||
if (statusFilter === "all") return statusChanges;
|
||||
|
||||
return statusChanges.filter(incident => incident.status === statusFilter);
|
||||
}, [uptimeData, statusFilter]);
|
||||
|
||||
// Calculate pagination
|
||||
const { paginatedIncidents, totalPages } = useMemo(() => {
|
||||
if (pageSize === "all") {
|
||||
return {
|
||||
paginatedIncidents: incidents,
|
||||
totalPages: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const itemsPerPage = parseInt(pageSize, 10);
|
||||
const pages = Math.ceil(incidents.length / itemsPerPage);
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
|
||||
return {
|
||||
paginatedIncidents: incidents.slice(start, end),
|
||||
totalPages: Math.max(1, pages),
|
||||
};
|
||||
}, [incidents, currentPage, pageSize]);
|
||||
|
||||
// Calculate items per page for pagination display
|
||||
const itemsPerPage = pageSize === "all" ? incidents.length : parseInt(pageSize, 10);
|
||||
|
||||
console.log(`Status Filter: ${statusFilter}, Incidents: ${incidents.length}, Includes paused: ${incidents.some(i => i.status === 'paused')}`);
|
||||
|
||||
return (
|
||||
<Card className={`mb-6 transition-colors ${theme === 'dark' ? 'bg-card border-border' : 'bg-white border-gray-200'}`}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<CardTitle className="text-card-foreground">
|
||||
<span>Incident History</span>
|
||||
</CardTitle>
|
||||
<StatusFilterTabs statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{incidents.length === 0 ? (
|
||||
<EmptyState statusFilter={statusFilter} />
|
||||
) : (
|
||||
<>
|
||||
<IncidentTable incidents={paginatedIncidents} />
|
||||
|
||||
<TablePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
totalItems={incidents.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
|
||||
import { Activity, AlertTriangle, CheckCircle, Pause, X } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { StatusFilter } from "./types";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface StatusFilterTabsProps {
|
||||
statusFilter: StatusFilter;
|
||||
onStatusFilterChange: (value: StatusFilter) => void;
|
||||
}
|
||||
|
||||
export function StatusFilterTabs({
|
||||
statusFilter,
|
||||
onStatusFilterChange
|
||||
}: StatusFilterTabsProps) {
|
||||
// Get current theme to apply appropriate styling
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={statusFilter}
|
||||
onValueChange={value => onStatusFilterChange(value as StatusFilter)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className={`grid grid-cols-5 w-full max-w-md rounded-full ${
|
||||
theme === 'dark' ? 'bg-secondary' : 'bg-slate-100'
|
||||
}`}>
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
<span>All</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="up"
|
||||
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>Up</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="down"
|
||||
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span>Down</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="warning"
|
||||
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>Warning</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="paused"
|
||||
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
<span>Paused</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { PageSize } from "./types";
|
||||
|
||||
interface TablePaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
pageSize: PageSize;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: PageSize) => void;
|
||||
}
|
||||
|
||||
export function TablePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
pageSize,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: TablePaginationProps) {
|
||||
// Generate page numbers
|
||||
const getPageNumbers = () => {
|
||||
const pages = [];
|
||||
const maxVisiblePages = 5;
|
||||
const halfVisible = Math.floor(maxVisiblePages / 2);
|
||||
|
||||
let startPage = Math.max(1, currentPage - halfVisible);
|
||||
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-muted-foreground">Rows per page:</span>
|
||||
<Select
|
||||
value={pageSize}
|
||||
onValueChange={(value) => onPageSizeChange(value as PageSize)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder="25" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="250">250</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pageSize === 'all'
|
||||
? `Showing all ${totalItems} items`
|
||||
: `Showing ${Math.min((currentPage - 1) * itemsPerPage + 1, totalItems)}-${Math.min(currentPage * itemsPerPage, totalItems)} of ${totalItems} items`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPageNumbers().map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
isActive={page === currentPage}
|
||||
onClick={() => onPageChange(page)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
// Export components
|
||||
export { LatestChecksTable } from './LatestChecksTable';
|
||||
export { StatusFilterTabs } from './StatusFilterTabs';
|
||||
export { TablePagination } from './TablePagination';
|
||||
export { IncidentTable } from './IncidentTable';
|
||||
export { EmptyState } from './EmptyState';
|
||||
|
||||
// Export types and utils
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
import { UptimeData } from "@/types/service.types";
|
||||
|
||||
export type StatusFilter = "all" | "up" | "down" | "paused" | "warning";
|
||||
export type PageSize = "10" | "25" | "50" | "100" | "250" | "all";
|
||||
|
||||
export interface LatestChecksTableProps {
|
||||
uptimeData: UptimeData[];
|
||||
}
|
||||
|
||||
export interface StatusInfo {
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
textColor: string;
|
||||
badge: JSX.Element;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
|
||||
import { CheckCircle, AlertTriangle, X, Pause } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { UptimeData } from "@/types/service.types";
|
||||
import { format } from "date-fns";
|
||||
import { StatusInfo } from "./types";
|
||||
|
||||
// Get appropriate icon and style for each status
|
||||
export const getStatusInfo = (status: string): StatusInfo => {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return {
|
||||
icon: <CheckCircle className="h-4 w-4 text-green-500 mr-2" />,
|
||||
text: "Up",
|
||||
textColor: "text-green-500",
|
||||
badge: <Badge variant="default" className="bg-emerald-800 text-white hover:bg-emerald-700">Up</Badge>
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
icon: <AlertTriangle className="h-4 w-4 text-yellow-500 mr-2" />,
|
||||
text: "Warning",
|
||||
textColor: "text-yellow-500",
|
||||
badge: <Badge variant="outline" className="bg-yellow-800/80 text-yellow-300 border-yellow-700 hover:bg-yellow-800">Warning</Badge>
|
||||
};
|
||||
case "paused":
|
||||
return {
|
||||
icon: <Pause className="h-4 w-4 text-gray-500 mr-2" />,
|
||||
text: "Paused",
|
||||
textColor: "text-gray-500",
|
||||
badge: <Badge variant="outline" className="bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800">Paused</Badge>
|
||||
};
|
||||
default: // down
|
||||
return {
|
||||
icon: <X className="h-4 w-4 text-red-500 mr-2" />,
|
||||
text: "Down",
|
||||
textColor: "text-red-500",
|
||||
badge: <Badge variant="destructive">Down</Badge>
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Filter the uptimeData to only include status changes (incidents)
|
||||
export const getStatusChangeEvents = (uptimeData: UptimeData[]): UptimeData[] => {
|
||||
if (!uptimeData.length) return [];
|
||||
|
||||
// First sort the data by timestamp (newest first for display)
|
||||
const sortedData = [...uptimeData].sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
// We need to find all status changes, including to/from paused
|
||||
const statusChanges: UptimeData[] = [];
|
||||
|
||||
// Always include the most recent check as a baseline
|
||||
if (sortedData.length > 0) {
|
||||
statusChanges.push(sortedData[0]);
|
||||
}
|
||||
|
||||
// Compare each check with the previous one to detect status changes
|
||||
for (let i = 0; i < sortedData.length - 1; i++) {
|
||||
const currentCheck = sortedData[i];
|
||||
const nextCheck = sortedData[i + 1]; // This is actually the "older" check
|
||||
|
||||
// If the status changed, add the "older" check to our incidents list
|
||||
// This shows what the status changed TO
|
||||
if (currentCheck.status !== nextCheck.status) {
|
||||
statusChanges.push(nextCheck);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${statusChanges.length} status changes, including paused status changes: ${statusChanges.some(i => i.status === 'paused')}`);
|
||||
|
||||
return statusChanges;
|
||||
};
|
||||
|
||||
// Get date range for display
|
||||
export const getDateRangeDisplay = (incidents: UptimeData[]): string => {
|
||||
if (!incidents.length) return "";
|
||||
|
||||
const sortedData = [...incidents].sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
const start = format(new Date(sortedData[0].timestamp), 'MMM dd, yyyy');
|
||||
const end = format(new Date(sortedData[sortedData.length - 1].timestamp), 'MMM dd, yyyy');
|
||||
|
||||
// Display date range if different dates
|
||||
return start === end ? start : `${start} - ${end}`;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
// Export all service components for easier imports
|
||||
export * from './ServiceHeader';
|
||||
export * from './ServiceStatsCards';
|
||||
export * from './ResponseTimeChart';
|
||||
export * from './incident-history/LatestChecksTable';
|
||||
export * from './LoadingState';
|
||||
export * from './ServiceNotFound';
|
||||
export * from './ServiceMonitoringButton';
|
||||
export * from './AddServiceDialog';
|
||||
export * from './ServicesTableContainer';
|
||||
export * from './ServicesTableView';
|
||||
export * from './ServiceDeleteDialog';
|
||||
export * from './ServiceHistoryDialog';
|
||||
export * from './ServiceEditDialog';
|
||||
@@ -0,0 +1,184 @@
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MoreHorizontal, Eye, Play, Pause, Edit, Bell, BellOff, Trash2 } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Service } from "@/types/service.types";
|
||||
import { serviceService } from "@/services/serviceService";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface ServiceRowActionsProps {
|
||||
service: Service;
|
||||
onViewDetail: (service: Service) => void;
|
||||
onPauseResume: (service: Service) => Promise<void>;
|
||||
onEdit: (service: Service) => void;
|
||||
onDelete: (service: Service) => void;
|
||||
onMuteAlerts?: (service: Service) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ServiceRowActions = ({
|
||||
service,
|
||||
onViewDetail,
|
||||
onPauseResume,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMuteAlerts
|
||||
}: ServiceRowActionsProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
// Handle pause/resume directly from dropdown
|
||||
const handlePauseResume = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
if (service.status === "paused") {
|
||||
// Resume monitoring
|
||||
console.log(`Resuming monitoring for service ${service.id} (${service.name}) from dropdown`);
|
||||
|
||||
// First ensure we update the status
|
||||
await serviceService.resumeMonitoring(service.id);
|
||||
|
||||
// Then start monitoring service (performs an immediate check)
|
||||
await serviceService.startMonitoringService(service.id);
|
||||
|
||||
toast({
|
||||
title: "Monitoring resumed",
|
||||
description: `Monitoring for ${service.name} has been resumed. First check is running now.`,
|
||||
});
|
||||
} else {
|
||||
// Pause monitoring
|
||||
console.log(`Pausing monitoring for service ${service.id} (${service.name}) from dropdown`);
|
||||
await serviceService.pauseMonitoring(service.id);
|
||||
|
||||
toast({
|
||||
title: "Monitoring paused",
|
||||
description: `Monitoring for ${service.name} has been paused.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Call the parent handler to refresh the UI
|
||||
onPauseResume(service);
|
||||
} catch (error) {
|
||||
console.error("Error toggling monitoring:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to change monitoring status. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Check alerts status - check both fields for backward compatibility
|
||||
const alertsMuted = service.alerts === "muted" || service.muteAlerts === true;
|
||||
|
||||
// Handle mute/unmute alerts
|
||||
const handleMuteAlerts = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (onMuteAlerts) {
|
||||
try {
|
||||
console.log(`Attempting to ${alertsMuted ? 'unmute' : 'mute'} alerts for service ${service.id} (${service.name})`);
|
||||
await onMuteAlerts(service);
|
||||
} catch (error) {
|
||||
console.error("Error toggling alerts:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to change alert settings. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex space-x-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="More options"
|
||||
className="opacity-70 hover:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48 bg-gray-900 border border-gray-800 text-white"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetail(service);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>View Detail</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
|
||||
onClick={handlePauseResume}
|
||||
>
|
||||
{service.status === "paused" ? (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
<span>Resume Monitoring</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="h-4 w-4" />
|
||||
<span>Pause Monitoring</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(service);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
|
||||
onClick={handleMuteAlerts}
|
||||
>
|
||||
{alertsMuted ? (
|
||||
<>
|
||||
<Bell className="h-4 w-4" />
|
||||
<span>Unmute Alerts</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BellOff className="h-4 w-4" />
|
||||
<span>Mute Alerts</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-gray-700" />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-red-500 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(service);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
|
||||
import React from "react";
|
||||
import { BellOff } from "lucide-react";
|
||||
import { Service } from "@/types/service.types";
|
||||
|
||||
interface ServiceRowHeaderProps {
|
||||
service: Service;
|
||||
}
|
||||
|
||||
export const ServiceRowHeader = ({ service }: ServiceRowHeaderProps) => {
|
||||
// Display URL for HTTP services, hostname for others
|
||||
const shouldDisplayFullUrl = service.type.toLowerCase() === "http";
|
||||
let serviceSubtitle = "";
|
||||
|
||||
// Check alerts status - check both fields for backward compatibility
|
||||
const alertsMuted = service.alerts === "muted" || service.muteAlerts === true;
|
||||
|
||||
if (service.url) {
|
||||
try {
|
||||
const url = service.url;
|
||||
// If the URL doesn't start with http:// or https://, add https:// prefix
|
||||
const formattedUrl = (!url.startsWith('http://') && !url.startsWith('https://'))
|
||||
? `https://${url}`
|
||||
: url;
|
||||
|
||||
try {
|
||||
// Now try to parse it as a URL
|
||||
const urlObj = new URL(formattedUrl);
|
||||
if (shouldDisplayFullUrl) {
|
||||
serviceSubtitle = formattedUrl;
|
||||
} else {
|
||||
serviceSubtitle = urlObj.hostname;
|
||||
}
|
||||
} catch (urlError) {
|
||||
// If URL parsing still fails, just show the original URL
|
||||
serviceSubtitle = url;
|
||||
}
|
||||
} catch (e) {
|
||||
// If any other error occurs, just show the original URL
|
||||
serviceSubtitle = service.url;
|
||||
console.log("Error processing URL:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<div className="text-base font-medium">{service.name}</div>
|
||||
{service.url && (
|
||||
<div className="text-sm text-gray-500 mt-1">{serviceSubtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Add a visual indicator if alerts are muted for this service */}
|
||||
{alertsMuted && (
|
||||
<div className="ml-1" title="Alerts muted">
|
||||
<BellOff className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
import React from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface ServiceRowResponseTimeProps {
|
||||
responseTime: number;
|
||||
}
|
||||
|
||||
export const ServiceRowResponseTime = ({ responseTime }: ServiceRowResponseTimeProps) => {
|
||||
// Determine if response time is high (≥ 1000ms)
|
||||
const isResponseTimeHigh = responseTime >= 1000;
|
||||
|
||||
return (
|
||||
<div className="font-mono text-base flex items-center gap-1.5">
|
||||
{responseTime > 0 ? (
|
||||
<>
|
||||
<span className={isResponseTimeHigh ? "text-amber-500 font-semibold" : ""}>
|
||||
{responseTime}ms
|
||||
</span>
|
||||
{isResponseTimeHigh && (
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
)}
|
||||
</>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
export * from './ServiceRowActions';
|
||||
export * from './ServiceRowHeader';
|
||||
export * from './ServiceRowResponseTime';
|
||||
@@ -0,0 +1,105 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Settings } from "lucide-react";
|
||||
import { settingsService, type GeneralSettings } from "@/services/settingsService";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const GeneralSettingsPanel = () => {
|
||||
const { toast } = useToast();
|
||||
const [formData, setFormData] = useState<Partial<GeneralSettings>>({});
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const { data: settings, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['generalSettings'],
|
||||
queryFn: settingsService.getGeneralSettings,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData(settings);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!settings?.id || !formData) return;
|
||||
|
||||
try {
|
||||
const result = await settingsService.updateGeneralSettings(settings.id, formData);
|
||||
if (result) {
|
||||
toast({
|
||||
title: "Settings updated",
|
||||
description: "Your settings have been updated successfully.",
|
||||
variant: "default",
|
||||
});
|
||||
refetch();
|
||||
setIsEditing(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating settings:", error);
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: "There was a problem updating your settings.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-4">Loading settings...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-4 text-red-500">Error loading settings</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your system settings and preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="system_name">System Name</Label>
|
||||
<Input
|
||||
id="system_name"
|
||||
name="system_name"
|
||||
value={formData?.system_name || ''}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
placeholder="My Monitoring System"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setIsEditing(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave}>Save Changes</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettingsPanel;
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Github, FileText, Twitter, MessageCircle, Code2, ServerIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||
export const AboutSystem: React.FC = () => {
|
||||
const {
|
||||
t
|
||||
} = useLanguage();
|
||||
const {
|
||||
theme
|
||||
} = useTheme();
|
||||
const {
|
||||
systemName
|
||||
} = useSystemSettings();
|
||||
return <div className="space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
|
||||
<p className="text-muted-foreground text-base leading-relaxed mt-2">{t('aboutCheckCle')}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
|
||||
<CardHeader className="bg-muted/50 pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ServerIcon className={`h-5 w-5 ${theme === 'dark' ? 'text-sky-400' : 'text-sky-600'}`} />
|
||||
<span className="font-thin text-xl">{t('systemDescription')}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
|
||||
|
||||
<div className="flex flex-col space-y-3 pt-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{t('systemVersion')}</span>
|
||||
<span className="text-foreground font-medium">{t('version')} 1.0.0</span>
|
||||
</div>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{t('license')}</span>
|
||||
<span className="text-foreground font-medium">{t('mitLicense')}</span>
|
||||
</div>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{t('releasedOn')}</span>
|
||||
<span className="text-foreground font-medium">May 10, 2025</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
|
||||
<CardHeader className="bg-muted/50 pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Code2 className={`h-5 w-5 ${theme === 'dark' ? 'text-emerald-400' : 'text-emerald-600'}`} />
|
||||
<span>{t('links')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="font-medium text-base">{systemName || 'CheckCle'} {t('resources').toLowerCase()}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://github.com/operacle/checkcle", "_blank")}>
|
||||
<Github className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
|
||||
<span>{t('viewOnGithub')}</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://docs.checkcle.io", "_blank")}>
|
||||
<FileText className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
|
||||
<span>{t('viewDocumentation')}</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://x.com/tlengoss", "_blank")}>
|
||||
<Twitter className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
|
||||
<span>{t('followOnX')}</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("#", "_blank")}>
|
||||
<MessageCircle className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
|
||||
<span>{t('joinDiscord')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
export default AboutSystem;
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export { default as AboutSystem } from './AboutSystem';
|
||||
@@ -0,0 +1,89 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { templateService } from "@/services/templateService";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, RefreshCcw } from "lucide-react";
|
||||
import { TemplateList } from "./TemplateList";
|
||||
import { TemplateDialog } from "./TemplateDialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export const AlertsTemplates = () => {
|
||||
const { toast } = useToast();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: templates = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch
|
||||
} = useQuery({
|
||||
queryKey: ['notification_templates'],
|
||||
queryFn: templateService.getTemplates,
|
||||
});
|
||||
|
||||
const handleAddTemplate = () => {
|
||||
setEditingTemplate(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditTemplate = (id: string) => {
|
||||
setEditingTemplate(id);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
toast({
|
||||
title: "Refreshing",
|
||||
description: "Updating template list...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Alert Templates</CardTitle>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCcw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={handleAddTemplate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Template
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error ? (
|
||||
<div className="text-center p-6">
|
||||
<p className="text-destructive mb-4">Error loading templates</p>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TemplateList
|
||||
templates={templates}
|
||||
isLoading={isLoading}
|
||||
onEdit={handleEditTemplate}
|
||||
refetchTemplates={refetch}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<TemplateDialog
|
||||
open={isDialogOpen}
|
||||
templateId={editingTemplate}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
setIsDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTemplateForm } from "./hooks/useTemplateForm";
|
||||
import { BasicTemplateFields } from "./form/BasicTemplateFields";
|
||||
import { MessagesTabContent } from "./form/MessagesTabContent";
|
||||
import { PlaceholdersTabContent } from "./form/PlaceholdersTabContent";
|
||||
import { Loader2, ChevronDown } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface TemplateDialogProps {
|
||||
open: boolean;
|
||||
templateId: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const TemplateDialog: React.FC<TemplateDialogProps> = ({
|
||||
open,
|
||||
templateId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const {
|
||||
form,
|
||||
isEditMode,
|
||||
isLoadingTemplate,
|
||||
isSubmitting,
|
||||
onSubmit
|
||||
} = useTemplateForm({
|
||||
templateId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess
|
||||
});
|
||||
|
||||
// For debugging purposes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
console.log("Template dialog opened. Edit mode:", isEditMode, "Template ID:", templateId);
|
||||
|
||||
// Log form values when they change
|
||||
const subscription = form.watch((value) => {
|
||||
console.log("Current form values:", value);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}
|
||||
}, [open, isEditMode, templateId, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "Edit Template" : "Add Template"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoadingTemplate ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-2">Loading template data...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 flex-1 flex flex-col">
|
||||
<div className="relative flex-1">
|
||||
<ScrollArea className="pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
|
||||
<div className="space-y-6 pb-6 pr-4">
|
||||
<BasicTemplateFields control={form.control} />
|
||||
|
||||
<Tabs defaultValue="messages">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="messages">Messages</TabsTrigger>
|
||||
<TabsTrigger value="placeholders">Placeholders</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="messages" className="pt-4">
|
||||
<MessagesTabContent control={form.control} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="placeholders" className="pt-4">
|
||||
<PlaceholdersTabContent control={form.control} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="absolute bottom-2 right-4 text-muted-foreground opacity-60">
|
||||
<ChevronDown className="h-4 w-4 animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-2 pt-2 border-t border-border">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoadingTemplate}
|
||||
className="relative"
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isSubmitting
|
||||
? (isEditMode ? "Updating..." : "Creating...")
|
||||
: (isEditMode ? "Update Template" : "Create Template")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { NotificationTemplate, templateService } from "@/services/templateService";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface TemplateListProps {
|
||||
templates: NotificationTemplate[];
|
||||
isLoading: boolean;
|
||||
onEdit: (id: string) => void;
|
||||
refetchTemplates: () => void;
|
||||
}
|
||||
|
||||
export const TemplateList: React.FC<TemplateListProps> = ({
|
||||
templates,
|
||||
isLoading,
|
||||
onEdit,
|
||||
refetchTemplates,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [templateToDelete, setTemplateToDelete] = useState<NotificationTemplate | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDeletePrompt = (template: NotificationTemplate) => {
|
||||
setTemplateToDelete(template);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async () => {
|
||||
if (!templateToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await templateService.deleteTemplate(templateToDelete.id);
|
||||
toast({
|
||||
title: "Template deleted",
|
||||
description: `Template "${templateToDelete.name}" has been removed.`,
|
||||
});
|
||||
refetchTemplates();
|
||||
} catch (error) {
|
||||
console.error("Error deleting template:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete template. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setTemplateToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="py-8 flex justify-center">
|
||||
<div className="animate-pulse flex flex-col items-center">
|
||||
<div className="h-4 bg-gray-200 rounded w-32 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-64"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-2">No templates found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create your first notification template to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{templates.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell className="font-medium">{template.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{template.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(template.created).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onEdit(template.id)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeletePrompt(template)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the template "{templateToDelete?.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteTemplate();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
|
||||
import React from "react";
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
interface BasicTemplateFieldsProps {
|
||||
control: Control<any>;
|
||||
}
|
||||
|
||||
export const BasicTemplateFields: React.FC<BasicTemplateFieldsProps> = ({ control }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Template Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter template name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Template Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select template type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="service">Service</SelectItem>
|
||||
<SelectItem value="incident">Incident</SelectItem>
|
||||
<SelectItem value="maintenance">Maintenance</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
|
||||
import React from "react";
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Control } from "react-hook-form";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface MessagesTabContentProps {
|
||||
control: Control<any>;
|
||||
}
|
||||
|
||||
export const MessagesTabContent: React.FC<MessagesTabContentProps> = ({ control }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="up_message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Up Message</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Service ${service_name} is UP. Response time: ${response_time}ms"
|
||||
className="min-h-24"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Message sent when a service returns to UP status
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="down_message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Down Message</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Service ${service_name} is DOWN. Status: ${status}"
|
||||
className="min-h-24"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Message sent when a service goes DOWN
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="maintenance_message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Maintenance Message</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Service ${service_name} is under maintenance"
|
||||
className="min-h-20"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="incident_message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Incident Message</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Warning: Service ${service_name} has an incident"
|
||||
className="min-h-20"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="resolved_message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Resolved Message</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Issue with service ${service_name} has been resolved"
|
||||
className="min-h-20"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
import React from "react";
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Control } from "react-hook-form";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface PlaceholdersTabContentProps {
|
||||
control: Control<any>;
|
||||
}
|
||||
|
||||
export const PlaceholdersTabContent: React.FC<PlaceholdersTabContentProps> = ({ control }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Template Placeholders</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="service_name_placeholder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service Name Placeholder</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="${service_name}" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Used for service name in messages
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="response_time_placeholder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Response Time Placeholder</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="${response_time}" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Used for response time in milliseconds
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="status_placeholder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status Placeholder</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="${status}" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Used for service status (UP, DOWN, etc.)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="threshold_placeholder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Threshold Placeholder</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="${threshold}" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Used for threshold values in alerts
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Placeholder Usage Guide</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
These placeholders will be replaced with actual values when notifications are sent:
|
||||
</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-muted/30 p-2 rounded">
|
||||
<code className="text-xs">${"{service_name}"}</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">The name of the service</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 p-2 rounded">
|
||||
<code className="text-xs">${"{response_time}"}</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">Response time in milliseconds</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 p-2 rounded">
|
||||
<code className="text-xs">${"{status}"}</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">Service status (UP, DOWN)</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 p-2 rounded">
|
||||
<code className="text-xs">${"{threshold}"}</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">Service threshold value</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 p-2 rounded">
|
||||
<code className="text-xs">${"{url}"}</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">Service URL</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 p-2 rounded">
|
||||
<code className="text-xs">${"{time}"}</code>
|
||||
<p className="text-xs text-muted-foreground mt-1">Current date and time</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
export * from './BasicTemplateFields';
|
||||
export * from './MessagesTabContent';
|
||||
export * from './PlaceholdersTabContent';
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export * from './useTemplateForm';
|
||||
@@ -0,0 +1,199 @@
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { templateService, CreateUpdateTemplateData } from "@/services/templateService";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Template form schema
|
||||
export const templateFormSchema = z.object({
|
||||
name: z.string().min(2, "Name is required and must be at least 2 characters"),
|
||||
type: z.string().min(1, "Type is required"),
|
||||
up_message: z.string().min(1, "Up message is required"),
|
||||
down_message: z.string().min(1, "Down message is required"),
|
||||
maintenance_message: z.string().min(1, "Maintenance message is required"),
|
||||
incident_message: z.string().min(1, "Incident message is required"),
|
||||
resolved_message: z.string().min(1, "Resolved message is required"),
|
||||
service_name_placeholder: z.string().min(1, "Service name placeholder is required"),
|
||||
response_time_placeholder: z.string().min(1, "Response time placeholder is required"),
|
||||
status_placeholder: z.string().min(1, "Status placeholder is required"),
|
||||
threshold_placeholder: z.string().min(1, "Threshold placeholder is required"),
|
||||
});
|
||||
|
||||
// Define the form data type from the schema
|
||||
export type TemplateFormData = z.infer<typeof templateFormSchema>;
|
||||
|
||||
// Default form values
|
||||
const defaultFormValues: TemplateFormData = {
|
||||
name: "",
|
||||
type: "default",
|
||||
up_message: "Service ${service_name} is UP. Response time: ${response_time}ms",
|
||||
down_message: "Service ${service_name} is DOWN. Status: ${status}",
|
||||
maintenance_message: "Service ${service_name} is under maintenance",
|
||||
incident_message: "Service ${service_name} has an incident",
|
||||
resolved_message: "Service ${service_name} issue has been resolved",
|
||||
service_name_placeholder: "${service_name}",
|
||||
response_time_placeholder: "${response_time}",
|
||||
status_placeholder: "${status}",
|
||||
threshold_placeholder: "${threshold}",
|
||||
};
|
||||
|
||||
export interface UseTemplateFormProps {
|
||||
templateId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: UseTemplateFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const isEditMode = !!templateId;
|
||||
|
||||
console.log("Template form initialized with templateId:", templateId, "isEditMode:", isEditMode);
|
||||
|
||||
const form = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateFormSchema),
|
||||
defaultValues: defaultFormValues,
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
// Query to fetch template data for editing
|
||||
const { isLoading: isLoadingTemplate, data: templateData } = useQuery({
|
||||
queryKey: ['template', templateId],
|
||||
queryFn: () => {
|
||||
if (!templateId) return null;
|
||||
console.log("Fetching template data for ID:", templateId);
|
||||
return templateService.getTemplate(templateId);
|
||||
},
|
||||
enabled: !!templateId && open,
|
||||
});
|
||||
|
||||
// Set form values when template data is loaded
|
||||
useEffect(() => {
|
||||
if (templateData && open) {
|
||||
console.log("Setting form values with template data:", templateData);
|
||||
|
||||
form.reset({
|
||||
name: templateData.name || "",
|
||||
type: templateData.type || "default",
|
||||
up_message: templateData.up_message || "",
|
||||
down_message: templateData.down_message || "",
|
||||
maintenance_message: templateData.maintenance_message || "",
|
||||
incident_message: templateData.incident_message || "",
|
||||
resolved_message: templateData.resolved_message || "",
|
||||
service_name_placeholder: templateData.service_name_placeholder || "${service_name}",
|
||||
response_time_placeholder: templateData.response_time_placeholder || "${response_time}",
|
||||
status_placeholder: templateData.status_placeholder || "${status}",
|
||||
threshold_placeholder: templateData.threshold_placeholder || "${threshold}",
|
||||
});
|
||||
}
|
||||
}, [templateData, open, form]);
|
||||
|
||||
// Handle form errors
|
||||
useEffect(() => {
|
||||
const subscription = form.formState.errors.root?.message &&
|
||||
toast({
|
||||
title: "Error",
|
||||
description: form.formState.errors.root.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (subscription) {
|
||||
// Clean up if needed
|
||||
}
|
||||
};
|
||||
}, [form.formState.errors.root, toast]);
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateUpdateTemplateData) => templateService.createTemplate(data),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Template created",
|
||||
description: "Your notification template has been created successfully.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['notification_templates'] });
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error creating template:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to create template. Please check your inputs and try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: CreateUpdateTemplateData }) =>
|
||||
templateService.updateTemplate(id, data),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Template updated",
|
||||
description: "Your notification template has been updated successfully.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['notification_templates'] });
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error updating template:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update template. Please check your inputs and try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitting = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = (formData: TemplateFormData) => {
|
||||
console.log("Submitting form data:", formData);
|
||||
|
||||
// Ensure all required fields are present
|
||||
const completeData: CreateUpdateTemplateData = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
up_message: formData.up_message,
|
||||
down_message: formData.down_message,
|
||||
maintenance_message: formData.maintenance_message,
|
||||
incident_message: formData.incident_message,
|
||||
resolved_message: formData.resolved_message,
|
||||
service_name_placeholder: formData.service_name_placeholder,
|
||||
response_time_placeholder: formData.response_time_placeholder,
|
||||
status_placeholder: formData.status_placeholder,
|
||||
threshold_placeholder: formData.threshold_placeholder,
|
||||
};
|
||||
|
||||
if (isEditMode && templateId) {
|
||||
console.log("Updating template with ID:", templateId);
|
||||
updateMutation.mutate({ id: templateId, data: completeData });
|
||||
} else {
|
||||
console.log("Creating new template");
|
||||
createMutation.mutate(completeData);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
console.log("Dialog closed, resetting form");
|
||||
form.reset(defaultFormValues);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return {
|
||||
form,
|
||||
isEditMode,
|
||||
isLoadingTemplate,
|
||||
isSubmitting,
|
||||
onSubmit
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export { AlertsTemplates } from "./AlertsTemplates";
|
||||
@@ -0,0 +1,349 @@
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface NotificationChannelDialogProps {
|
||||
open: boolean;
|
||||
onClose: (refreshList: boolean) => void;
|
||||
editingConfig: AlertConfiguration | null;
|
||||
}
|
||||
|
||||
const baseSchema = z.object({
|
||||
notify_name: z.string().min(1, "Name is required"),
|
||||
notification_type: z.enum(["telegram", "discord", "slack", "signal", "email"]),
|
||||
enabled: z.boolean().default(true),
|
||||
service_id: z.string().default("global"), // Assuming global for now, could be linked to specific services
|
||||
template_id: z.string().optional(),
|
||||
});
|
||||
|
||||
const telegramSchema = baseSchema.extend({
|
||||
notification_type: z.literal("telegram"),
|
||||
telegram_chat_id: z.string().min(1, "Chat ID is required"),
|
||||
bot_token: z.string().min(1, "Bot token is required"),
|
||||
});
|
||||
|
||||
const discordSchema = baseSchema.extend({
|
||||
notification_type: z.literal("discord"),
|
||||
discord_webhook_url: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
|
||||
const slackSchema = baseSchema.extend({
|
||||
notification_type: z.literal("slack"),
|
||||
slack_webhook_url: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
|
||||
const signalSchema = baseSchema.extend({
|
||||
notification_type: z.literal("signal"),
|
||||
signal_number: z.string().min(1, "Signal number is required"),
|
||||
});
|
||||
|
||||
const emailSchema = baseSchema.extend({
|
||||
notification_type: z.literal("email"),
|
||||
// Email specific fields could be added here
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("notification_type", [
|
||||
telegramSchema,
|
||||
discordSchema,
|
||||
slackSchema,
|
||||
signalSchema,
|
||||
emailSchema
|
||||
]);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const NotificationChannelDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
editingConfig
|
||||
}: NotificationChannelDialogProps) => {
|
||||
const isEditing = !!editingConfig;
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
notify_name: "",
|
||||
notification_type: "telegram" as const,
|
||||
enabled: true,
|
||||
service_id: "global",
|
||||
template_id: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { watch, reset } = form;
|
||||
const notificationType = watch("notification_type");
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingConfig) {
|
||||
// Handle string vs boolean for enabled field
|
||||
const enabled = typeof editingConfig.enabled === 'string'
|
||||
? editingConfig.enabled === "true"
|
||||
: !!editingConfig.enabled;
|
||||
|
||||
reset({
|
||||
...editingConfig,
|
||||
enabled
|
||||
});
|
||||
} else if (open) {
|
||||
reset({
|
||||
notify_name: "",
|
||||
notification_type: "telegram" as const,
|
||||
enabled: true,
|
||||
service_id: "global",
|
||||
template_id: "",
|
||||
});
|
||||
}
|
||||
}, [editingConfig, open, reset]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose(false);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Ensure service_id is always present
|
||||
const configData = {
|
||||
...values,
|
||||
service_id: values.service_id || "global",
|
||||
};
|
||||
|
||||
if (isEditing && editingConfig?.id) {
|
||||
await alertConfigService.updateAlertConfiguration(editingConfig.id, configData);
|
||||
} else {
|
||||
await alertConfigService.createAlertConfiguration(configData as any);
|
||||
}
|
||||
onClose(true); // Close with refresh
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => handleClose()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? "Edit Notification Channel" : "Add Notification Channel"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notify_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Notification Channel" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A name to identify this notification channel
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notification_type"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Channel Type</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-1"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="telegram" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Telegram
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="discord" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Discord
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="slack" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Slack
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="signal" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Signal
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="email" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Email
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show different fields based on notification type */}
|
||||
{notificationType === "telegram" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="telegram_chat_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Telegram Chat ID" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Telegram Bot Token" {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{notificationType === "discord" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="discord_webhook_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Discord Webhook URL" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notificationType === "slack" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slack_webhook_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Slack Webhook URL" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notificationType === "signal" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signal_number"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Signal Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="+1234567890" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
<FormDescription>
|
||||
Turn notifications on or off
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
import { AlertConfiguration } from "@/services/alertConfigService";
|
||||
import { Bell, Edit, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { alertConfigService } from "@/services/alertConfigService";
|
||||
|
||||
interface NotificationChannelListProps {
|
||||
channels: AlertConfiguration[];
|
||||
onEdit: (config: AlertConfiguration) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const NotificationChannelList = ({
|
||||
channels,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: NotificationChannelListProps) => {
|
||||
const toggleEnabled = async (config: AlertConfiguration) => {
|
||||
if (!config.id) return;
|
||||
|
||||
await alertConfigService.updateAlertConfiguration(config.id, {
|
||||
enabled: !config.enabled
|
||||
});
|
||||
|
||||
// The parent component will refresh the list
|
||||
onEdit(config);
|
||||
};
|
||||
|
||||
const getChannelTypeLabel = (type: string) => {
|
||||
switch(type) {
|
||||
case "telegram": return "Telegram";
|
||||
case "discord": return "Discord";
|
||||
case "slack": return "Slack";
|
||||
case "signal": return "Signal";
|
||||
case "email": return "Email";
|
||||
default: return type;
|
||||
}
|
||||
};
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<Bell className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">No notification channels configured</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Add a notification channel to get alerts when your services go down.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell className="font-medium">{channel.notify_name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{getChannelTypeLabel(channel.notification_type)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={
|
||||
typeof channel.enabled === 'string'
|
||||
? channel.enabled === "true"
|
||||
: !!channel.enabled
|
||||
}
|
||||
onCheckedChange={() => toggleEnabled(channel)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{channel.created ? new Date(channel.created).toLocaleDateString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(channel)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (channel.id && confirm("Are you sure you want to delete this notification channel?")) {
|
||||
onDelete(channel.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService";
|
||||
import { NotificationChannelDialog } from "./NotificationChannelDialog";
|
||||
import { NotificationChannelList } from "./NotificationChannelList";
|
||||
|
||||
const NotificationSettings = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [currentTab, setCurrentTab] = useState<string>("all");
|
||||
const [editingConfig, setEditingConfig] = useState<AlertConfiguration | null>(null);
|
||||
|
||||
const fetchAlertConfigurations = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const configs = await alertConfigService.getAlertConfigurations();
|
||||
setAlertConfigs(configs);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlertConfigurations();
|
||||
}, []);
|
||||
|
||||
const handleAddNew = () => {
|
||||
setEditingConfig(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (config: AlertConfiguration) => {
|
||||
setEditingConfig(config);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const success = await alertConfigService.deleteAlertConfiguration(id);
|
||||
if (success) {
|
||||
fetchAlertConfigurations();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogClose = (refreshList: boolean) => {
|
||||
setDialogOpen(false);
|
||||
if (refreshList) {
|
||||
fetchAlertConfigurations();
|
||||
}
|
||||
};
|
||||
|
||||
const getFilteredConfigs = () => {
|
||||
if (currentTab === "all") return alertConfigs;
|
||||
return alertConfigs.filter(config => config.notification_type === currentTab);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Notification Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure notification channels for your services
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleAddNew}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Channel
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
defaultValue="all"
|
||||
value={currentTab}
|
||||
onValueChange={setCurrentTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="all">All Channels</TabsTrigger>
|
||||
<TabsTrigger value="telegram">Telegram</TabsTrigger>
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
<TabsTrigger value="slack">Slack</TabsTrigger>
|
||||
<TabsTrigger value="signal">Signal</TabsTrigger>
|
||||
<TabsTrigger value="email">Email</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={currentTab} className="mt-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<NotificationChannelList
|
||||
channels={getFilteredConfigs()}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
|
||||
<NotificationChannelDialog
|
||||
open={dialogOpen}
|
||||
onClose={handleDialogClose}
|
||||
editingConfig={editingConfig}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationSettings;
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
import NotificationSettings from "./NotificationSettings";
|
||||
|
||||
export { NotificationSettings };
|
||||