chore: import upstream snapshot with attribution
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
.cache
|
||||
*.log
|
||||
|
||||
@@ -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,25 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CheckCle</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="@checkcle_oss" />
|
||||
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"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.6",
|
||||
"@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",
|
||||
"jspdf": "^3.0.1",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"pocketbase": "^0.26.8",
|
||||
"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: 12 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 49 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: /
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 250 B |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 6.0 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 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,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { SidebarProvider } from './contexts/SidebarContext';
|
||||
|
||||
import Index from './pages/Index';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import InstanceMonitoring from './pages/InstanceMonitoring';
|
||||
import ContainerMonitoring from './pages/ContainerMonitoring';
|
||||
import ServiceDetail from './pages/ServiceDetail';
|
||||
import SslDomain from './pages/SslDomain';
|
||||
import ScheduleIncident from './pages/ScheduleIncident';
|
||||
import OperationalPage from './pages/OperationalPage';
|
||||
import RegionalMonitoring from './pages/RegionalMonitoring';
|
||||
import Settings from './pages/Settings';
|
||||
import Profile from './pages/Profile';
|
||||
import NotFound from './pages/NotFound';
|
||||
import PublicStatusPage from './pages/PublicStatusPage';
|
||||
import { ProtectedRoute } from './components/auth/ProtectedRoute';
|
||||
import ServerDetail from './pages/ServerDetail';
|
||||
|
||||
function App() {
|
||||
const [queryClient] = useState(() => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<SidebarProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/public/:slug" element={<PublicStatusPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><Index /></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/instance-monitoring" element={<ProtectedRoute><InstanceMonitoring /></ProtectedRoute>} />
|
||||
<Route path="/server-detail/:serverId" element={<ProtectedRoute><ServerDetail /></ProtectedRoute>} />
|
||||
<Route path="/container-monitoring" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
|
||||
<Route path="/container-monitoring/:serverId" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
|
||||
<Route path="/service/:id" element={<ProtectedRoute><ServiceDetail /></ProtectedRoute>} />
|
||||
<Route path="/ssl-domain" element={<ProtectedRoute><SslDomain /></ProtectedRoute>} />
|
||||
<Route path="/schedule-incident" element={<ProtectedRoute><ScheduleIncident /></ProtectedRoute>} />
|
||||
<Route path="/operational-page" element={<ProtectedRoute><OperationalPage /></ProtectedRoute>} />
|
||||
<Route path="/regional-monitoring" element={<ProtectedRoute><RegionalMonitoring /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</SidebarProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
// API routes handler
|
||||
import realtime from './realtime';
|
||||
import settingsApi from './settings';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
} else if (path === '/api/settings' || path.startsWith('/api/settings/')) {
|
||||
// console.log("Routing to settings handler");
|
||||
return await settingsApi(body, path);
|
||||
}
|
||||
|
||||
// Return 404 for unknown routes
|
||||
// console.error(`Endpoint not found: ${path}`);
|
||||
return {
|
||||
status: 404,
|
||||
json: {
|
||||
ok: false,
|
||||
error_code: 404,
|
||||
description: "Endpoint not found"
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Mock fetch override for development
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async (url, options = {}) => {
|
||||
// Check if this is an API request to our mock endpoints
|
||||
if (typeof url === 'string' && url.startsWith('/api/')) {
|
||||
// console.log('Intercepting API request:', url, options);
|
||||
|
||||
try {
|
||||
let body = {};
|
||||
|
||||
// Properly handle different body types
|
||||
if (options.body) {
|
||||
if (typeof options.body === 'string') {
|
||||
body = JSON.parse(options.body);
|
||||
} else {
|
||||
// Handle ReadableStream or other BodyInit types
|
||||
const bodyText = await new Response(options.body).text();
|
||||
body = bodyText ? JSON.parse(bodyText) : {};
|
||||
}
|
||||
}
|
||||
|
||||
const result = await api.handleRequest(url, options.method || 'GET', body);
|
||||
|
||||
// Create a proper Response object
|
||||
return new Response(JSON.stringify(result.json), {
|
||||
status: result.status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in API handler:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
message: 'Internal server error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// For all other requests, use the original fetch
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
|
||||
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,26 @@
|
||||
|
||||
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
export const getSettings = async (): Promise<SettingsApiResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/api/settings`, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const settings = await response.json();
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: true, data: settings },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
return {
|
||||
status: 500,
|
||||
json: { success: false, message: 'Failed to fetch settings' },
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
|
||||
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
||||
let subject = 'Test Email from CheckCle';
|
||||
let htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb;">Test Email</h2>
|
||||
<p>This is a test email from your monitoring system.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
Sent from CheckCle Monitoring System<br>
|
||||
Template: ${template}<br>
|
||||
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
switch (template) {
|
||||
case 'verification':
|
||||
subject = 'Email Verification Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #10b981;">Email Verification Test</h2>
|
||||
<p>This is a test of the email verification template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Verification Email</p>
|
||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
case 'password-reset':
|
||||
subject = 'Password Reset Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #f59e0b;">Password Reset Test</h2>
|
||||
<p>This is a test of the password reset template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Password Reset Email</p>
|
||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
case 'email-change':
|
||||
subject = 'Email Change Confirmation Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
|
||||
<p>This is a test of the email change confirmation template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
return { subject, htmlBody };
|
||||
};
|
||||
|
||||
export const sendTestEmail = async (data: any): Promise<SettingsApiResponse> => {
|
||||
console.log('sendTestEmail function called with data:', data);
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!data || typeof data !== 'object') {
|
||||
console.log('Invalid request data - not object');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Invalid request data' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.email || typeof data.email !== 'string') {
|
||||
console.log('Email address missing or invalid type');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Email address is required and must be a string' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateEmail(data.email)) {
|
||||
console.log('Invalid email format:', data.email);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Invalid email address format' },
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Email validation passed for:', data.email);
|
||||
|
||||
const headers = getAuthHeaders();
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Get current SMTP settings first
|
||||
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
|
||||
|
||||
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!settingsResponse.ok) {
|
||||
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Failed to get SMTP settings' },
|
||||
};
|
||||
}
|
||||
|
||||
const settingsData = await settingsResponse.json();
|
||||
console.log('Retrieved settings data:', settingsData);
|
||||
|
||||
const smtpSettings = settingsData?.smtp;
|
||||
|
||||
if (!smtpSettings || !smtpSettings.enabled) {
|
||||
console.log('SMTP not enabled or missing');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!smtpSettings.host || !smtpSettings.username) {
|
||||
console.log('SMTP configuration incomplete - missing host or username');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!smtpSettings.password) {
|
||||
console.log('SMTP password missing');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP password is required for authentication. Please configure the SMTP password.' },
|
||||
};
|
||||
}
|
||||
|
||||
// Create test email content based on template
|
||||
const template = data.template || 'basic';
|
||||
const { subject, htmlBody } = createEmailTemplate(template, data);
|
||||
|
||||
console.log('Test email prepared successfully:', {
|
||||
to: data.email,
|
||||
subject: subject,
|
||||
template: template,
|
||||
smtpHost: smtpSettings.host,
|
||||
smtpPort: smtpSettings.port || 587
|
||||
});
|
||||
|
||||
// Simulate processing time
|
||||
console.log('Simulating email send...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
console.log('Email send simulation completed');
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${data.email}`,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in sendTestEmail function:', error);
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
||||
let subject = 'Test Email from CheckCle';
|
||||
let htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb;">Test Email</h2>
|
||||
<p>This is a test email from your monitoring system.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
Sent from CheckCle Monitoring System<br>
|
||||
Template: ${template}<br>
|
||||
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
switch (template) {
|
||||
case 'verification':
|
||||
subject = 'Email Verification Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #10b981;">Email Verification Test</h2>
|
||||
<p>This is a test of the email verification template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Verification Email</p>
|
||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
case 'password-reset':
|
||||
subject = 'Password Reset Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #f59e0b;">Password Reset Test</h2>
|
||||
<p>This is a test of the password reset template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Password Reset Email</p>
|
||||
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
case 'email-change':
|
||||
subject = 'Email Change Confirmation Test - CheckCle';
|
||||
htmlBody = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
|
||||
<p>This is a test of the email change confirmation template.</p>
|
||||
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
return { subject, htmlBody };
|
||||
};
|
||||
|
||||
export const testEmail = async (data: any): Promise<SettingsApiResponse> => {
|
||||
console.log('testEmail function called with data:', data);
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!data || typeof data !== 'object') {
|
||||
console.log('Invalid request data - not object');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Invalid request data' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.email || typeof data.email !== 'string') {
|
||||
console.log('Email address missing or invalid type');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Email address is required and must be a string' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateEmail(data.email)) {
|
||||
console.log('Invalid email format:', data.email);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Invalid email address format' },
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Email validation passed for:', data.email);
|
||||
|
||||
const headers = getAuthHeaders();
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
// Get current SMTP settings first
|
||||
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
|
||||
|
||||
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!settingsResponse.ok) {
|
||||
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Failed to get SMTP settings' },
|
||||
};
|
||||
}
|
||||
|
||||
const settingsData = await settingsResponse.json();
|
||||
console.log('Retrieved settings data:', settingsData);
|
||||
|
||||
const smtpSettings = settingsData?.smtp;
|
||||
|
||||
if (!smtpSettings || !smtpSettings.enabled) {
|
||||
console.log('SMTP not enabled or missing');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!smtpSettings.host || !smtpSettings.username) {
|
||||
console.log('SMTP configuration incomplete - missing host or username');
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
|
||||
};
|
||||
}
|
||||
|
||||
// Create test email content based on template
|
||||
const template = data.template || 'basic';
|
||||
const { subject, htmlBody } = createEmailTemplate(template, data);
|
||||
|
||||
console.log('Test email prepared successfully:', {
|
||||
to: data.email,
|
||||
subject: subject,
|
||||
template: template,
|
||||
smtpHost: smtpSettings.host,
|
||||
smtpPort: smtpSettings.port || 587
|
||||
});
|
||||
|
||||
// Send actual email using the correct PocketBase API endpoint
|
||||
console.log('Sending actual email via PocketBase...');
|
||||
|
||||
// Fix the payload structure to match PocketBase API expectations
|
||||
const emailPayload = {
|
||||
email: data.email, // Use 'email' instead of 'to'
|
||||
template: template, // Add the template field
|
||||
subject: subject,
|
||||
html: htmlBody,
|
||||
};
|
||||
|
||||
console.log('Email payload:', emailPayload);
|
||||
|
||||
const emailResponse = await fetch(`${baseUrl}/api/settings/test/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(emailPayload),
|
||||
});
|
||||
|
||||
if (!emailResponse.ok) {
|
||||
console.error('Failed to send email, status:', emailResponse.status);
|
||||
const errorText = await emailResponse.text();
|
||||
console.error('Email send error response:', errorText);
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: false, message: 'Failed to send email. Please check your SMTP configuration.' },
|
||||
};
|
||||
}
|
||||
|
||||
// Handle 204 No Content response (successful but no body)
|
||||
if (emailResponse.status === 204) {
|
||||
console.log('Email sent successfully (204 No Content)');
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${data.email}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For other successful responses, try to parse JSON
|
||||
const emailResult = await emailResponse.json();
|
||||
console.log('Email sent successfully:', emailResult);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${data.email}`,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in testEmail function:', error);
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
export const testEmailConnection = async (data: any): Promise<SettingsApiResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/api/settings/test-email`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
status: 200,
|
||||
json: {
|
||||
success: result.success || false,
|
||||
message:
|
||||
result.message || (result.success ? 'Connection successful' : 'Connection failed'),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error testing email connection:', error);
|
||||
return {
|
||||
status: 500,
|
||||
json: { success: false, message: 'Failed to test email connection' },
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||
import { SettingsApiResponse } from '../types';
|
||||
|
||||
export const updateSettings = async (data: any): Promise<SettingsApiResponse> => {
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
let response = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok && (response.status === 404 || response.status === 405)) {
|
||||
response = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const updatedSettings = await response.json();
|
||||
return {
|
||||
status: 200,
|
||||
json: { success: true, data: updatedSettings },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
return {
|
||||
status: 500,
|
||||
json: { success: false, message: 'Failed to update settings' },
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
import { getSettings } from './actions/getSettings';
|
||||
import { updateSettings } from './actions/updateSettings';
|
||||
import { testEmailConnection } from './actions/testEmailConnection';
|
||||
import { testEmail } from './actions/testEmail';
|
||||
|
||||
/**
|
||||
* Settings API handler
|
||||
*/
|
||||
const settingsApi = async (body: any, path?: string) => {
|
||||
// console.log('Settings API called with path:', path, 'body:', body);
|
||||
|
||||
// Handle test email endpoint specifically
|
||||
if (path === '/api/settings/test/email') {
|
||||
console.log('Handling test email request');
|
||||
return await testEmail(body);
|
||||
}
|
||||
|
||||
// Handle regular settings API with action-based routing
|
||||
const action = body?.action;
|
||||
// console.log('Settings API called with action:', action, 'data:', body?.data);
|
||||
|
||||
switch (action) {
|
||||
case 'getSettings':
|
||||
return await getSettings();
|
||||
|
||||
case 'updateSettings':
|
||||
return await updateSettings(body.data);
|
||||
|
||||
case 'testEmailConnection':
|
||||
return await testEmailConnection(body.data);
|
||||
|
||||
default:
|
||||
console.error('Unknown action:', action);
|
||||
return {
|
||||
status: 400,
|
||||
json: { success: false, message: 'Unknown action' },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default settingsApi;
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
export interface SettingsApiRequest {
|
||||
action: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface SettingsApiResponse {
|
||||
status: number;
|
||||
json: {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SmtpSettings {
|
||||
enabled?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
authMethod?: string;
|
||||
tls?: boolean;
|
||||
localName?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
|
||||
|
||||
export const getAuthHeaders = (): Record<string, string> => {
|
||||
const authToken = pb.authStore.token;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const getBaseUrl = (): string => {
|
||||
return getCurrentEndpoint();
|
||||
};
|
||||
|
||||
export const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center p-6">
|
||||
<h2 className="text-2xl font-bold text-destructive mb-4">Something went wrong</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { Mail, ArrowLeft } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { getCurrentEndpoint } from '@/lib/pocketbase';
|
||||
|
||||
interface ForgotPasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) {
|
||||
const [step, setStep] = useState<'request' | 'confirm'>('request');
|
||||
const [email, setEmail] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleRequestReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const apiUrl = getCurrentEndpoint();
|
||||
const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to send reset email');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Reset Email Sent",
|
||||
description: "Please check your email for password reset instructions.",
|
||||
});
|
||||
setStep('confirm');
|
||||
} catch (error) {
|
||||
console.error('Password reset request error:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Reset Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Password Mismatch",
|
||||
description: "Passwords do not match. Please try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Password Too Short",
|
||||
description: "Password must be at least 6 characters long.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const apiUrl = getCurrentEndpoint();
|
||||
const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
password,
|
||||
passwordConfirm
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to reset password');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Password Reset Successful",
|
||||
description: "Your password has been reset successfully. You can now log in with your new password.",
|
||||
});
|
||||
onOpenChange(false);
|
||||
// Reset form state
|
||||
setStep('request');
|
||||
setEmail('');
|
||||
setToken('');
|
||||
setPassword('');
|
||||
setPasswordConfirm('');
|
||||
} catch (error) {
|
||||
console.error('Password reset confirmation error:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Reset Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to reset password. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
// Reset form state when closing
|
||||
setStep('request');
|
||||
setEmail('');
|
||||
setToken('');
|
||||
setPassword('');
|
||||
setPasswordConfirm('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'request'
|
||||
? 'Enter your email address and we\'ll send you a reset link.'
|
||||
: 'Enter the reset token from your email and your new password.'
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'request' ? (
|
||||
<form onSubmit={handleRequestReset} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
id="reset-email"
|
||||
placeholder="your.email@provider.com"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !email}
|
||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Reset Email'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleConfirmReset} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
|
||||
Reset Token
|
||||
</label>
|
||||
<Input
|
||||
id="reset-token"
|
||||
placeholder="Enter token from email"
|
||||
type="text"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
id="new-password"
|
||||
placeholder="••••••••••••"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
placeholder="••••••••••••"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setStep('request')}
|
||||
className="flex-1"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !token || !password || !passwordConfirm}
|
||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { authService } from '@/services/authService';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||
if (!authService.isAuthenticated()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
|
||||
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";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DashboardContentProps {
|
||||
services: Service[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
|
||||
const { t } = useLanguage();
|
||||
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()}>{t('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">{t('overview')}</h2>
|
||||
<Button
|
||||
className="text-primary-foreground"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> {t('newService')}
|
||||
</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,207 @@
|
||||
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, Menu, X
|
||||
} 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";
|
||||
import { useSidebar } from "@/contexts/SidebarContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HeaderProps {
|
||||
currentUser: AuthUser | null;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({
|
||||
currentUser,
|
||||
onLogout,
|
||||
}: HeaderProps) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
const { sidebarCollapsed, toggleSidebar, isMobileOpen, toggleMobileMenu } = useSidebar();
|
||||
const [greeting, setGreeting] = useState<string>("");
|
||||
const { systemName } = useSystemSettings();
|
||||
const navigate = useNavigate();
|
||||
|
||||
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();
|
||||
}, [language, t]);
|
||||
|
||||
const avatarUrl = currentUser?.avatar || '';
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="relative bg-background border-b border-border px-4 lg:px-6 flex justify-between items-center h-16 shrink-0 z-30 overflow-hidden">
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 pointer-events-none opacity-20">
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-2 lg:gap-4 z-10">
|
||||
{/* Mobile Toggle */}
|
||||
<Button variant="ghost" size="icon" onClick={toggleMobileMenu} className="lg:hidden h-9 w-9">
|
||||
{isMobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Desktop Toggle */}
|
||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="hidden lg:flex h-9 w-9">
|
||||
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-sm lg:text-lg font-medium truncate max-w-[150px] lg:max-w-none">
|
||||
{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 lg:space-x-3 z-10">
|
||||
{/* External Links - Hidden on small screens */}
|
||||
<div className="hidden md:flex items-center space-x-1 mr-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => openExternalLink('https://docs.checkcle.io')}
|
||||
title={t("documentation")}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => openExternalLink('https://github.com/operacle/checkcle')}
|
||||
title="GitHub"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => openExternalLink('https://x.com/checkcle_oss')}
|
||||
title="X (Twitter)"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => openExternalLink('https://discord.gg/xs9gbubGwX')}
|
||||
title="Discord"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
title={t("notifications")}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border hidden sm:flex" 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" className="w-40">
|
||||
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
|
||||
{t("english")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
|
||||
{t("khmer")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
|
||||
{t("german")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("ko")} className={language === "ko" ? "bg-accent" : ""}>
|
||||
{t("korean")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}>
|
||||
{t("japanese")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("zhcn")} className={language === "zhcn" ? "bg-accent" : ""}>
|
||||
{t("simplifiedChinese")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="h-8 w-px bg-border mx-1 hidden sm:block" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Avatar className="h-8 w-8 cursor-pointer border hover:ring-2 hover:ring-primary/20 transition-all">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[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="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
|
||||
</Avatar>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="text-sm font-medium truncate">{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,56 @@
|
||||
|
||||
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";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
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) => {
|
||||
const { t } = useLanguage();
|
||||
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">{t('currentlyMonitoring')}</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={t('allTypes')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('allTypes')}</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={t('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,44 @@
|
||||
import React from "react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { SidebarHeader } from "./sidebar/SidebarHeader";
|
||||
import { MainNavigation } from "./sidebar/MainNavigation";
|
||||
import { SettingsPanel } from "./sidebar/SettingsPanel";
|
||||
import { useSidebar } from "@/contexts/SidebarContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Sidebar = () => {
|
||||
const { theme } = useTheme();
|
||||
const { sidebarCollapsed, isMobileOpen, toggleMobileMenu } = useSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Overlay */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 lg:hidden backdrop-blur-sm transition-opacity animate-in fade-in"
|
||||
onClick={toggleMobileMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar Container */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 lg:static lg:block transition-all duration-300 ease-in-out border-r flex flex-col h-full",
|
||||
theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border',
|
||||
// Mobile state
|
||||
isMobileOpen ? "translate-x-0 w-64" : "-translate-x-full lg:translate-x-0",
|
||||
// Desktop state (collapsed/expanded)
|
||||
!isMobileOpen && sidebarCollapsed ? "lg:w-16" : "lg:w-64"
|
||||
)}
|
||||
>
|
||||
<SidebarHeader collapsed={!isMobileOpen && sidebarCollapsed} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar">
|
||||
<MainNavigation collapsed={!isMobileOpen && sidebarCollapsed} />
|
||||
</div>
|
||||
|
||||
<SettingsPanel collapsed={!isMobileOpen && sidebarCollapsed} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
|
||||
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";
|
||||
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||
|
||||
interface StatusCardsProps {
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export const StatusCards = ({ services }: StatusCardsProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
// 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(65, 59, 55, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 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(#000 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">{t("upServices")}</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(65, 59, 55, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 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(#000 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">{t("downServices")}</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(65, 59, 55, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 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(#000 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">{t("pausedServices")}</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(65, 59, 55, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 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(#000 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">{t("warningServices")}</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,27 @@
|
||||
|
||||
import React from "react";
|
||||
import { MenuItem } from "./MenuItem";
|
||||
import { mainMenuItems } from "./navigationData";
|
||||
|
||||
interface MainNavigationProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) => {
|
||||
return (
|
||||
<nav className="my-2 mx-1 py-1 px-1">
|
||||
{mainMenuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
path={item.path}
|
||||
icon={item.icon}
|
||||
translationKey={item.translationKey}
|
||||
color={item.color}
|
||||
hasNavigation={item.hasNavigation}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MenuItemProps {
|
||||
id: string;
|
||||
path: string | null;
|
||||
icon: LucideIcon;
|
||||
translationKey: string;
|
||||
color: string;
|
||||
hasNavigation: boolean;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const MenuItem: React.FC<MenuItemProps> = ({
|
||||
path,
|
||||
icon: Icon,
|
||||
translationKey,
|
||||
color,
|
||||
hasNavigation,
|
||||
collapsed
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isActive = path && location.pathname === path;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasNavigation && path) {
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center mb-1 rounded-lg cursor-pointer transition-all duration-200",
|
||||
collapsed ? "p-3 justify-center" : "p-2.5 px-4",
|
||||
isActive
|
||||
? (theme === 'dark' ? 'bg-zinc-800 text-white' : 'bg-primary/10 text-primary')
|
||||
: (theme === 'dark' ? 'text-zinc-400 hover:bg-zinc-800/50 hover:text-white' : 'text-zinc-600 hover:bg-zinc-100')
|
||||
)}
|
||||
onClick={handleClick}
|
||||
title={collapsed ? t(translationKey) : ""}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5 shrink-0 transition-colors", color, isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100")} />
|
||||
|
||||
{!collapsed && (
|
||||
<span className="ml-3 font-medium tracking-wide text-sm truncate">
|
||||
{t(translationKey)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Small Indicator for active state */}
|
||||
{isActive && !collapsed && (
|
||||
<div className="absolute left-0 w-1 h-6 bg-primary rounded-r-full" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { Settings, ChevronDown } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { settingsMenuItems } from "./navigationData";
|
||||
|
||||
interface SettingsPanelProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
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 handleMenuItemClick = (path: string, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Use navigate instead of window.location to prevent full page reload
|
||||
navigate(path, { replace: false });
|
||||
};
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
const mainIconSize = "h-6 w-6";
|
||||
return (
|
||||
<div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
|
||||
<div
|
||||
onClick={(e) => handleMenuItemClick('/settings', e)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Settings className={`${mainIconSize} text-purple-400`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
{settingsMenuItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={getMenuItemClasses(activeSettingsItem === item.id)}
|
||||
onClick={(e) => {
|
||||
handleMenuItemClick(`/settings?panel=${item.id}`, e);
|
||||
handleSettingsItemClick(item.id);
|
||||
}}
|
||||
>
|
||||
<item.icon className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t(item.translationKey)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
import React from "react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<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-gray-600 rounded flex items-center justify-center mr-2">
|
||||
<img
|
||||
src="/favicon_sidebar.ico"
|
||||
alt="CheckCle"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export { SidebarHeader } from './SidebarHeader';
|
||||
export { MainNavigation } from './MainNavigation';
|
||||
export { MenuItem } from './MenuItem';
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
export { mainMenuItems, settingsMenuItems } from './navigationData';
|
||||
@@ -0,0 +1,94 @@
|
||||
|
||||
import { Globe, Boxes, Layers, Calendar, BarChart2, LineChart, MapPin, Settings, User, Bell, Database, Info, BookOpen } from "lucide-react";
|
||||
|
||||
export const mainMenuItems = [
|
||||
{
|
||||
id: 'uptime-monitoring',
|
||||
path: '/dashboard',
|
||||
icon: Globe,
|
||||
translationKey: 'uptimeMonitoring',
|
||||
color: 'text-purple-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'instance-monitoring',
|
||||
path: '/instance-monitoring',
|
||||
icon: Boxes,
|
||||
translationKey: 'instanceMonitoring',
|
||||
color: 'text-blue-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'ssl-domain',
|
||||
path: '/ssl-domain',
|
||||
icon: Layers,
|
||||
translationKey: 'sslDomain',
|
||||
color: 'text-cyan-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'schedule-incident',
|
||||
path: '/schedule-incident',
|
||||
icon: Calendar,
|
||||
translationKey: 'scheduleIncident',
|
||||
color: 'text-emerald-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'operational-page',
|
||||
path: '/operational-page',
|
||||
icon: BarChart2,
|
||||
translationKey: 'operationalPage',
|
||||
color: 'text-amber-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'regional-monitoring',
|
||||
path: '/regional-monitoring',
|
||||
icon: MapPin,
|
||||
translationKey: 'regionalMonitoring',
|
||||
color: 'text-indigo-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
path: null,
|
||||
icon: LineChart,
|
||||
translationKey: 'reports',
|
||||
color: 'text-rose-400',
|
||||
hasNavigation: false
|
||||
}
|
||||
];
|
||||
|
||||
export const settingsMenuItems = [
|
||||
{
|
||||
id: 'general',
|
||||
icon: Settings,
|
||||
translationKey: 'generalSettings'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
icon: User,
|
||||
translationKey: 'userManagement'
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
icon: Bell,
|
||||
translationKey: 'notificationSettings'
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
icon: BookOpen,
|
||||
translationKey: 'alertsTemplates'
|
||||
},
|
||||
{
|
||||
id: 'data-retention',
|
||||
icon: Database,
|
||||
translationKey: 'dataRetention'
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
icon: Info,
|
||||
translationKey: 'aboutSystem'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,99 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody } from "@/components/ui/table";
|
||||
import { DockerContainer } from "@/types/docker.types";
|
||||
import { DockerMetricsDialog } from "./DockerMetricsDialog";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import {
|
||||
DockerTableSearch,
|
||||
DockerTableHeader,
|
||||
DockerTableRow,
|
||||
DockerEmptyState
|
||||
} from "./table";
|
||||
|
||||
interface DockerContainersTableProps {
|
||||
containers: DockerContainer[];
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => {
|
||||
const { t } = useLanguage();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null);
|
||||
const [metricsDialogOpen, setMetricsDialogOpen] = useState(false);
|
||||
|
||||
const filteredContainers = containers.filter(container =>
|
||||
container.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
container.docker_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
container.hostname.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleContainerAction = (action: string, containerId: string, containerName: string) => {
|
||||
console.log(`${action} action for container ${containerName} (${containerId})`);
|
||||
// TODO: Implement container actions
|
||||
};
|
||||
|
||||
const handleRowClick = (container: DockerContainer) => {
|
||||
setSelectedContainer(container);
|
||||
setMetricsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewMetrics = (container: DockerContainer) => {
|
||||
setSelectedContainer(container);
|
||||
setMetricsDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full bg-transparent border-0 shadow-none">
|
||||
<CardHeader className="pb-4 px-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<CardTitle className="text-lg sm:text-xl font-semibold">{t('dockerContainers', 'docker')}</CardTitle>
|
||||
<DockerTableSearch
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
onRefresh={onRefresh}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-full inline-block align-middle">
|
||||
<div className="overflow-hidden border border-border rounded-lg shadow-sm">
|
||||
<Table>
|
||||
<DockerTableHeader />
|
||||
<TableBody>
|
||||
{filteredContainers.length === 0 ? (
|
||||
<DockerEmptyState searchTerm={searchTerm} />
|
||||
) : (
|
||||
filteredContainers.map((container) => (
|
||||
<DockerTableRow
|
||||
key={container.id}
|
||||
container={container}
|
||||
onRowClick={handleRowClick}
|
||||
onContainerAction={handleContainerAction}
|
||||
onViewMetrics={handleViewMetrics}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DockerMetricsDialog
|
||||
container={selectedContainer}
|
||||
open={metricsDialogOpen}
|
||||
onOpenChange={setMetricsDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,697 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, AreaChart, Area } from "recharts";
|
||||
import { DockerContainer, DockerMetrics } from "@/types/docker.types";
|
||||
import { dockerService } from "@/services/dockerService";
|
||||
import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerMetricsDialogProps {
|
||||
container: DockerContainer | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: '60m' as TimeRange, label: 'minutes60', hours: 1 },
|
||||
{ value: '1d' as TimeRange, label: 'day1', hours: 24 },
|
||||
{ value: '7d' as TimeRange, label: 'days7', hours: 24 * 7 },
|
||||
{ value: '1m' as TimeRange, label: 'month1', hours: 24 * 30 },
|
||||
{ value: '3m' as TimeRange, label: 'months3', hours: 24 * 90 },
|
||||
];
|
||||
|
||||
export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const {
|
||||
data: metrics = [],
|
||||
isLoading,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['docker-metrics', container?.docker_id, timeRange],
|
||||
queryFn: () => container ? dockerService.getContainerMetrics(container.docker_id) : Promise.resolve([]),
|
||||
enabled: !!container && open,
|
||||
refetchInterval: 30000
|
||||
});
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const parseValueWithUnit = (value: string | number): { numeric: number; unit: string; original: string } => {
|
||||
if (typeof value === 'number') {
|
||||
return { numeric: value, unit: 'B', original: value.toString() };
|
||||
}
|
||||
|
||||
const str = value.toString();
|
||||
const match = str.match(/^([\d.]+)\s*([A-Za-z%]*)/);
|
||||
if (match) {
|
||||
const numeric = parseFloat(match[1]);
|
||||
const unit = match[2] || '';
|
||||
return { numeric, unit, original: str };
|
||||
}
|
||||
return { numeric: 0, unit: '', original: str };
|
||||
};
|
||||
|
||||
const convertToBytes = (value: string | number): number => {
|
||||
if (typeof value === 'number') return value;
|
||||
|
||||
const parsed = parseValueWithUnit(value);
|
||||
const multipliers: { [key: string]: number } = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
const multiplier = multipliers[parsed.unit.toUpperCase()] || 1;
|
||||
return parsed.numeric * multiplier;
|
||||
};
|
||||
|
||||
const filterMetricsByTimeRange = (metrics: DockerMetrics[], timeRange: TimeRange): DockerMetrics[] => {
|
||||
const now = new Date();
|
||||
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
|
||||
if (!selectedRange) return metrics;
|
||||
|
||||
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
|
||||
|
||||
return metrics.filter(metric => {
|
||||
const metricTime = new Date(metric.timestamp);
|
||||
return metricTime >= cutoffTime;
|
||||
});
|
||||
};
|
||||
|
||||
const formatChartData = (metrics: DockerMetrics[]) => {
|
||||
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
|
||||
|
||||
return filteredMetrics.slice(0, 100).reverse().map((metric, index) => {
|
||||
// Parse CPU usage
|
||||
const cpuUsage = typeof metric.cpu_usage === 'string' ?
|
||||
parseFloat(metric.cpu_usage.replace('%', '')) :
|
||||
parseFloat(metric.cpu_usage) || 0;
|
||||
|
||||
// Parse memory values
|
||||
const ramUsedBytes = convertToBytes(metric.ram_used);
|
||||
const ramTotalBytes = convertToBytes(metric.ram_total);
|
||||
const ramFreeBytes = convertToBytes(metric.ram_free);
|
||||
const ramUsagePercent = ramTotalBytes > 0 ? (ramUsedBytes / ramTotalBytes) * 100 : 0;
|
||||
|
||||
// Parse disk values
|
||||
const diskUsedBytes = convertToBytes(metric.disk_used);
|
||||
const diskTotalBytes = convertToBytes(metric.disk_total);
|
||||
const diskFreeBytes = convertToBytes(metric.disk_free);
|
||||
const diskUsagePercent = diskTotalBytes > 0 ? (diskUsedBytes / diskTotalBytes) * 100 : 0;
|
||||
|
||||
// Network values
|
||||
const networkRxBytes = metric.network_rx_bytes || 0;
|
||||
const networkTxBytes = metric.network_tx_bytes || 0;
|
||||
const networkRxSpeed = metric.network_rx_speed || 0;
|
||||
const networkTxSpeed = metric.network_tx_speed || 0;
|
||||
|
||||
return {
|
||||
timestamp: new Date(metric.timestamp).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}),
|
||||
// CPU
|
||||
cpuUsage: Math.round(cpuUsage * 100) / 100,
|
||||
cpuCores: parseInt(metric.cpu_cores) || 0,
|
||||
cpuFree: 100 - cpuUsage,
|
||||
|
||||
// Memory
|
||||
ramUsedBytes,
|
||||
ramTotalBytes,
|
||||
ramFreeBytes,
|
||||
ramUsed: formatBytes(ramUsedBytes),
|
||||
ramTotal: formatBytes(ramTotalBytes),
|
||||
ramFree: formatBytes(ramFreeBytes),
|
||||
ramUsagePercent: Math.round(ramUsagePercent * 100) / 100,
|
||||
|
||||
// Disk
|
||||
diskUsedBytes,
|
||||
diskTotalBytes,
|
||||
diskFreeBytes,
|
||||
diskUsed: formatBytes(diskUsedBytes),
|
||||
diskTotal: formatBytes(diskTotalBytes),
|
||||
diskFree: formatBytes(diskFreeBytes),
|
||||
diskUsagePercent: Math.round(diskUsagePercent * 100) / 100,
|
||||
|
||||
// Network
|
||||
networkRxBytes,
|
||||
networkTxBytes,
|
||||
networkRx: formatBytes(networkRxBytes),
|
||||
networkTx: formatBytes(networkTxBytes),
|
||||
networkRxSpeed: Math.round(networkRxSpeed * 100) / 100,
|
||||
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const chartData = formatChartData(metrics);
|
||||
const latestMetric = chartData[chartData.length - 1];
|
||||
|
||||
const chartConfig = {
|
||||
cpuUsage: {
|
||||
label: "CPU Usage (%)",
|
||||
color: theme === 'dark' ? "#3b82f6" : "#2563eb",
|
||||
},
|
||||
ramUsagePercent: {
|
||||
label: "RAM Usage (%)",
|
||||
color: theme === 'dark' ? "#10b981" : "#059669",
|
||||
},
|
||||
diskUsagePercent: {
|
||||
label: "Disk Usage (%)",
|
||||
color: theme === 'dark' ? "#f59e0b" : "#d97706",
|
||||
},
|
||||
networkRx: {
|
||||
label: "Network RX",
|
||||
color: theme === 'dark' ? "#8b5cf6" : "#7c3aed",
|
||||
},
|
||||
networkTx: {
|
||||
label: "Network TX",
|
||||
color: theme === 'dark' ? "#ef4444" : "#dc2626",
|
||||
},
|
||||
};
|
||||
|
||||
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
|
||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||
|
||||
const MetricCard = ({ title, used, total, free, percentage, icon: Icon, color }: {
|
||||
title: string;
|
||||
used: string;
|
||||
total: string;
|
||||
free: string;
|
||||
percentage: number;
|
||||
icon: any;
|
||||
color: string;
|
||||
}) => (
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" style={{ color }} />
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Used:</span>
|
||||
<span className="font-mono">{used}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Free:</span>
|
||||
<span className="font-mono">{free}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total:</span>
|
||||
<span className="font-mono">{total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Usage:</span>
|
||||
<span className="font-mono">{percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-7xl max-h-[95vh] overflow-y-auto bg-background text-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Cpu className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
{t('containerMetricsTitle', 'docker', { name: container.name })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timeRangeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.label, 'docker')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dockerId', 'docker')}: {container.docker_id} • {container.hostname}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">{t('loadingMetrics', 'docker')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||
<p>{t('errorLoadingMetrics', 'docker')}: {String((error as any)?.message ?? '')}</p>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||
<p>{t('noMetricsAvailable', 'docker')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Current Metrics Summary */}
|
||||
{latestMetric && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<MetricCard
|
||||
title={t('cpu', 'docker')}
|
||||
used={`${latestMetric.cpuUsage}%`}
|
||||
total={`${latestMetric.cpuCores} cores`}
|
||||
free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`}
|
||||
percentage={latestMetric.cpuUsage}
|
||||
icon={Cpu}
|
||||
color={chartConfig.cpuUsage.color}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('memoryTitle', 'docker')}
|
||||
used={latestMetric.ramUsed}
|
||||
total={latestMetric.ramTotal}
|
||||
free={latestMetric.ramFree}
|
||||
percentage={latestMetric.ramUsagePercent}
|
||||
icon={MemoryStick}
|
||||
color={chartConfig.ramUsagePercent.color}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('diskTitle', 'docker')}
|
||||
used={latestMetric.diskUsed}
|
||||
total={latestMetric.diskTotal}
|
||||
free={latestMetric.diskFree}
|
||||
percentage={latestMetric.diskUsagePercent}
|
||||
icon={HardDrive}
|
||||
color={chartConfig.diskUsagePercent.color}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('network', 'docker')}
|
||||
used={`RX: ${latestMetric.networkRx}`}
|
||||
total={`TX: ${latestMetric.networkTx}`}
|
||||
free={`${t('networkSpeedKbs', 'docker').replace('(KB/s)', '')}: ${latestMetric.networkRxSpeed} KB/s`}
|
||||
percentage={0}
|
||||
icon={Network}
|
||||
color={chartConfig.networkRx.color}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="cpu" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 bg-muted">
|
||||
<TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t('cpu', 'docker')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<MemoryStick className="h-4 w-4" />
|
||||
{t('memoryTitle', 'docker')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
{t('diskTitle', 'docker')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<Network className="h-4 w-4" />
|
||||
{t('network', 'docker')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="cpu" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('cpuUsagePct', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cpuUsage"
|
||||
stroke={chartConfig.cpuUsage.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: chartConfig.cpuUsage.color }}
|
||||
name={t('cpuUsagePct', 'docker')}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('cpuUsageVsAvailable', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpuFree"
|
||||
stackId="1"
|
||||
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fillOpacity={0.3}
|
||||
name={t('cpuAvailablePct', 'docker')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpuUsage"
|
||||
stackId="1"
|
||||
stroke={chartConfig.cpuUsage.color}
|
||||
fill={chartConfig.cpuUsage.color}
|
||||
fillOpacity={0.6}
|
||||
name={t('cpuUsagePct', 'docker')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="memory" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('ramUsagePct', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ramUsagePercent"
|
||||
stackId="1"
|
||||
stroke={chartConfig.ramUsagePercent.color}
|
||||
fill={chartConfig.ramUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name="RAM Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('memoryUsageBytes', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
formatter={(value, name) => [
|
||||
name === t('usedMemory', 'docker') ? formatBytes(Number(value)) :
|
||||
name === t('totalMemory', 'docker') ? formatBytes(Number(value)) : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ramTotalBytes"
|
||||
stackId="1"
|
||||
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fillOpacity={0.3}
|
||||
name={t('totalMemory', 'docker')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ramUsedBytes"
|
||||
stackId="1"
|
||||
stroke={chartConfig.ramUsagePercent.color}
|
||||
fill={chartConfig.ramUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name={t('usedMemory', 'docker')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disk" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('diskUsagePct', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskUsagePercent"
|
||||
stackId="1"
|
||||
stroke={chartConfig.diskUsagePercent.color}
|
||||
fill={chartConfig.diskUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name={t('diskUsagePct', 'docker')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('diskUsageBytes', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
formatter={(value, name) => [
|
||||
name === t('usedDisk', 'docker') ? formatBytes(Number(value)) :
|
||||
name === t('totalDisk', 'docker') ? formatBytes(Number(value)) : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskTotalBytes"
|
||||
stackId="1"
|
||||
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fillOpacity={0.3}
|
||||
name={t('totalDisk', 'docker')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskUsedBytes"
|
||||
stackId="1"
|
||||
stroke={chartConfig.diskUsagePercent.color}
|
||||
fill={chartConfig.diskUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name={t('usedDisk', 'docker')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('networkTraffic', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-64">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkRxBytes"
|
||||
stroke={chartConfig.networkRx.color}
|
||||
strokeWidth={2}
|
||||
name={t('rxBytes', 'docker')}
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkTxBytes"
|
||||
stroke={chartConfig.networkTx.color}
|
||||
strokeWidth={2}
|
||||
name={t('txBytes', 'docker')}
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('networkSpeedKbs', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-64">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkRxSpeed"
|
||||
stroke={chartConfig.networkRx.color}
|
||||
strokeWidth={2}
|
||||
name={t('rxSpeedKbs', 'docker')}
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkTxSpeed"
|
||||
stroke={chartConfig.networkTx.color}
|
||||
strokeWidth={2}
|
||||
name={t('txSpeedKbs', 'docker')}
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Container, Play, Square, AlertTriangle } from "lucide-react";
|
||||
import { DockerStats } from "@/types/docker.types";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerStatsCardsProps {
|
||||
stats: DockerStats;
|
||||
}
|
||||
|
||||
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('totalContainers', 'docker'),
|
||||
value: stats.total,
|
||||
icon: Container,
|
||||
color: "text-blue-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(59, 130, 246, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
|
||||
},
|
||||
{
|
||||
title: t('running', 'docker'),
|
||||
value: stats.running,
|
||||
icon: Play,
|
||||
color: "text-green-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(16, 185, 129, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
|
||||
},
|
||||
{
|
||||
title: t('stopped', 'docker'),
|
||||
value: stats.stopped,
|
||||
icon: Square,
|
||||
color: "text-gray-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(107, 114, 128, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
|
||||
},
|
||||
{
|
||||
title: t('warning', 'docker'),
|
||||
value: stats.warning,
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(245, 158, 11, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #f59e0b 100%)"
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||
{cards.map((card) => {
|
||||
const IconComponent = card.icon;
|
||||
return (
|
||||
<Card
|
||||
key={card.title}
|
||||
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
|
||||
style={{ background: card.gradient }}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white/70">
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
|
||||
<IconComponent className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{card.value}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30"
|
||||
>
|
||||
{t('containersLabel', 'docker')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerStatusBadgeProps {
|
||||
status: 'running' | 'stopped' | 'warning';
|
||||
}
|
||||
|
||||
export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => {
|
||||
const { t } = useLanguage();
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200',
|
||||
label: t('running', 'docker')
|
||||
};
|
||||
case 'stopped':
|
||||
return {
|
||||
variant: 'secondary' as const,
|
||||
className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
|
||||
label: t('stopped', 'docker')
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
variant: 'destructive' as const,
|
||||
className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200',
|
||||
label: t('warning', 'docker')
|
||||
};
|
||||
default:
|
||||
return {
|
||||
variant: 'outline' as const,
|
||||
className: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
label: t('unknown', 'docker')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={config.variant}
|
||||
className={`${config.className} font-medium text-xs px-2 py-1`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerEmptyStateProps {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-lg font-medium">
|
||||
{searchTerm ? t('noContainersFound', 'docker') : t('noContainersRunning', 'docker')}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{searchTerm ? t('tryAdjustSearch', 'docker') : t('startSomeContainers', 'docker')}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react";
|
||||
import { DockerContainer } from "@/types/docker.types";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerRowActionsProps {
|
||||
container: DockerContainer;
|
||||
containerStatus: 'running' | 'stopped' | 'warning';
|
||||
onContainerAction: (action: string, containerId: string, containerName: string) => void;
|
||||
onViewMetrics: (container: DockerContainer) => void;
|
||||
}
|
||||
|
||||
export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted">
|
||||
<span className="sr-only">{t('openMenu', 'docker')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-popover border-border shadow-md">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onViewMetrics(container)}
|
||||
className="cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
{t('viewMetrics', 'docker')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onContainerAction('view-detail', container.id, container.name)}
|
||||
className="cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t('viewDetails', 'docker')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
export const DockerTableHeader = () => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<TableHeader>
|
||||
<TableRow className="border-border bg-muted/30">
|
||||
<TableHead className="min-w-[200px] font-semibold">{t('container', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[100px] font-semibold">{t('status', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[140px] font-semibold">{t('cpuUsage', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[160px] font-semibold">{t('memory', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[160px] font-semibold">{t('disk', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[100px] font-semibold">{t('uptime', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[160px] font-semibold">{t('lastChecked', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center font-semibold">{t('actions', 'docker')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { DockerContainer } from "@/types/docker.types";
|
||||
import { DockerStatusBadge } from "../DockerStatusBadge";
|
||||
import { DockerRowActions } from "./DockerRowActions";
|
||||
import { dockerService } from "@/services/dockerService";
|
||||
|
||||
interface DockerTableRowProps {
|
||||
container: DockerContainer;
|
||||
onRowClick: (container: DockerContainer) => void;
|
||||
onContainerAction: (action: string, containerId: string, containerName: string) => void;
|
||||
onViewMetrics: (container: DockerContainer) => void;
|
||||
}
|
||||
|
||||
export const DockerTableRow = ({ container, onRowClick, onContainerAction, onViewMetrics }: DockerTableRowProps) => {
|
||||
const cpuPercentage = container.cpu_usage;
|
||||
const memoryPercentage = Math.round((container.ram_used / container.ram_total) * 100);
|
||||
const diskPercentage = Math.round((container.disk_used / container.disk_total) * 100);
|
||||
const containerStatus = dockerService.getStatusFromDockerStatus(container.status);
|
||||
|
||||
const formatPercentage = (used: number, total: number) => {
|
||||
if (total === 0) return "0%";
|
||||
return `${Math.round((used / total) * 100)}%`;
|
||||
};
|
||||
|
||||
const getUsageColor = (percentage: number) => {
|
||||
if (percentage >= 90) return "text-red-500";
|
||||
if (percentage >= 70) return "text-amber-500";
|
||||
return "text-emerald-500";
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage >= 90) return "bg-red-500";
|
||||
if (percentage >= 70) return "bg-amber-500";
|
||||
return "bg-emerald-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className="hover:bg-muted/50 transition-colors border-border cursor-pointer"
|
||||
onClick={() => onRowClick(container)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-sm sm:text-base text-foreground">{container.name}</div>
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">
|
||||
<div className="font-mono">{container.docker_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DockerStatusBadge status={containerStatus} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Progress
|
||||
value={cpuPercentage}
|
||||
className="flex-1 h-2 bg-muted/50"
|
||||
indicatorClassName={getProgressColor(cpuPercentage)}
|
||||
/>
|
||||
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(cpuPercentage)}`}>
|
||||
{cpuPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Progress
|
||||
value={memoryPercentage}
|
||||
className="flex-1 h-2 bg-muted/50"
|
||||
indicatorClassName={getProgressColor(memoryPercentage)}
|
||||
/>
|
||||
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(memoryPercentage)}`}>
|
||||
{formatPercentage(container.ram_used, container.ram_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{dockerService.formatBytes(container.ram_used)} / {dockerService.formatBytes(container.ram_total)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Progress
|
||||
value={diskPercentage}
|
||||
className="flex-1 h-2 bg-muted/50"
|
||||
indicatorClassName={getProgressColor(diskPercentage)}
|
||||
/>
|
||||
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(diskPercentage)}`}>
|
||||
{formatPercentage(container.disk_used, container.disk_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{dockerService.formatBytes(container.disk_used)} / {dockerService.formatBytes(container.disk_total)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs sm:text-sm font-medium font-mono">
|
||||
{dockerService.formatUptime(container.uptime)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground">
|
||||
{new Date(container.last_checked).toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<DockerRowActions
|
||||
container={container}
|
||||
containerStatus={containerStatus}
|
||||
onContainerAction={onContainerAction}
|
||||
onViewMetrics={onViewMetrics}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw } from "lucide-react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerTableSearchProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 sm:flex-initial">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t('searchContainersPlaceholder', 'docker')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10 sm:w-64 bg-background border-border"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto bg-background border-border hover:bg-muted"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<span className="sm:inline">{t('refresh', 'docker')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export { DockerTableSearch } from './DockerTableSearch';
|
||||
export { DockerTableHeader } from './DockerTableHeader';
|
||||
export { DockerTableRow } from './DockerTableRow';
|
||||
export { DockerRowActions } from './DockerRowActions';
|
||||
export { DockerEmptyState } from './DockerEmptyState';
|
||||
@@ -0,0 +1,209 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, X, Server } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { serviceService } from '@/services/serviceService';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface ComponentsSelectorProps {
|
||||
selectedComponents: Partial<StatusPageComponentRecord>[];
|
||||
onComponentsChange: (components: Partial<StatusPageComponentRecord>[]) => void;
|
||||
onComponentDelete?: (componentId: string) => void;
|
||||
}
|
||||
|
||||
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
|
||||
const { t } = useLanguage();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newComponent, setNewComponent] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
service_id: '',
|
||||
server_id: '',
|
||||
display_order: selectedComponents.length + 1,
|
||||
});
|
||||
|
||||
// Fetch uptime services for the dropdown
|
||||
const { data: services = [] } = useQuery({
|
||||
queryKey: ['services'],
|
||||
queryFn: serviceService.getServices,
|
||||
});
|
||||
|
||||
const addComponent = () => {
|
||||
if (!newComponent.name.trim()) return;
|
||||
|
||||
const component: Partial<StatusPageComponentRecord> = {
|
||||
...newComponent,
|
||||
operational_status_id: '', // Will be set when page is created
|
||||
};
|
||||
|
||||
onComponentsChange([...selectedComponents, component]);
|
||||
setNewComponent({
|
||||
name: '',
|
||||
description: '',
|
||||
service_id: '',
|
||||
server_id: '',
|
||||
display_order: selectedComponents.length + 2,
|
||||
});
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const removeComponent = async (index: number) => {
|
||||
const component = selectedComponents[index];
|
||||
|
||||
// If component has an ID, it exists in database and needs to be deleted
|
||||
if (component.id && onComponentDelete) {
|
||||
await onComponentDelete(component.id);
|
||||
} else {
|
||||
// For new components not yet saved, just remove from local state
|
||||
const updated = selectedComponents.filter((_, i) => i !== index);
|
||||
onComponentsChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
{t('statusPageComponents')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('addMonitoringComponentsDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedComponents.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('selectedComponents')}</Label>
|
||||
<div className="space-y-2">
|
||||
{selectedComponents.map((component, index) => (
|
||||
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{component.name}</div>
|
||||
{component.description && (
|
||||
<div className="text-sm text-muted-foreground">{component.description}</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-1">
|
||||
{component.service_id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t('service')}: {services.find(s => s.id === component.service_id)?.name || component.service_id}
|
||||
</Badge>
|
||||
)}
|
||||
{component.server_id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t('server')}: {component.server_id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeComponent(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAddForm ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('addComponent')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="component-name">{t('componentName')}</Label>
|
||||
<Input
|
||||
id="component-name"
|
||||
placeholder={t('componentNamePlaceholder')}
|
||||
value={newComponent.name}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="display-order">{t('displayOrder')}</Label>
|
||||
<Input
|
||||
id="display-order"
|
||||
type="number"
|
||||
value={newComponent.display_order}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, display_order: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="component-description">{t('descriptionOptional')}</Label>
|
||||
<Textarea
|
||||
id="component-description"
|
||||
placeholder={t('descriptionPlaceholder')}
|
||||
value={newComponent.description}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="service-id">{t('uptimeServiceOptional')}</Label>
|
||||
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectUptimeService')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
service.status === 'up' ? 'bg-green-500' :
|
||||
service.status === 'down' ? 'bg-red-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
{service.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="server-id">{t('serverIdOptional')}</Label>
|
||||
<Input
|
||||
id="server-id"
|
||||
placeholder={t('serverIdPlaceholder')}
|
||||
value={newComponent.server_id}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
|
||||
{t('addComponent')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowAddForm(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCreateOperationalPage } from '@/hooks/useOperationalPage';
|
||||
import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||
import { ComponentsSelector } from './ComponentsSelector';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
theme: z.string().min(1, 'Theme is required'),
|
||||
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||
is_public: z.boolean(),
|
||||
logo_url: z.string().optional(),
|
||||
custom_domain: z.string().optional(),
|
||||
custom_css: z.string().optional(),
|
||||
page_style: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const CreateOperationalPageDialog = () => {
|
||||
const { t } = useLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||
const createMutation = useCreateOperationalPage();
|
||||
const createComponentMutation = useCreateStatusPageComponent();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
slug: '',
|
||||
theme: 'default',
|
||||
status: 'operational',
|
||||
is_public: true,
|
||||
logo_url: '',
|
||||
custom_domain: '',
|
||||
custom_css: '',
|
||||
page_style: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const payload = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
slug: data.slug,
|
||||
theme: data.theme,
|
||||
status: data.status,
|
||||
is_public: data.is_public ? 'true' : 'false',
|
||||
logo_url: data.logo_url || '',
|
||||
custom_domain: data.custom_domain || '',
|
||||
custom_css: data.custom_css || '',
|
||||
page_style: data.page_style || '',
|
||||
};
|
||||
|
||||
// console.log('Creating operational page with payload:', payload);
|
||||
const createdPage = await createMutation.mutateAsync(payload);
|
||||
// console.log('Created page:', createdPage);
|
||||
|
||||
// Create components after page is created
|
||||
if (selectedComponents.length > 0) {
|
||||
// console.log('Creating components for page:', createdPage.id);
|
||||
for (const component of selectedComponents) {
|
||||
const componentPayload = {
|
||||
operational_status_id: createdPage.id,
|
||||
name: component.name || '',
|
||||
description: component.description || '',
|
||||
service_id: component.service_id || '',
|
||||
server_id: component.server_id || '',
|
||||
display_order: component.display_order || 1,
|
||||
};
|
||||
|
||||
// console.log('Creating component with payload:', componentPayload);
|
||||
await createComponentMutation.mutateAsync(componentPayload);
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
setSelectedComponents([]);
|
||||
} catch (error) {
|
||||
// console.error('Error creating operational page:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('createPage')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('createOperationalPage')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('createOperationalPageDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('title')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('operationalPageDescriptionPlaceholder')}
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('theme')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectTheme')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t('themeDefault')}</SelectItem>
|
||||
<SelectItem value="dark">{t('themeDark')}</SelectItem>
|
||||
<SelectItem value="light">{t('themeLight')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('initialStatus')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectStatus')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="operational">{t('statusOperational')}</SelectItem>
|
||||
<SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
|
||||
<SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
|
||||
<SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_public"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t('publicPage')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('makePagePublic')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('customDomainOptional')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('customDomainPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('customDomainDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ComponentsSelector
|
||||
selectedComponents={selectedComponents}
|
||||
onComponentsChange={setSelectedComponents}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || createComponentMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending || createComponentMutation.isPending ? t('creating') : t('createPage')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useUpdateOperationalPage } from '@/hooks/useOperationalPage';
|
||||
import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, useDeleteStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||
import { ComponentsSelector } from './ComponentsSelector';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
theme: z.string().min(1, 'Theme is required'),
|
||||
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||
is_public: z.boolean(),
|
||||
logo_url: z.string().optional(),
|
||||
custom_domain: z.string().optional(),
|
||||
custom_css: z.string().optional(),
|
||||
page_style: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
interface EditOperationalPageDialogProps {
|
||||
page: OperationalPageRecord | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
|
||||
const { t } = useLanguage();
|
||||
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
|
||||
const updateMutation = useUpdateOperationalPage();
|
||||
const createComponentMutation = useCreateStatusPageComponent();
|
||||
const deleteComponentMutation = useDeleteStatusPageComponent();
|
||||
|
||||
// Fetch existing components for this operational page
|
||||
const { data: components = [] } = useStatusPageComponentsByOperationalId(page?.id || '');
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
slug: '',
|
||||
theme: 'default',
|
||||
status: 'operational',
|
||||
is_public: true,
|
||||
logo_url: '',
|
||||
custom_domain: '',
|
||||
custom_css: '',
|
||||
page_style: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize the form reset values to prevent unnecessary re-renders
|
||||
const formValues = useMemo(() => {
|
||||
if (!page) return null;
|
||||
return {
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
slug: page.slug,
|
||||
theme: page.theme,
|
||||
status: page.status,
|
||||
is_public: page.is_public === 'true',
|
||||
logo_url: page.logo_url || '',
|
||||
custom_domain: page.custom_domain || '',
|
||||
custom_css: page.custom_css || '',
|
||||
page_style: page.page_style || '',
|
||||
};
|
||||
}, [page?.id, page?.title, page?.description, page?.slug, page?.theme, page?.status, page?.is_public, page?.logo_url, page?.custom_domain, page?.custom_css, page?.page_style]);
|
||||
|
||||
// Reset form when page data changes
|
||||
useEffect(() => {
|
||||
if (formValues) {
|
||||
form.reset(formValues);
|
||||
}
|
||||
}, [formValues, form]);
|
||||
|
||||
// Convert components to selector format and initialize state - only when dialog opens and components change
|
||||
useEffect(() => {
|
||||
if (!open || !page?.id || !components) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if components actually changed or haven't been loaded yet
|
||||
const componentIds = components.map(c => c.id).sort().join(',');
|
||||
const currentSelectedIds = selectedComponents.map(c => c.id).filter(Boolean).sort().join(',');
|
||||
|
||||
if (componentIds !== currentSelectedIds || !componentsLoaded) {
|
||||
// console.log('Loading existing components:', components);
|
||||
const existingComponentsForSelector = components.map(comp => ({
|
||||
id: comp.id,
|
||||
name: comp.name,
|
||||
description: comp.description,
|
||||
service_id: comp.service_id,
|
||||
server_id: comp.server_id,
|
||||
display_order: comp.display_order,
|
||||
operational_status_id: comp.operational_status_id,
|
||||
}));
|
||||
|
||||
setSelectedComponents(existingComponentsForSelector);
|
||||
setComponentsLoaded(true);
|
||||
}
|
||||
}, [open, page?.id, components, componentsLoaded, selectedComponents]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setComponentsLoaded(false);
|
||||
setSelectedComponents([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleComponentDelete = useCallback(async (componentId: string) => {
|
||||
try {
|
||||
// console.log('Deleting component:', componentId);
|
||||
await deleteComponentMutation.mutateAsync(componentId);
|
||||
|
||||
// Update local state to remove the deleted component
|
||||
setSelectedComponents(prev => prev.filter(comp => comp.id !== componentId));
|
||||
} catch (error) {
|
||||
// console.error('Error deleting component:', error);
|
||||
}
|
||||
}, [deleteComponentMutation]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
if (!page) return;
|
||||
|
||||
try {
|
||||
setIsFormSubmitting(true);
|
||||
|
||||
const payload = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
slug: data.slug,
|
||||
theme: data.theme,
|
||||
status: data.status,
|
||||
is_public: data.is_public ? 'true' : 'false',
|
||||
logo_url: data.logo_url || '',
|
||||
custom_domain: data.custom_domain || '',
|
||||
custom_css: data.custom_css || '',
|
||||
page_style: data.page_style || '',
|
||||
};
|
||||
|
||||
// console.log('Updating operational page with payload:', payload);
|
||||
await updateMutation.mutateAsync({ id: page.id, data: payload });
|
||||
|
||||
// Handle component changes
|
||||
const currentComponentIds = components.map(c => c.id);
|
||||
const newComponentsToCreate = selectedComponents.filter(comp => !comp.id);
|
||||
const componentsToDelete = components.filter(comp => !selectedComponents.some(selected => selected.id === comp.id));
|
||||
|
||||
// Delete removed components
|
||||
for (const component of componentsToDelete) {
|
||||
// console.log('Deleting component during save:', component.id);
|
||||
await deleteComponentMutation.mutateAsync(component.id);
|
||||
}
|
||||
|
||||
// Create new components
|
||||
for (const component of newComponentsToCreate) {
|
||||
const componentPayload = {
|
||||
operational_status_id: page.id,
|
||||
name: component.name || '',
|
||||
description: component.description || '',
|
||||
service_id: component.service_id || '',
|
||||
server_id: component.server_id || '',
|
||||
display_order: component.display_order || 1,
|
||||
};
|
||||
|
||||
// console.log('Creating component with payload:', componentPayload);
|
||||
await createComponentMutation.mutateAsync(componentPayload);
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
// console.error('Error updating operational page:', error);
|
||||
} finally {
|
||||
setIsFormSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('editOperationalPage')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('updateYourOperationalPage')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('title')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('operationalPageDescriptionPlaceholder')}
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('theme')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectTheme')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t('themeDefault')}</SelectItem>
|
||||
<SelectItem value="dark">{t('themeDark')}</SelectItem>
|
||||
<SelectItem value="light">{t('themeLight')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('status')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectStatus')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="operational">{t('statusOperational')}</SelectItem>
|
||||
<SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
|
||||
<SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
|
||||
<SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_public"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t('publicPage')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('makePagePublic')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('customDomainOptional')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('customDomainPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('customDomainDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ComponentsSelector
|
||||
selectedComponents={selectedComponents}
|
||||
onComponentsChange={setSelectedComponents}
|
||||
onComponentDelete={handleComponentDelete}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
|
||||
>
|
||||
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? t('updating') : t('updatePage')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface OperationalPageCardProps {
|
||||
page: OperationalPageRecord;
|
||||
onEdit?: (page: OperationalPageRecord) => void;
|
||||
onView?: (page: OperationalPageRecord) => void;
|
||||
onDelete?: (page: OperationalPageRecord) => void;
|
||||
}
|
||||
|
||||
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-lg transition-shadow duration-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg font-semibold mb-1">{page.title}</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{page.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<StatusBadge status={page.status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">{t('slug')}:</span>
|
||||
<p className="mt-1">{page.slug}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">{t('theme')}:</span>
|
||||
<p className="mt-1 capitalize">{page.theme}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">{t('public')}:</span>
|
||||
<div className="mt-1">
|
||||
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
|
||||
{page.is_public === 'true' ? t('yes') : t('no')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">{t('updated')}:</span>
|
||||
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{page.custom_domain && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{page.custom_domain}</span>
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{onView && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onView(page)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
{t('view')}
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(page)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('edit')}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(page)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOperationalPages, useDeleteOperationalPage } from '@/hooks/useOperationalPage';
|
||||
import { CreateOperationalPageDialog } from './CreateOperationalPageDialog';
|
||||
import { EditOperationalPageDialog } from './EditOperationalPageDialog';
|
||||
import { OperationalPageCard } from './OperationalPageCard';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { Activity, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export const OperationalPageContent = () => {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
|
||||
const deleteMutation = useDeleteOperationalPage();
|
||||
|
||||
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [pageToDelete, setPageToDelete] = useState<OperationalPageRecord | null>(null);
|
||||
|
||||
const handleEdit = (page: OperationalPageRecord) => {
|
||||
setEditingPage(page);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleView = (page: OperationalPageRecord) => {
|
||||
if (page.custom_domain) {
|
||||
window.open(`https://${page.custom_domain}`, '_blank');
|
||||
} else {
|
||||
// Navigate to the public status page route using the correct format
|
||||
window.open(`/public/${page.slug}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (page: OperationalPageRecord) => {
|
||||
setPageToDelete(page);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (pageToDelete) {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(pageToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setPageToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting page:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('failedToLoadOperationalPages')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('loadingoperationalPages')}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('tryagain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2"> {t('operationalPages')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('describeOperation')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 sm:mt-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<CreateOperationalPageDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-6 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Skeleton className="h-8 flex-1" />
|
||||
<Skeleton className="h-8 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && (!pages || pages.length === 0) && (
|
||||
<Card className="p-12">
|
||||
<CardContent className="text-center">
|
||||
<div className="mb-4">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('noOperationalPagesFound')}</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t('createYourFirstOperationalPage')}
|
||||
</p>
|
||||
<CreateOperationalPageDialog />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pages Grid */}
|
||||
{!isLoading && pages && pages.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pages.map((page) => (
|
||||
<OperationalPageCard
|
||||
key={page.id}
|
||||
page={page}
|
||||
onEdit={handleEdit}
|
||||
onView={handleView}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Footer */}
|
||||
{!isLoading && pages && pages.length > 0 && (
|
||||
<div className="mt-8 pt-6 border-t">
|
||||
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">{t('totalPages')}:</span> {pages.length}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{t('totalPages')}:</span>{' '}
|
||||
{pages.filter(p => p.is_public === 'true').length}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{t('operational')}:</span>{' '}
|
||||
{pages.filter(p => p.status === 'operational').length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<EditOperationalPageDialog
|
||||
page={editingPage}
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteOperationalPage')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteOperationalPageConfirm').replace('{title}', pageToDelete?.title ?? '')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? t('deleting') : t('delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: OperationalPageRecord['status'];
|
||||
}
|
||||
|
||||
export const StatusBadge = ({ status }: StatusBadgeProps) => {
|
||||
const getStatusConfig = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return {
|
||||
label: 'Operational',
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-200',
|
||||
};
|
||||
case 'degraded':
|
||||
return {
|
||||
label: 'Degraded Performance',
|
||||
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
|
||||
};
|
||||
case 'maintenance':
|
||||
return {
|
||||
label: 'Under Maintenance',
|
||||
className: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
|
||||
};
|
||||
case 'major_outage':
|
||||
return {
|
||||
label: 'Major Outage',
|
||||
className: 'bg-red-100 text-red-800 hover:bg-red-200',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Unknown',
|
||||
className: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<Badge className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
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";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// 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 navigate = useNavigate();
|
||||
|
||||
const form = useForm<PasswordFormValues>({
|
||||
resolver: zodResolver(passwordFormSchema),
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Function to determine which collection the user belongs to
|
||||
const getUserCollection = async (userId: string): Promise<string> => {
|
||||
try {
|
||||
// First try to find the user in the regular users collection
|
||||
await pb.collection('users').getOne(userId);
|
||||
return 'users';
|
||||
} catch (error) {
|
||||
try {
|
||||
// If not found, try the superadmin collection
|
||||
await pb.collection('_superusers').getOne(userId);
|
||||
return '_superusers';
|
||||
} catch (error) {
|
||||
throw new Error('User not found in any collection');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function onSubmit(data: PasswordFormValues) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
console.log("Starting password change for user:", userId);
|
||||
|
||||
// Determine which collection the user belongs to
|
||||
const collection = await getUserCollection(userId);
|
||||
console.log("User found in collection:", collection);
|
||||
|
||||
// PocketBase requires the old password along with the new one
|
||||
await pb.collection(collection).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. You will be logged out in 3 seconds for security.",
|
||||
});
|
||||
|
||||
// Reset the form
|
||||
form.reset();
|
||||
|
||||
// Auto logout after successful password change
|
||||
setTimeout(() => {
|
||||
console.log("Auto logout after password change");
|
||||
authService.logout();
|
||||
navigate("/login");
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Password change error:", error);
|
||||
|
||||
let errorMessage = "Failed to update password. Please try again.";
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Failed to authenticate")) {
|
||||
errorMessage = "Current password is incorrect. Please try again.";
|
||||
} else if (error.message.includes("User not found")) {
|
||||
errorMessage = "User account not found. Please contact your administrator.";
|
||||
} else {
|
||||
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,82 @@
|
||||
|
||||
import { useState, useEffect } 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;
|
||||
onUserUpdated?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ProfileContent({ currentUser, onUserUpdated }: ProfileContentProps) {
|
||||
const [activeTab, setActiveTab] = useState("details");
|
||||
|
||||
// When active tab changes, refresh user data if needed
|
||||
useEffect(() => {
|
||||
if (activeTab === "details" && onUserUpdated) {
|
||||
onUserUpdated();
|
||||
}
|
||||
}, [activeTab, onUserUpdated]);
|
||||
|
||||
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,195 @@
|
||||
|
||||
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";
|
||||
import { AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// 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 [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [updateSuccess, setUpdateSuccess] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 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);
|
||||
setUpdateError(null);
|
||||
setUpdateSuccess(null);
|
||||
|
||||
try {
|
||||
console.log("Submitting profile update with data:", data);
|
||||
|
||||
// Detect if email is being changed
|
||||
const isEmailChanged = data.email !== user.email;
|
||||
|
||||
// Create update payload with all fields
|
||||
const updateData = {
|
||||
full_name: data.full_name,
|
||||
username: data.username,
|
||||
// Only include email if it's changed
|
||||
email: isEmailChanged ? data.email : undefined,
|
||||
// Always set emailVisibility to true if email is changing
|
||||
emailVisibility: isEmailChanged ? true : undefined
|
||||
};
|
||||
|
||||
console.log("Sending update payload:", updateData);
|
||||
|
||||
// Update user data using the userService
|
||||
await userService.updateUser(user.id, updateData);
|
||||
|
||||
// If email was changed, show success message and auto-logout
|
||||
if (isEmailChanged) {
|
||||
setUpdateSuccess("Email changed successfully! You will be logged out for security reasons. Please log in again with your new email.");
|
||||
|
||||
toast({
|
||||
title: "Email changed successfully",
|
||||
description: "You will be logged out for security reasons. Please log in again with your new email.",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// Auto-logout after 3 seconds
|
||||
setTimeout(() => {
|
||||
authService.logout();
|
||||
navigate("/login");
|
||||
}, 3000);
|
||||
} else {
|
||||
// Refresh user data in auth context for other field changes
|
||||
await authService.refreshUserData();
|
||||
|
||||
setUpdateSuccess("Your profile information has been updated successfully.");
|
||||
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;
|
||||
}
|
||||
|
||||
setUpdateError(errorMessage);
|
||||
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{updateError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{updateError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{updateSuccess && (
|
||||
<Alert className="bg-green-50 border-green-200 text-green-800">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription>
|
||||
{updateSuccess}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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 />
|
||||
{field.value !== user.email && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Changing your email will log you out for security reasons. You will need to log in again with your new email.
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
// Determine if the user is active based on the status field
|
||||
const isActive = user.status === "Active";
|
||||
|
||||
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={isActive ? "default" : "outline"} className="px-2 py-1">
|
||||
{user.status || (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,210 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Server, CheckCircle, XCircle, AlertTriangle, Pause, Clock } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { Service, UptimeData } from '@/types/service.types';
|
||||
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface ComponentsStatusSectionProps {
|
||||
components: StatusPageComponentRecord[];
|
||||
services: Service[];
|
||||
uptimeData: Record<string, UptimeData[]>;
|
||||
}
|
||||
|
||||
export const ComponentsStatusSection = ({ components, services, uptimeData }: ComponentsStatusSectionProps) => {
|
||||
const getServiceForComponent = (component: StatusPageComponentRecord) => {
|
||||
return services.find(service => service.id === component.service_id);
|
||||
};
|
||||
|
||||
const getComponentStatus = (component: StatusPageComponentRecord) => {
|
||||
const service = getServiceForComponent(component);
|
||||
return service?.status || 'unknown';
|
||||
};
|
||||
|
||||
const getUptimePercentage = (serviceId: string) => {
|
||||
const history = uptimeData[serviceId] || [];
|
||||
if (history.length === 0) return 100;
|
||||
|
||||
const upCount = history.filter(record => record.status === 'up').length;
|
||||
return Math.round((upCount / history.length) * 100 * 100) / 100;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'down':
|
||||
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
case 'paused':
|
||||
return <Pause className="h-5 w-5 text-gray-500" />;
|
||||
default:
|
||||
return <Server className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-200 dark:border-green-800">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Operational
|
||||
</Badge>
|
||||
);
|
||||
case 'down':
|
||||
return (
|
||||
<Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-red-200 dark:border-red-800">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Down
|
||||
</Badge>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<Badge className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Degraded
|
||||
</Badge>
|
||||
);
|
||||
case 'paused':
|
||||
return (
|
||||
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200 border-gray-200 dark:border-gray-800">
|
||||
<Pause className="h-3 w-3 mr-1" />
|
||||
Maintenance
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusDotColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return 'bg-green-500';
|
||||
case 'down':
|
||||
return 'bg-red-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (components.length === 0) {
|
||||
return (
|
||||
<Card className="mb-8 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||
<Server className="h-5 w-5" />
|
||||
System Components
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: 'API Services', description: 'Core API endpoints and services', status: 'up' },
|
||||
{ name: 'Database', description: 'Primary database systems', status: 'up' },
|
||||
{ name: 'Authentication', description: 'User authentication services', status: 'up' },
|
||||
{ name: 'File Storage', description: 'Media and file hosting', status: 'up' }
|
||||
].map((component, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(component.status)}
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">{component.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{component.description}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">99.9% uptime</span>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">100ms response</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(component.status)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-8 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||
<Server className="h-5 w-5" />
|
||||
System Components
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real-time status of all monitored components
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{components
|
||||
.sort((a, b) => a.display_order - b.display_order)
|
||||
.map((component) => {
|
||||
const service = getServiceForComponent(component);
|
||||
const status = getComponentStatus(component);
|
||||
const uptime = service?.id ? getUptimePercentage(component.service_id) : 100;
|
||||
|
||||
return (
|
||||
<div key={component.id} className="space-y-4">
|
||||
<div className="flex items-center justify-between p-5 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-all duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon(status)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-foreground text-lg">{component.name}</h3>
|
||||
{service?.responseTime && service.responseTime > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
<Clock className="h-3 w-3" />
|
||||
{service.responseTime}ms
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{component.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">{component.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${getStatusDotColor(status)}`}></div>
|
||||
<span className="font-medium">{uptime}% uptime (90 days)</span>
|
||||
</div>
|
||||
{service?.lastChecked && (
|
||||
<span>Last checked: {format(new Date(service.lastChecked), 'HH:mm:ss')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{getStatusBadge(status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{component.service_id && (
|
||||
<div className="ml-9">
|
||||
<div className="text-xs text-muted-foreground mb-2">90-day uptime history</div>
|
||||
<UptimeHistoryRenderer
|
||||
serviceId={component.service_id}
|
||||
uptimeData={uptimeData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Shield, Clock, CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { Service } from '@/types/service.types';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
interface CurrentStatusSectionProps {
|
||||
page: OperationalPageRecord;
|
||||
components: StatusPageComponentRecord[];
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
const getActualStatus = (components: StatusPageComponentRecord[], services: Service[]) => {
|
||||
if (components.length === 0) {
|
||||
return 'operational'; // Default if no components
|
||||
}
|
||||
|
||||
let hasDown = false;
|
||||
let hasDegraded = false;
|
||||
let hasMaintenance = false;
|
||||
|
||||
components.forEach(component => {
|
||||
const service = services.find(s => s.id === component.service_id);
|
||||
if (service) {
|
||||
switch (service.status) {
|
||||
case 'down':
|
||||
hasDown = true;
|
||||
break;
|
||||
case 'warning':
|
||||
hasDegraded = true;
|
||||
break;
|
||||
case 'paused':
|
||||
hasMaintenance = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Priority: down > degraded > maintenance > operational
|
||||
if (hasDown) return 'major_outage';
|
||||
if (hasDegraded) return 'degraded';
|
||||
if (hasMaintenance) return 'maintenance';
|
||||
return 'operational';
|
||||
};
|
||||
|
||||
const getStatusMessage = (status: OperationalPageRecord['status'], t: (k: string, m?: string) => string) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return t('allOperational', 'public');
|
||||
case 'degraded':
|
||||
return t('degradedPerformance', 'public');
|
||||
case 'maintenance':
|
||||
return t('underMaintenance', 'public');
|
||||
case 'major_outage':
|
||||
return t('majorOutage', 'public');
|
||||
default:
|
||||
return t('statusUnknown', 'public');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'degraded':
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
case 'maintenance':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'major_outage':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return <CheckCircle className="h-6 w-6 text-green-500" />;
|
||||
case 'degraded':
|
||||
return <AlertTriangle className="h-6 w-6 text-yellow-500" />;
|
||||
case 'maintenance':
|
||||
return <Wrench className="h-6 w-6 text-blue-500" />;
|
||||
case 'major_outage':
|
||||
return <XCircle className="h-6 w-6 text-red-500" />;
|
||||
default:
|
||||
return <Shield className="h-6 w-6 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBackground = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
|
||||
case 'degraded':
|
||||
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
|
||||
case 'maintenance':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
|
||||
case 'major_outage':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
|
||||
default:
|
||||
return 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
|
||||
const { t } = useLanguage();
|
||||
const actualStatus = getActualStatus(components, services);
|
||||
const displayStatus = actualStatus; // Use actual status for real-time accuracy
|
||||
|
||||
return (
|
||||
<Card className={`mb-8 border-2 ${getStatusBackground(displayStatus)}`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
|
||||
<Shield className="h-6 w-6" />
|
||||
{t('systemStatus', 'public')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className={`flex items-center justify-between p-6 rounded-lg border-2 ${getStatusBackground(displayStatus)}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon(displayStatus)}
|
||||
<div>
|
||||
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
|
||||
{getStatusMessage(displayStatus, t)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('autoUpdatedByHealth', 'public')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-4 py-2 rounded-full text-sm font-medium ${
|
||||
displayStatus === 'operational' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
|
||||
displayStatus === 'degraded' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
|
||||
displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{displayStatus === 'operational' ? t('allOperational', 'public') :
|
||||
displayStatus === 'degraded' ? t('degradedPerformance', 'public') :
|
||||
displayStatus === 'maintenance' ? t('underMaintenance', 'public') : t('majorOutage', 'public')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{t('lastUpdatedAt', 'public', { time: format(new Date(), 'MMM dd, yyyy HH:mm') })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>{t('liveStatusMonitoring', 'public')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, Calendar, BarChart3 } from 'lucide-react';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||
|
||||
interface OverallUptimeSectionProps {
|
||||
uptimeData: Record<string, UptimeData[]>;
|
||||
}
|
||||
|
||||
export const OverallUptimeSection = ({ uptimeData }: OverallUptimeSectionProps) => {
|
||||
const getOverallUptime = () => {
|
||||
const allHistories = Object.values(uptimeData);
|
||||
if (allHistories.length === 0) return 99.9;
|
||||
|
||||
let totalRecords = 0;
|
||||
let upRecords = 0;
|
||||
|
||||
allHistories.forEach(history => {
|
||||
totalRecords += history.length;
|
||||
upRecords += history.filter(record => record.status === 'up').length;
|
||||
});
|
||||
|
||||
if (totalRecords === 0) return 99.9;
|
||||
return Math.round((upRecords / totalRecords) * 100 * 100) / 100;
|
||||
};
|
||||
|
||||
const getUptimeTrend = () => {
|
||||
const uptime = getOverallUptime();
|
||||
if (uptime >= 99.9) return 'excellent';
|
||||
if (uptime >= 99.5) return 'good';
|
||||
if (uptime >= 95) return 'fair';
|
||||
return 'poor';
|
||||
};
|
||||
|
||||
const getIncidentCount = () => {
|
||||
const allHistories = Object.values(uptimeData);
|
||||
let incidents = 0;
|
||||
|
||||
allHistories.forEach(history => {
|
||||
let wasDown = false;
|
||||
history.forEach(record => {
|
||||
if (record.status === 'down' && !wasDown) {
|
||||
incidents++;
|
||||
wasDown = true;
|
||||
} else if (record.status === 'up') {
|
||||
wasDown = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return incidents;
|
||||
};
|
||||
|
||||
const getBadgeClassName = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'excellent':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
case 'good':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
case 'fair':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
default:
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendText = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'excellent':
|
||||
return 'Excellent';
|
||||
case 'good':
|
||||
return 'Good';
|
||||
case 'fair':
|
||||
return 'Fair';
|
||||
default:
|
||||
return 'Needs Improvement';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusMessage = (uptime: number) => {
|
||||
if (uptime >= 99.9) {
|
||||
return "All systems are performing excellently with minimal downtime.";
|
||||
} else if (uptime >= 99.5) {
|
||||
return "Systems are performing well with occasional minor issues.";
|
||||
} else if (uptime >= 95) {
|
||||
return "We're working to improve system reliability and reduce incidents.";
|
||||
} else {
|
||||
return "We apologize for recent service disruptions and are actively working on improvements.";
|
||||
}
|
||||
};
|
||||
|
||||
const overallUptime = getOverallUptime();
|
||||
const trend = getUptimeTrend();
|
||||
const incidentCount = getIncidentCount();
|
||||
|
||||
return (
|
||||
<Card className="mb-8 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Performance Metrics (Last 90 Days)
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Historical performance and reliability statistics
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">Overall Uptime</span>
|
||||
</div>
|
||||
<Badge className={getBadgeClassName(trend)}>
|
||||
{getTrendText(trend)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-green-600 dark:text-green-400">{overallUptime}%</div>
|
||||
<div className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
Target: 99.9%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Incidents</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">{incidentCount}</div>
|
||||
<div className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||
Last 90 days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Avg Response</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">100ms</div>
|
||||
<div className="text-xs text-purple-700 dark:text-purple-300 mt-1">
|
||||
Response time
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-foreground">Uptime History</h4>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 bg-green-500 rounded"></div>
|
||||
<span>Operational</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 bg-yellow-500 rounded"></div>
|
||||
<span>Degraded</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 bg-red-500 rounded"></div>
|
||||
<span>Down</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-background/50 rounded-lg border">
|
||||
{Object.keys(uptimeData).length > 0 ? (
|
||||
<UptimeHistoryRenderer serviceId={Object.keys(uptimeData)[0]} uptimeData={uptimeData} />
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-12 text-muted-foreground">
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 90 }, (_, i) => (
|
||||
<div key={i} className="h-8 w-1 bg-green-500 rounded-sm"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>90 days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
{getStatusMessage(overallUptime)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { usePublicStatusPageData } from './hooks/usePublicStatusPageData';
|
||||
import { StatusPageHeader } from './StatusPageHeader';
|
||||
import { CurrentStatusSection } from './CurrentStatusSection';
|
||||
import { ComponentsStatusSection } from './ComponentsStatusSection';
|
||||
import { OverallUptimeSection } from './OverallUptimeSection';
|
||||
import { PublicStatusPageFooter } from './PublicStatusPageFooter';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
export const PublicStatusPage = () => {
|
||||
const { t } = useLanguage();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
// console.log('PublicStatusPage - slug from params:', slug);
|
||||
|
||||
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
|
||||
const [lastUpdated, setLastUpdated] = useState(new Date());
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setLastUpdated(new Date());
|
||||
// The usePublicStatusPageData hook handles data refetching
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
if (page) {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove any existing theme classes
|
||||
root.classList.remove('dark', 'light');
|
||||
|
||||
// Apply the selected theme
|
||||
if (page.theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else if (page.theme === 'light') {
|
||||
root.classList.add('light');
|
||||
}
|
||||
// For 'default' theme, don't add any class (uses system preference)
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark', 'light');
|
||||
};
|
||||
}, [page?.theme]);
|
||||
|
||||
// console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium text-foreground">{t('loadingStatusPage', 'public')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('fetchingRealtimeStatus', 'public')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !page) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-6 max-w-md">
|
||||
<div className="mx-auto h-16 w-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('statusPageNotFound', 'public')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{error || t('notFoundDescription', 'public')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button onClick={() => window.history.back()} variant="outline">
|
||||
{t('goBack', 'public')}
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('retry', 'public')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* Header */}
|
||||
<StatusPageHeader page={page} />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Current Status */}
|
||||
<CurrentStatusSection page={page} components={components} services={services} />
|
||||
|
||||
{/* Components Status */}
|
||||
<ComponentsStatusSection
|
||||
components={components}
|
||||
services={services}
|
||||
uptimeData={uptimeData}
|
||||
/>
|
||||
|
||||
{/* Overall Uptime History */}
|
||||
<OverallUptimeSection uptimeData={uptimeData} />
|
||||
|
||||
{/* Footer */}
|
||||
<PublicStatusPageFooter page={page} />
|
||||
</main>
|
||||
|
||||
{/* Custom CSS */}
|
||||
{page.custom_css && (
|
||||
<style dangerouslySetInnerHTML={{ __html: page.custom_css }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { format } from 'date-fns';
|
||||
import { Clock, Shield, Zap, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface PublicStatusPageFooterProps {
|
||||
page: OperationalPageRecord;
|
||||
}
|
||||
|
||||
export const PublicStatusPageFooter = ({ page }: PublicStatusPageFooterProps) => {
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="mt-12 pt-8 border-t border-border">
|
||||
<div className="space-y-6">
|
||||
{/* Status Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6 bg-muted/30 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Real-time Monitoring</div>
|
||||
<div className="text-sm text-muted-foreground">24/7 automated checks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
|
||||
<Zap className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Instant Updates</div>
|
||||
<div className="text-sm text-muted-foreground">Status changes in real-time</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Historical Data</div>
|
||||
<div className="text-sm text-muted-foreground">90-day performance history</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm:ss')} UTC</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Monitoring active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh Status
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="text-center text-xs text-muted-foreground p-4 bg-muted/20 rounded-lg">
|
||||
<p>
|
||||
This status page provides real-time information about our systems and services.
|
||||
Historical data reflects the last 90 days of monitoring. For support inquiries, please contact our team.
|
||||
</p>
|
||||
{page.custom_domain && (
|
||||
<p className="mt-2">
|
||||
Powered by automated monitoring • Status page for {page.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { Shield, Globe, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface StatusPageHeaderProps {
|
||||
page: OperationalPageRecord;
|
||||
}
|
||||
|
||||
export const StatusPageHeader = ({ page }: StatusPageHeaderProps) => {
|
||||
return (
|
||||
<header className="bg-background border-b border-border">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{page.logo_url ? (
|
||||
<img
|
||||
src={page.logo_url}
|
||||
alt={`${page.title} logo`}
|
||||
className="h-12 w-12 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{page.title}</h1>
|
||||
<p className="text-muted-foreground mt-1">{page.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{page.custom_domain && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={`https://${page.custom_domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Visit Site
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-medium">Live Status</span>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
Auto-updated every 30s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Status Page</span>
|
||||
<span>•</span>
|
||||
<span className="text-foreground font-medium">{page.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { format } from 'date-fns';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
|
||||
interface UptimeHistoryRendererProps {
|
||||
serviceId: string;
|
||||
uptimeData: Record<string, UptimeData[]>;
|
||||
}
|
||||
|
||||
export const UptimeHistoryRenderer = ({ serviceId, uptimeData }: UptimeHistoryRendererProps) => {
|
||||
const renderUptimeHistory = (serviceId: string) => {
|
||||
const history = uptimeData[serviceId] || [];
|
||||
|
||||
// Generate array for the last 90 days
|
||||
const days = Array.from({ length: 90 }, (_, i) => {
|
||||
const daysSinceToday = 90 - i - 1;
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysSinceToday);
|
||||
date.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
||||
return date;
|
||||
});
|
||||
|
||||
if (history.length === 0) {
|
||||
// Generate mock data if no real data - showing mostly operational with some realistic incidents
|
||||
return days.map((date, i) => {
|
||||
// Simulate some realistic patterns - mostly up with occasional incidents
|
||||
const isUp = Math.random() > 0.02; // 98% uptime simulation
|
||||
const responseTime = isUp ? Math.floor(Math.random() * 200) + 50 : 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider key={i}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-8 w-1 rounded-sm cursor-pointer ${
|
||||
isUp ? 'bg-green-500 hover:bg-green-600' : 'bg-red-500 hover:bg-red-600'
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{format(date, 'MMM dd, yyyy')}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Status: {isUp ? 'Operational' : 'Incident - Down'}
|
||||
</div>
|
||||
{isUp && (
|
||||
<div className="text-muted-foreground">
|
||||
Response: {responseTime}ms
|
||||
</div>
|
||||
)}
|
||||
{!isUp && (
|
||||
<div className="text-muted-foreground text-red-400">
|
||||
Service outage detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Create a map of dates to status records for efficient lookup and incident tracking
|
||||
const dateToRecordMap = new Map();
|
||||
const incidentsByDate = new Map();
|
||||
|
||||
history.forEach(record => {
|
||||
const recordDate = new Date(record.timestamp);
|
||||
recordDate.setHours(0, 0, 0, 0); // Normalize to start of day
|
||||
const dateKey = recordDate.toDateString();
|
||||
|
||||
// Track incidents for this date
|
||||
if (!incidentsByDate.has(dateKey)) {
|
||||
incidentsByDate.set(dateKey, []);
|
||||
}
|
||||
incidentsByDate.get(dateKey).push(record);
|
||||
|
||||
// Keep the latest record for each day (or aggregate if needed)
|
||||
if (!dateToRecordMap.has(dateKey) ||
|
||||
new Date(record.timestamp) > new Date(dateToRecordMap.get(dateKey).timestamp)) {
|
||||
dateToRecordMap.set(dateKey, record);
|
||||
}
|
||||
});
|
||||
|
||||
// Use real uptime data mapped to the correct days with incident details
|
||||
return days.map((date, i) => {
|
||||
const dateKey = date.toDateString();
|
||||
const record = dateToRecordMap.get(dateKey);
|
||||
const incidents = incidentsByDate.get(dateKey) || [];
|
||||
|
||||
// Calculate uptime percentage for the day
|
||||
const uptimePercentage = incidents.length > 0 ?
|
||||
Math.round((incidents.filter(inc => inc.status === 'up').length / incidents.length) * 100) : 100;
|
||||
|
||||
// Determine color based on actual status and incident history
|
||||
const getStatusColor = (status: string, incidents: UptimeData[]) => {
|
||||
const downIncidents = incidents.filter(inc => inc.status === 'down').length;
|
||||
const warningIncidents = incidents.filter(inc => inc.status === 'warning').length;
|
||||
|
||||
if (downIncidents > 0) return 'bg-red-500 hover:bg-red-600';
|
||||
if (warningIncidents > 0) return 'bg-yellow-500 hover:bg-yellow-600';
|
||||
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return 'bg-green-500 hover:bg-green-600';
|
||||
case 'down':
|
||||
return 'bg-red-500 hover:bg-red-600';
|
||||
case 'paused':
|
||||
return 'bg-gray-400 hover:bg-gray-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500 hover:bg-yellow-600';
|
||||
default:
|
||||
return 'bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = record ? getStatusColor(record.status, incidents) : 'bg-gray-300 dark:bg-gray-600';
|
||||
const statusText = record ?
|
||||
record.status === 'up' ? (incidents.some(inc => inc.status === 'down') ? 'Recovered' : 'Operational') :
|
||||
record.status === 'down' ? 'Incident - Down' :
|
||||
record.status === 'paused' ? 'Paused' :
|
||||
record.status === 'warning' ? 'Incident - Degraded' : 'Unknown'
|
||||
: 'No Data';
|
||||
|
||||
return (
|
||||
<TooltipProvider key={i}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-8 w-1 rounded-sm cursor-pointer ${statusColor}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{format(date, 'MMM dd, yyyy')}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Status: {statusText}
|
||||
</div>
|
||||
{incidents.length > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
Uptime: {uptimePercentage}% ({incidents.length} checks)
|
||||
</div>
|
||||
)}
|
||||
{incidents.filter(inc => inc.status === 'down').length > 0 && (
|
||||
<div className="text-red-400 text-xs">
|
||||
{incidents.filter(inc => inc.status === 'down').length} incident(s) detected
|
||||
</div>
|
||||
)}
|
||||
{record && record.status === 'up' && record.responseTime > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
Response: {record.responseTime}ms
|
||||
</div>
|
||||
)}
|
||||
{record && (
|
||||
<div className="text-muted-foreground">
|
||||
Last Check: {format(new Date(record.timestamp), 'HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-8 p-3 bg-background/30 rounded-lg border border-border/50">
|
||||
<div className="text-sm font-medium text-foreground mb-2">90-day uptime history</div>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
{renderUptimeHistory(serviceId)}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>90 days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { Service, UptimeData } from '@/types/service.types';
|
||||
import { operationalPageService } from '@/services/operationalPageService';
|
||||
import { statusPageComponentsService } from '@/services/statusPageComponentsService';
|
||||
import { serviceService } from '@/services/serviceService';
|
||||
import { uptimeService } from '@/services/uptimeService';
|
||||
|
||||
export const usePublicStatusPageData = (slug: string | undefined) => {
|
||||
const [page, setPage] = useState<OperationalPageRecord | null>(null);
|
||||
const [components, setComponents] = useState<StatusPageComponentRecord[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [uptimeData, setUptimeData] = useState<Record<string, UptimeData[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPublicPage = async () => {
|
||||
if (!slug) {
|
||||
// console.log('No slug provided');
|
||||
setError('No status page slug provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// console.log('Fetching public status page for slug:', slug);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch operational page
|
||||
// console.log('Fetching operational pages...');
|
||||
const pages = await operationalPageService.getOperationalPages();
|
||||
// console.log('All pages:', pages);
|
||||
|
||||
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
|
||||
// console.log('Found page:', foundPage);
|
||||
|
||||
if (!foundPage) {
|
||||
// console.log('Page not found or not public');
|
||||
setError('Status page not found or not public');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPage(foundPage);
|
||||
// console.log('Page set successfully');
|
||||
|
||||
// Fetch components for this page
|
||||
// console.log('Fetching components for page:', foundPage.id);
|
||||
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
|
||||
// console.log('Components found:', pageComponents);
|
||||
setComponents(pageComponents);
|
||||
|
||||
// Fetch all services
|
||||
// console.log('Fetching all services...');
|
||||
const allServices = await serviceService.getServices();
|
||||
// console.log('Services found:', allServices);
|
||||
setServices(allServices);
|
||||
|
||||
// Fetch uptime data for each component that has a service
|
||||
// console.log('Fetching uptime data...');
|
||||
const uptimePromises = pageComponents
|
||||
.filter(component => component.service_id)
|
||||
.map(async (component) => {
|
||||
try {
|
||||
// console.log('Fetching uptime for service:', component.service_id);
|
||||
const endDate = new Date();
|
||||
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
|
||||
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
|
||||
// console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
||||
return { serviceId: component.service_id, history };
|
||||
} catch (error) {
|
||||
// console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
||||
return { serviceId: component.service_id, history: [] };
|
||||
}
|
||||
});
|
||||
|
||||
const uptimeResults = await Promise.all(uptimePromises);
|
||||
const uptimeMap: Record<string, UptimeData[]> = {};
|
||||
uptimeResults.forEach(result => {
|
||||
uptimeMap[result.serviceId] = result.history;
|
||||
});
|
||||
setUptimeData(uptimeMap);
|
||||
// console.log('Uptime data set successfully');
|
||||
|
||||
// console.log('All data fetched successfully');
|
||||
|
||||
} catch (err) {
|
||||
// console.error('Error fetching public page:', err);
|
||||
setError(`Failed to load status page: ${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPublicPage();
|
||||
}, [slug]);
|
||||
|
||||
return {
|
||||
page,
|
||||
components,
|
||||
services,
|
||||
uptimeData,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { getCurrentEndpoint } from "@/lib/pocketbase";
|
||||
import { RegionalAgentConfigForm } from "./RegionalAgentConfigForm";
|
||||
import { RegionalOneClickTab } from "./RegionalOneClickTab";
|
||||
import { RegionalManualTab } from "./RegionalManualTab";
|
||||
|
||||
interface AddRegionalAgentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAgentAdded: () => void;
|
||||
}
|
||||
|
||||
export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAgentAdded
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState("configure");
|
||||
const [formData, setFormData] = useState({
|
||||
regionName: "",
|
||||
agentIp: "",
|
||||
});
|
||||
const [agentToken, setAgentToken] = useState("");
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [currentPocketBaseUrl, setCurrentPocketBaseUrl] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Generate new credentials when dialog opens or after successful creation
|
||||
const generateNewCredentials = () => {
|
||||
const newToken = `rgn_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
|
||||
const newAgentId = `regional_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
setAgentToken(newToken);
|
||||
setAgentId(newAgentId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const endpoint = getCurrentEndpoint();
|
||||
setCurrentPocketBaseUrl(endpoint);
|
||||
generateNewCredentials();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.regionName.trim() || !formData.agentIp.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('agentCreatedSuccessfully'),
|
||||
});
|
||||
setActiveTab("one-click");
|
||||
generateNewCredentials();
|
||||
|
||||
onAgentAdded();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('failedToCreateAgent'),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setFormData({
|
||||
regionName: "",
|
||||
agentIp: "",
|
||||
});
|
||||
setActiveTab("configure");
|
||||
generateNewCredentials();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('addRegionalMonitoringAgent')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('deployRegionalMonitoringAgent')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="configure">{t('configureAgent')}</TabsTrigger>
|
||||
<TabsTrigger value="one-click">{t('oneClickInstallTab')}</TabsTrigger>
|
||||
<TabsTrigger value="manual">{t('manualInstallTab')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="configure" className="space-y-6">
|
||||
<RegionalAgentConfigForm
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
agentId={agentId}
|
||||
agentToken={agentToken}
|
||||
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="one-click" className="space-y-6">
|
||||
<RegionalOneClickTab
|
||||
agentToken={agentToken}
|
||||
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||
formData={formData}
|
||||
agentId={agentId}
|
||||
onDialogClose={handleDialogClose}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="space-y-6">
|
||||
<RegionalManualTab
|
||||
agentToken={agentToken}
|
||||
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||
formData={formData}
|
||||
agentId={agentId}
|
||||
onDialogClose={handleDialogClose}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { MapPin, Wifi, WifiOff, MoreVertical, Trash2, Terminal, Copy } from "lucide-react";
|
||||
import { RegionalService } from "@/types/regional.types";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface RegionalAgentCardProps {
|
||||
agent: RegionalService;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Check if this is the default agent that cannot be removed
|
||||
const isDefaultAgent = agent.agent_id === "1" || agent.region_name === "Default";
|
||||
|
||||
const copyAgentId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(agent.agent_id);
|
||||
toast({
|
||||
title: t('copied'),
|
||||
description: t('copiedDescription'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('copyFailed'),
|
||||
description: t('copyFailedDescription'),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionStatus = () => {
|
||||
if (agent.connection === 'online') {
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4" />,
|
||||
label: t('online'),
|
||||
variant: 'default' as const,
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-100'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
label: t('offline'),
|
||||
variant: 'secondary' as const,
|
||||
className: 'bg-red-100 text-red-800 hover:bg-red-100'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const connectionStatus = getConnectionStatus();
|
||||
|
||||
return (
|
||||
<Card className="relative">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-5 w-5 text-blue-600" />
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{agent.region_name}
|
||||
{isDefaultAgent && (
|
||||
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
|
||||
{t('defaultBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1 text-sm">
|
||||
{agent.agent_ip_address}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={copyAgentId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{t('copyAgentId')}
|
||||
</DropdownMenuItem>
|
||||
{!isDefaultAgent && (
|
||||
<DropdownMenuItem onClick={onDelete} className="text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('removeAgent')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={connectionStatus.variant}
|
||||
className={connectionStatus.className}
|
||||
>
|
||||
{connectionStatus.icon}
|
||||
<span className="ml-1">{connectionStatus.label}</span>
|
||||
</Badge>
|
||||
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{agent.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('agentId')}</span>
|
||||
<span className="font-mono text-xs">{agent.agent_id.substring(0, 12)}...</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('lastUpdated')}</span>
|
||||
<span className="text-xs">
|
||||
{formatDistanceToNow(new Date(agent.updated), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agent.connection === 'online' && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center text-xs text-green-600">
|
||||
<div className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></div>
|
||||
{t('activeMonitoring')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent.connection === 'offline' && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center text-xs text-red-600">
|
||||
<div className="w-2 h-2 bg-red-600 rounded-full mr-2"></div>
|
||||
{t('connectionLost')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||