Initial Release

This commit is contained in:
Tola Leng
2025-05-09 21:28:07 +07:00
commit 0c6cd18e6f
244 changed files with 50633 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+24
View File
@@ -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?
+119
View File
@@ -0,0 +1,119 @@
# 🛠️ Contributing to CheckCle
Thank you for your interest in contributing to **CheckCle** — the ultimate open-source platform for real-time full-stack monitoring. Whether you're here to report bugs, suggest features, improve documentation, or submit code, your contribution matters!
We welcome all kinds of contributions, big or small. This guide will help you get started.
---
## 📌 Table of Contents
- [Code of Conduct](#-code-of-conduct)
- [Ways to Contribute](#-ways-to-contribute)
- [Development Setup](#-development-setup)
- [Pull Request Process](#-pull-request-process)
- [Reporting Bugs & Issues](#-reporting-bugs--issues)
- [Feature Requests](#-feature-requests)
- [Community & Support](#-community--support)
- [License](#-license)
---
## 📜 Code of Conduct
We follow a [Code of Conduct](https://opensource.guide/code-of-conduct/) to foster an open and welcoming community. By participating, you agree to uphold these standards.
---
## 🤝 Ways to Contribute
Here are some ways you can help improve CheckCle:
- 🐞 **Report Bugs** Found a glitch? Let us know by opening a [GitHub Issue](https://github.com/operacle/checkcle/issues).
- 🌟 **Suggest Features** Have an idea? Start a [Discussion](https://github.com/operacle/checkcle/discussions) or open a Feature Request issue.
- 🛠 **Submit Pull Requests** Improve the code, fix bugs, add features, or enhance the docs.
- 📝 **Improve Documentation** Even a typo fix helps!
- 🌍 **Spread the Word** Star ⭐ the repo, share it on socials, and invite others to contribute!
---
## 🧰 Development Setup
Before contributing code, set up the project locally:
### 1. Fork the Repository
Click "Fork" on [GitHub](https://github.com/operacle/checkcle) to create your own copy.
### 2. Clone Your Fork
```bash
git clone https://github.com/operacle/checkcle.git
cd checkcle
```
### 3. Install Dependencies
Follow the instructions in the README or project docs to install required packages and run the local development server.
### 4. Start Local Development
```bash
#Web Application
cd application/
npm install && npm run dev
#Server Backend
cd server
./pocketbase serve --dir pb_data
```
---
## ✅ Pull Request Process
1. Ensure your code follows the existing style and naming conventions.
2. Write clear, concise commit messages.
3. Push your branch and open a Pull Request (PR) on the `main` branch.
4. Provide a meaningful PR description (what/why/how).
5. Link related issues if applicable (e.g. `Closes #12`).
6. Make sure all checks pass (e.g., linting, tests).
Well review your PR, request changes if needed, and merge it once ready!
---
## 🐛 Reporting Bugs & Issues
Please include as much information as possible:
- A clear, descriptive title
- Steps to reproduce
- Expected vs actual behavior
- Environment info (OS, browser, device, etc.)
- Screenshots or logs if applicable
Use the [Issue Tracker](https://github.com/operacle/checkcle/issues) to report.
---
## 💡 Feature Requests
Wed love to hear your ideas! Open a [Discussion](https://github.com/operacle/checkcle/discussions) or Feature Request issue. Make sure its not already listed in the [Roadmap](https://github.com/operacle/checkcle#development-roadmap).
---
## 🌍 Community & Support
Need help? Want to connect?
- 💬 [Join our Discord](https://discord.gg/xs9gbubGwX)
- 🗣 Start or join a [GitHub Discussion](https://github.com/operacle/checkcle/discussions)
- 🐦 Follow us on [X (Twitter)](https://x.com/tl)
---
## 📜 License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
---
## 🙏 Thank You
Were excited to build CheckCle together — a powerful monitoring platform **for the community, by the community**. Your support means the world! 💙
+44
View File
@@ -0,0 +1,44 @@
# Stage 1: Build the frontend
FROM node:18-alpine AS frontend-builder
WORKDIR /app
COPY application/package*.json ./
RUN npm install
COPY application/ ./
RUN npm run build
# Stage 2: Download PocketBase binary
FROM alpine:3.17 AS pb-builder
WORKDIR /pb
ARG PB_VERSION=0.27.2
RUN apk add --no-cache wget unzip \
&& wget https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip \
&& unzip pocketbase_${PB_VERSION}_linux_amd64.zip \
&& chmod +x /pb/pocketbase
# Stage 3: Final runtime image
FROM alpine:3.17
WORKDIR /app
# Copy PocketBase binary
COPY --from=pb-builder /pb/pocketbase /app/pocketbase
# Copy frontend build to PocketBase public directory
COPY --from=frontend-builder /app/dist /app/pb_public
# Copy backend files
COPY server/pb_migrations /app/pb_migrations
COPY server/pb_hooks /app/pb_hooks
COPY server/pb_data /app/pb_data
# Mark pb_data as a volume for runtime persistence
VOLUME /app/pb_data
# Expose default PocketBase port
EXPOSE 8090
# Launch PocketBase
#CMD ["/app/pocketbase", "serve", "--dir", "/app/pb_data"]
# Launch PocketBase and bind to 0.0.0.0 for external access
CMD ["/app/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir", "/app/pb_data"]
+17
View File
@@ -0,0 +1,17 @@
The MIT License (MIT)
Copyright (c) 2022 - Present, Tola Leng
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+123
View File
@@ -0,0 +1,123 @@
![CheckCle Platform](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png)
# 🚀 What is CheckCle?
CheckCle is an Open Source solution for seamless, real-time monitoring of full-stack systems, applications, and infrastructure. It provides developers, sysadmins, and DevOps teams with deep insights and actionable data across every layer of their environment—whether it's servers, applications, or services. With CheckCle, you gain visibility, control, and the ability to ensure optimal performance throughout your entire technology stack.
## 🎯 Live Demo
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io) Coming Soon!
## 🌟 Core Features
### Uptime Services & Infrastructure Server Monitoring
- Monitor HTTP, DNS, and Ping protocols
- Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP)
- Track detail uptime, response times, and performance issues
- Incident History (UP/DOWN/WARNING/PAUSE)
- SSL & Domain Monitoring (Domain, Issuer, Expiration Date, Days Left, Status, Last Notified)
- Infrastructure Server Monitoring, Supports Linux (🐧 Debian, Ubuntu, CentOS, Red Hat, etc.) and Windows (Beta). And Servers metrics like CPU, RAM, disk usage, and network activity) with an one-line installation angent script.
- Schedule Maintenance & Incident Management
- Operational Status / Public Status Pages
- Notifications via email, Telegram, Discord, and Slack
- Reports & Analytics
- Settings Panel (User Management, Data Retention, Multi-language, Themes (Dark & Light Mode), Notification and channels and alert templates).
## #️⃣ Getting Started
### Installation with Docker Compose
1. Clone the repository and run
```bash
#Clone the repository
git clone https://github.com/operacle/checkcle.git
cd checkcle
# Run docker compose
docker compose up -d
```
2. Docker Compose - Recommended
```bash
services:
checkcle:
image: operacle/checkcle:latest
container_name: checkcle
restart: unless-stopped
ports:
- "8090:8090" # Web Application
volumes:
- pb_data:/app/pb_data # Ensure persistent data across rebuilds
ulimits:
nofile:
soft: 4096
hard: 8192
volumes:
pb_data: # Docker-managed volume for data persistence
```
3. Admin Web Management
Default URL: http://0.0.0.0:8090
User: admin@example.com
Passwd: Admin123456
4. Follow the Quick Start Guide at https://docs.checkcle.com (Coming Soon)
###
![Uptime Monitoring](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-collapse-black.png)
![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png)
![Service Detail Page](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-detailpage.png)
## 📝 Development Roadmap
- [x] Health check & uptime monitoring (HTTP)
- [x] Dashboard UI with live stats
- [x] Auth with Multi-users system (admin)
- [x] Notifications (Telegram)
- [x] Docker containerization
- [ ] SSL & Domain Monitoring
- [ ] Infrastructure Server Monitoring
- [ ] Schedule Maintenance & Incident Management
- [ ] Operational Status / Public Status Pages
- [ ] Uptime monitoring (TCP, PING, DNS)
- [ ] User Permission Roles & Service Group
- [ ] Notifications (Email/Slack/Discord/Signal)
- [ ] Mobile PWA version
- [ ] Open-source release with full documentation
## 🌟 CheckCle for Communities?
- **Built with Passion**: Created by an open-source enthusiast for the community
- **Free & Open Source**: Completely free to use with no hidden costs
- **Collaborate & Connect**: Meet like-minded people passionate about Open Source
---
## 🤝 Ways to Contribute
Here are some ways you can help improve CheckCle:
- 🐞 **Report Bugs** Found a glitch? Let us know by opening a [GitHub Issue](https://github.com/operacle/checkcle/issues).
- 🌟 **Suggest Features** Have an idea? Start a [Discussion](https://github.com/operacle/checkcle/discussions) or open a Feature Request issue.
- 🛠 **Submit Pull Requests** Improve the code, fix bugs, add features, or enhance the docs.
- 📝 **Improve Documentation** Even a typo fix helps!
- 🌍 **Spread the Word** Star ⭐ the repo, share it on socials, and invite others to contribute!
---
## 🌍 Stay Connected
- Website: [checkcle.io](https://checkcle.io)
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
- Community Channels: Engage via discussions and issues!
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
- X: [@tlengoss](https://x.com/tlengoss)
## 📜 License
CheckCle is released under the MIT License.
---
Stay informed, stay online, with CheckCle! 🌐
+24
View File
@@ -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?
Binary file not shown.
+20
View File
@@ -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"
}
}
+29
View File
@@ -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",
},
}
);
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CheckCle is an open-source monitoring stack</title>
<meta name="description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
<meta name="author" content="Tola Leng" />
<meta property="og:title" content="checkcle-an-open-source" />
<meta property="og:description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@tlengoss" />
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
</head>
<body>
<div id="root"></div>
<!-- IMPORTANT: DO NOT REMOVE THIS SCRIPT TAG OR THIS VERY COMMENT! -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6560
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-query": "^5.56.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"pocketbase": "^0.19.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@tailwindcss/typography": "^0.5.15",
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+1
View File
@@ -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

+14
View File
@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#f6d594" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M40.77 31C24.27 31 5.8 21.8 4 1.63A1.4 1.4 0 0 1 5.21.07a1.38 1.38 0 0 1 1.55 1.21c1.6 18.47 18.74 26.89 34 26.89 21.58 0 32.38-13.53 33.52-26.89A1.38 1.38 0 0 1 75.73 0 1.45 1.45 0 0 1 77 1.47C75.34 21.84 57.08 31 40.8 31h-.03Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.84 21.11c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 1.1-2.26 1.68 1.68 0 0 1 1.86.61c.14.2.24.4.3.64.6 2.11 1.87 3.97 3.61 5.28a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.23.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.84 21.11c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.05 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.21 10.21 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.86 7.1 13.19 13.19 0 0 1-8.1 2.65Z" fill="#000"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#fcbc34" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M53.92 35a1.37 1.37 0 0 1-1.26-.83 15.26 15.26 0 0 1 15.67-22.03c3.71.52 7.1 2.39 9.53 5.26a1.48 1.48 0 0 1-1.15 2.09c-.3.04-.61-.02-.88-.17a12.52 12.52 0 0 0-16.54-2.35c-4.01 2.7-7.51 8.54-4.1 16.07a1.48 1.48 0 0 1-.76 1.83 1.3 1.3 0 0 1-.51.13Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.84 21.11c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 1.1-2.26 1.68 1.68 0 0 1 1.86.61c.14.2.24.4.3.64.6 2.11 1.87 3.97 3.61 5.28a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.23.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.84 21.11c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.05 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.21 10.21 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.86 7.1 13.19 13.19 0 0 1-8.1 2.65Z" fill="#000"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#d84be5" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4c10.24 32.68 61.73 31.31 72 0A167.66 167.66 0 0 1 4 4Z" fill="#2B1607"/><path d="M39.68 29h-.64c-17.41-.24-31.54-9.95-36-24.74a1.02 1.02 0 0 1 .2-.95.98.98 0 0 1 .91-.28 165.35 165.35 0 0 0 71.72 0 .91.91 0 0 1 .9.3 1.04 1.04 0 0 1 .19.97C71.93 19.37 57.36 29 39.68 29ZM5.33 5.3c4.73 13.13 17.76 21.54 33.7 21.77C55.6 27.3 69.29 18.8 74.56 5.3a167.24 167.24 0 0 1-69.22 0Z" fill="#000"/><g style="mix-blend-mode:soft-light" opacity=".4"><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" fill="#422715"/><path d="M22.02 35.23c5.08 5.6 28.25 5.96 33.85 0a79.58 79.58 0 0 1-33.85 0Z" stroke="#000" stroke-width="1.58" stroke-miterlimit="10"/></g><path fill-rule="evenodd" clip-rule="evenodd" d="M40.83 45H38.1c-2.67 0-5.24-1.06-7.13-2.94a10 10 0 0 1-2.96-7.1V19.54c0-1.9 2.8-3.94 4.88-3.46a29.03 29.03 0 0 0 13.26 0C48 15.7 51 17.62 51 19.53v15.44a9.98 9.98 0 0 1-6.28 9.29c-1.23.5-2.55.75-3.89.74Z" fill="url(#mouthTongueOut-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M38.27 17c.95 3.75.33 13 1.13 13 .8 0 .53-9.01 1.37-13h-2.5Z" fill="#A00707"/><defs><linearGradient id="mouthTongueOut-a" x1="39.46" y1="45" x2="39.46" y2="16" gradientUnits="userSpaceOnUse"><stop stop-color="#EF0A0A"/><stop offset=".5" stop-color="#ED0A0A"/><stop offset=".67" stop-color="#E60A0A"/><stop offset=".8" stop-color="#DB0A0A"/><stop offset=".9" stop-color="#CA0A0A"/><stop offset=".99" stop-color="#B40A0A"/><stop offset="1" stop-color="#B10A0A"/></linearGradient></defs></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M75.76 21.94c-2.9.04-5.72-.89-8.04-2.63a13.47 13.47 0 0 1-4.85-7.03 1.75 1.75 0 0 1 .49-1.92 1.7 1.7 0 0 1 2.76.9c.6 2.12 1.88 3.98 3.62 5.29a9.84 9.84 0 0 0 6.04 1.98c2.17.01 4.29-.68 6.03-2a10.17 10.17 0 0 0 3.65-5.26c.15-.42.46-.75.85-.95a1.68 1.68 0 0 1 2.24.7c.21.38.27.83.17 1.26a13.48 13.48 0 0 1-4.87 7.04 13.17 13.17 0 0 1-8.08 2.62ZM13.76 21.94c-2.9.03-5.73-.9-8.06-2.65a13.54 13.54 0 0 1-4.85-7.06 1.78 1.78 0 0 1 .51-1.88 1.67 1.67 0 0 1 2.4.22c.15.17.25.38.32.6.62 2.1 1.9 3.96 3.64 5.28a9.93 9.93 0 0 0 6.02 2c2.18.03 4.3-.67 6.06-1.99a10.22 10.22 0 0 0 3.66-5.3 1.68 1.68 0 0 1 3.08-.25c.21.4.27.85.17 1.27a13.57 13.57 0 0 1-4.87 7.1 13.19 13.19 0 0 1-8.08 2.65Z" fill="#000"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#d9915b" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M40.3 36c-.35 0-.68-.15-.92-.4a1.43 1.43 0 0 1-.38-.98c0-.37.14-.72.38-.98s.57-.4.91-.4c3.77 0 5.99-1.38 6.1-3.75.1-2.73-2.52-6.05-6.22-6.43a1.26 1.26 0 0 1-.84-.49 1.5 1.5 0 0 1 .08-1.88c.23-.25.52-.4.84-.44 3.66-.25 6.11-3.25 6.04-5.82 0-2.3-2.3-3.67-5.96-3.67-.27-.1-.5-.28-.67-.53a1.53 1.53 0 0 1 0-1.71c.16-.25.4-.43.67-.53 6.17 0 8.45 3.28 8.55 6.35a8.98 8.98 0 0 1-1.23 4.26 8.22 8.22 0 0 1-3.06 3.02 8.91 8.91 0 0 1 3.18 3.3A9.7 9.7 0 0 1 49 29.48C48.84 32.7 46.48 36 40.3 36Z" fill="#000"/><g fill-rule="evenodd" clip-rule="evenodd"><path d="M107.8 10.27c-.09.5-.2.98-.36 1.46-2.88 10.4-16.3 16.3-23.8 19.9-6.09-6.32-17.4-17.25-15.4-28.6.25-1.51.78-2.96 1.58-4.25 3.97-6.6 13.73-7.44 19.41.96 9.68-6.95 20.37.34 18.57 10.53Z" fill="#CE0F0F"/><path opacity=".2" d="M107.8 10.27c-.09.5-.2.98-.36 1.46-4.52 8.33-15.82 13.3-22.4 16.5C78.87 21.98 67.61 11.1 69.6-.32c0-.29.12-.57.2-.85 3.96-6.58 13.73-7.43 19.4.97 9.7-7.01 20.39.28 18.6 10.47Z" fill="#fff"/></g></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M78.36 25.92h-6.6c-9.72 0-9.72.07-9.72-9.73v-3.3A13.04 13.04 0 0 1 75.06-.13 13.04 13.04 0 0 1 88.1 12.9v3.38c-.03 9.72-.03 9.65-9.73 9.65Z" fill="#000"/><path d="M70.69 9.52a2.28 2.28 0 1 0 0-4.57 2.28 2.28 0 0 0 0 4.57Z" fill="#fff"/><path opacity=".1" d="M74.92 18.31a5.31 5.31 0 1 0 0-10.62 5.31 5.31 0 0 0 0 10.62Z" fill="#fff"/><path d="M17.36 25.92h-6.6c-9.72 0-9.72.07-9.72-9.73v-3.3A13.04 13.04 0 0 1 14.06-.13 13.04 13.04 0 0 1 27.1 12.9v3.38c-.03 9.72-.03 9.65-9.73 9.65Z" fill="#000"/><path d="M9.8 9.53a2.29 2.29 0 1 0 0-4.57 2.29 2.29 0 0 0 0 4.57Z" fill="#fff"/><path opacity=".1" d="M14.03 18.35a5.32 5.32 0 1 0 0-10.65 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#71cf62" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M21 29a19.88 19.88 0 0 1 39 0c-9.72-7.8-30.4-7.44-39 0Z" fill="#2B1607"/><path d="M20.58 29.54c-.1 0-.2-.03-.3-.09a.58.58 0 0 1-.24-.27.56.56 0 0 1-.02-.36c2.48-9.9 10.54-16.22 20.51-16.22 9.98 0 18.01 6.41 20.45 16.28.03.11.02.24-.02.35a.58.58 0 0 1-.23.27.62.62 0 0 1-.7 0c-9.4-7.44-30.24-7.44-39.1 0a.6.6 0 0 1-.35.04Zm19.74-6.8c7.07 0 14.22 1.6 19.11 4.77-1.15-3.98-3.62-7.5-7.03-10a19.88 19.88 0 0 0-11.83-3.82 19.94 19.94 0 0 0-11.82 3.8 19.02 19.02 0 0 0-7.06 9.97c4.58-3.14 11.6-4.71 18.63-4.71Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path fill-rule="evenodd" clip-rule="evenodd" d="M-10.24 2.64c-4.65 4.43-2.22 27.02 7.48 32.71 3.24 1.9 15.66 3.74 21.17 1.63 10.32-3.94 16.86-18.34 16.86-25.23 0-5.08-9.94-10.86-18.17-11.8C9.1-1-6-1.39-10.24 2.64Z" fill="url(#eyesShades-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M98.51 2.64c4.66 4.43 2.24 27.02-7.47 32.71-3.23 1.9-15.66 3.74-21.18 1.63C59.55 33.04 53 18.7 53 11.75 53 6.67 62.95.89 71.18-.05c7.97-.95 23.1-1.33 27.33 2.7Z" fill="url(#eyesShades-b)"/><path d="M109.83-2.7a.62.62 0 0 0-.48-.5c-8.12-1.39-30.47-2.95-42.45-.7A33.37 33.37 0 0 0 56.73-.04l-2.37 1.21A20.61 20.61 0 0 1 44.15 3.5c-3.55.12-7.08-.68-10.23-2.32l-2.37-1.2a33.23 33.23 0 0 0-10.17-3.89C9.4-6.1-12.93-4.59-21.07-3.2a.57.57 0 0 0-.47.5 33.81 33.81 0 0 0 0 9.3.6.6 0 0 0 .44.47s2.95.83 4.01 9.09c.84 6.53 4.21 22.03 17.7 24.5 3.63.6 7.3.92 11 .92 1.62.02 3.24-.07 4.85-.28 14.98-2.18 19.62-15.62 21.86-22.13.3-.86.55-1.58.78-2.16 1.48-3.55 2.64-3.93 5.06-3.93 1.9 0 3.58.3 5.05 3.93.22.58.49 1.32.78 2.16 2.23 6.46 6.89 19.9 21.84 22.13 5.3.5 10.62.28 15.85-.65 13.5-2.46 16.89-17.96 17.68-24.5 1.04-8.25 3.97-9.07 3.98-9.08a.6.6 0 0 0 .47-.47c.44-3.08.45-6.21.03-9.3Zm-9.87 18.58c-.77 6.71-3.66 15.7-9.21 18.96-3.08 1.81-15.26 3.66-20.64 1.6-10.25-3.92-16.52-18.2-16.52-24.7 0-4.42 9.08-10.22 17.69-11.22C74.88.1 78.48-.1 82.1-.1c6.8 0 13.54.8 16.03 3.17 1.78 1.71 2.52 6.86 1.83 12.8Zm-65.27-4.13c0 6.49-6.25 20.77-16.48 24.68-5.42 2.07-17.59.22-20.62-1.6-5.56-3.25-8.43-12.24-9.22-18.95-.69-5.9 0-11.1 1.86-12.8C-7.28.7-.54-.1 6.26-.1c3.61 0 7.22.2 10.81.62 8.55 1 17.62 6.77 17.62 11.23Z" fill="#000"/><defs><linearGradient id="eyesShades-a" x1="11.37" y1="37.92" x2="11.37" y2="-.68" gradientUnits="userSpaceOnUse"><stop offset=".32" stop-color="#121212"/><stop offset="1" stop-color="#474747"/></linearGradient><linearGradient id="eyesShades-b" x1="76.91" y1="-.68" x2="76.91" y2="37.92" gradientUnits="userSpaceOnUse"><stop stop-color="#474747"/><stop offset=".68" stop-color="#121212"/></linearGradient></defs></g></g></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#d84be5" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M25 24c4.5 3.87 25.04 4.13 30 0H25Z" fill="#000" style="mix-blend-mode:soft-light" opacity=".4"/><path d="M64.43 18.86h-48.5a1.47 1.47 0 1 1 0-2.79h48.5a1.47 1.47 0 1 1 0 2.8Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path fill-rule="evenodd" clip-rule="evenodd" d="M-10.24 2.64c-4.65 4.43-2.22 27.02 7.48 32.71 3.24 1.9 15.66 3.74 21.17 1.63 10.32-3.94 16.86-18.34 16.86-25.23 0-5.08-9.94-10.86-18.17-11.8C9.1-1-6-1.39-10.24 2.64Z" fill="url(#eyesShades-a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M98.51 2.64c4.66 4.43 2.24 27.02-7.47 32.71-3.23 1.9-15.66 3.74-21.18 1.63C59.55 33.04 53 18.7 53 11.75 53 6.67 62.95.89 71.18-.05c7.97-.95 23.1-1.33 27.33 2.7Z" fill="url(#eyesShades-b)"/><path d="M109.83-2.7a.62.62 0 0 0-.48-.5c-8.12-1.39-30.47-2.95-42.45-.7A33.37 33.37 0 0 0 56.73-.04l-2.37 1.21A20.61 20.61 0 0 1 44.15 3.5c-3.55.12-7.08-.68-10.23-2.32l-2.37-1.2a33.23 33.23 0 0 0-10.17-3.89C9.4-6.1-12.93-4.59-21.07-3.2a.57.57 0 0 0-.47.5 33.81 33.81 0 0 0 0 9.3.6.6 0 0 0 .44.47s2.95.83 4.01 9.09c.84 6.53 4.21 22.03 17.7 24.5 3.63.6 7.3.92 11 .92 1.62.02 3.24-.07 4.85-.28 14.98-2.18 19.62-15.62 21.86-22.13.3-.86.55-1.58.78-2.16 1.48-3.55 2.64-3.93 5.06-3.93 1.9 0 3.58.3 5.05 3.93.22.58.49 1.32.78 2.16 2.23 6.46 6.89 19.9 21.84 22.13 5.3.5 10.62.28 15.85-.65 13.5-2.46 16.89-17.96 17.68-24.5 1.04-8.25 3.97-9.07 3.98-9.08a.6.6 0 0 0 .47-.47c.44-3.08.45-6.21.03-9.3Zm-9.87 18.58c-.77 6.71-3.66 15.7-9.21 18.96-3.08 1.81-15.26 3.66-20.64 1.6-10.25-3.92-16.52-18.2-16.52-24.7 0-4.42 9.08-10.22 17.69-11.22C74.88.1 78.48-.1 82.1-.1c6.8 0 13.54.8 16.03 3.17 1.78 1.71 2.52 6.86 1.83 12.8Zm-65.27-4.13c0 6.49-6.25 20.77-16.48 24.68-5.42 2.07-17.59.22-20.62-1.6-5.56-3.25-8.43-12.24-9.22-18.95-.69-5.9 0-11.1 1.86-12.8C-7.28.7-.54-.1 6.26-.1c3.61 0 7.22.2 10.81.62 8.55 1 17.62 6.77 17.62 11.23Z" fill="#000"/><defs><linearGradient id="eyesShades-a" x1="11.37" y1="37.92" x2="11.37" y2="-.68" gradientUnits="userSpaceOnUse"><stop offset=".32" stop-color="#121212"/><stop offset="1" stop-color="#474747"/></linearGradient><linearGradient id="eyesShades-b" x1="76.91" y1="-.68" x2="76.91" y2="37.92" gradientUnits="userSpaceOnUse"><stop stop-color="#474747"/><stop offset=".68" stop-color="#121212"/></linearGradient></defs></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#059ff2" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path d="M75.08 12a1.37 1.37 0 0 1 1.26.83 15.26 15.26 0 0 1-15.67 22.03 15.2 15.2 0 0 1-9.53-5.26 1.48 1.48 0 0 1 1.15-2.09c.3-.04.61.02.88.17a12.52 12.52 0 0 0 16.54 2.35c4.01-2.7 7.51-8.54 4.1-16.07a1.48 1.48 0 0 1 .77-1.83c.16-.08.34-.12.51-.13Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M87.22 13.98c0-7.2-5.82-13.04-13-13.04s-13 5.84-13 13.04v3.92c0 7.2 5.82 13.04 13 13.04s13-5.83 13-13.04v-3.92Z" fill="#000"/><path d="M70 10.48a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.24 19.3a5.32 5.32 0 1 0 0-10.66 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M26.22 13.98c0-7.2-5.82-13.04-13-13.04s-13 5.84-13 13.04v3.92c0 7.2 5.82 13.04 13 13.04s13-5.83 13-13.04v-3.92Z" fill="#000"/><path d="M9 10.48A2.29 2.29 0 1 0 9 5.9a2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M13.24 19.3a5.32 5.32 0 1 0 0-10.66 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M84.33-5.7H65.45C58.63-5.7 53.1-.2 53.1 6.6v18.8c0 6.79 5.52 12.3 12.34 12.3h18.88c6.81 0 12.34-5.51 12.34-12.3V6.6c0-6.8-5.53-12.3-12.34-12.3Z" fill="url(#eyesGlasses-a)"/><path d="M21.3-5.7H2.42C-4.4-5.7-9.92-.2-9.92 6.6v18.8c0 6.79 5.52 12.3 12.34 12.3H21.3c6.81 0 12.34-5.51 12.34-12.3V6.6c0-6.8-5.53-12.3-12.34-12.3Z" fill="url(#eyesGlasses-b)"/><g fill="#000"><path d="M21.06 40.12H2.18A14.83 14.83 0 0 1-8.2 35.81a14.71 14.71 0 0 1-4.33-10.34V6.6c.02-3.87 1.58-7.59 4.34-10.34A14.85 14.85 0 0 1 2.18-8.06h18.88c3.92.02 7.66 1.58 10.42 4.35 2.76 2.76 4.31 6.5 4.31 10.4v18.7A14.62 14.62 0 0 1 26.71 39c-1.79.74-3.7 1.12-5.65 1.12ZM2.18-3.26A9.96 9.96 0 0 0-7 2.83a9.85 9.85 0 0 0-.76 3.78v18.8a9.85 9.85 0 0 0 9.9 9.86h18.92a9.93 9.93 0 0 0 9.9-9.86V6.6a9.87 9.87 0 0 0-9.9-9.86H2.18ZM84.33 40.12H65.46a14.83 14.83 0 0 1-10.39-4.31 14.71 14.71 0 0 1-4.33-10.34V6.6c.02-3.87 1.58-7.59 4.34-10.34a14.85 14.85 0 0 1 10.38-4.32h18.87c3.9.03 7.65 1.59 10.41 4.35s4.31 6.5 4.32 10.4v18.7c0 3.9-1.56 7.64-4.32 10.4a14.83 14.83 0 0 1-10.41 4.33ZM65.46-3.26a9.93 9.93 0 0 0-9.9 9.87v18.8a9.85 9.85 0 0 0 9.9 9.86h18.87a9.93 9.93 0 0 0 9.9-9.86V6.6a9.85 9.85 0 0 0-9.9-9.86H65.46Z"/><path d="M53.1 10.64H33.4v4.89h19.7v-4.89Z"/></g><defs><linearGradient id="eyesGlasses-a" x1="2332.67" y1="1561.82" x2="3621.21" y2="1561.82" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#969696" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity=".3"/></linearGradient><linearGradient id="eyesGlasses-b" x1="2269.64" y1="1561.82" x2="3558.18" y2="1561.82" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".3"/><stop offset=".5" stop-color="#969696" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity=".3"/></linearGradient></defs></g></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Fun Emoji Set</dc:title><dc:creator>Davis Uche</dc:creator><dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/968125295144990435</dc:source><dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license><dc:rights>Remix of „Fun Emoji Set” (https://www.figma.com/community/file/968125295144990435) by „Davis Uche”, licensed under „CC BY 4.0” (https://creativecommons.org/licenses/by/4.0/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="200" height="200" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#f6d594" width="200" height="200" x="0" y="0" /><g transform="matrix(1.5625 0 0 1.5625 37.5 110.94)"><path fill-rule="evenodd" clip-rule="evenodd" d="M20 28c8-13 29-13 37 0-13-6-24-6-37 0Z" fill="#1E120B"/><path d="M19.37 29a.37.37 0 0 1-.16-.04.4.4 0 0 1-.13-.1.44.44 0 0 1 0-.5C22.76 21.9 30.28 18 38.7 18h.15c8.29 0 15.45 3.9 19.07 10.32a.48.48 0 0 1 0 .52.4.4 0 0 1-.22.13.39.39 0 0 1-.25-.03c-13.42-7.1-24.4-7.1-37.92 0l-.16.06Zm19.17-6.29a39.78 39.78 0 0 1 17.96 4.72c-3.73-5.4-10.27-8.61-17.61-8.64h-.12c-7.56 0-14.37 3.26-18.17 8.64a40.03 40.03 0 0 1 17.94-4.72Z" fill="#000"/><path d="M104.7-9.65 57.64 28.27a32.88 32.88 0 0 0-5.27-2.3 36.9 36.9 0 0 0-5.28-1.56c-1.23-.26-2.48-.5-3.75-.66l53.53-43.12a3.13 3.13 0 0 1 4.4.48l3.92 4.83c.3.37.52.82.61 1.3a3.12 3.12 0 0 1-1.1 3.11Z" fill="#E2E0E0" stroke="#777" stroke-width=".39" stroke-miterlimit="10"/><path opacity=".66" d="M105.8-12.76c-.2.43-.5.8-.88 1.1L56.25 27.56l-.3-.13a32.88 32.88 0 0 0-3.58-1.47 37.79 37.79 0 0 0-5.28-1.55c-1.23-.26-2.48-.5-3.75-.66l53.53-43.12a3.13 3.13 0 0 1 4.4.48l3.92 4.83c.3.37.52.82.61 1.3Z" fill="#fff"/><path d="M96.55-11.11a1.46 1.46 0 0 0-2.08-.22L49.4 24.97c1.14.32 2.25.69 3.37 1.1L96.4-9.03a1.47 1.47 0 0 0 .14-2.08Z" fill="#CECCCC"/><path d="M83.9-.92a1.46 1.46 0 0 0-2.13-.24L49.4 24.97c1.14.32 2.25.69 3.37 1.1l30.9-24.94a1.47 1.47 0 0 0 .24-2.05Z" fill="#EF5656"/><path d="M75.58 3.45a.36.36 0 0 1-.28-.13l-.73-.91a.35.35 0 0 1 0-.49.37.37 0 0 1 .5 0l.73.9a.37.37 0 0 1 0 .5.3.3 0 0 1-.22.14ZM63.17 13.45a.37.37 0 0 1-.28-.13l-.73-.9a.37.37 0 0 1 .32-.55c.09 0 .17.03.24.09l.73.9a.35.35 0 0 1 0 .5.32.32 0 0 1-.28.1ZM51.04 23.28a.34.34 0 0 1-.28-.13l-.73-.91a.35.35 0 0 1 0-.5.37.37 0 0 1 .5 0l.71.9a.35.35 0 0 1 0 .5.34.34 0 0 1-.2.14ZM69.5 8.35a.3.3 0 0 1-.27-.13L67.77 6.4a.36.36 0 0 1 .55-.44l1.47 1.82a.35.35 0 0 1 0 .5.44.44 0 0 1-.3.07ZM56.9 18.54a.37.37 0 0 1-.29-.13l-1.46-1.82a.37.37 0 0 1 0-.5.35.35 0 0 1 .5 0l1.46 1.82a.37.37 0 0 1 0 .5.44.44 0 0 1-.22.13Z" fill="#000"/></g><g transform="matrix(1.5625 0 0 1.5625 31.25 59.38)"><path d="M77.41 26.02h-6.59c-9.7 0-9.7.07-9.7-9.7v-4.09A12.2 12.2 0 0 1 73.32.03h1.6a12.2 12.2 0 0 1 12.2 12.2v4.16c-.03 9.7-.03 9.63-9.7 9.63Z" fill="#000"/><path d="M69.87 9.56a2.29 2.29 0 1 0 0-4.58 2.29 2.29 0 0 0 0 4.58Z" fill="#fff"/><path opacity=".1" d="M74.11 18.37a5.32 5.32 0 1 0 0-10.65 5.32 5.32 0 0 0 0 10.65Z" fill="#fff"/><path d="M1.82 19.02c-.17 0-.34-.03-.5-.09a1.68 1.68 0 0 1-1-.84 1.75 1.75 0 0 1-.13-1.32 13.54 13.54 0 0 1 4.86-7.1 13.1 13.1 0 0 1 8.07-2.65c2.9-.02 5.73.91 8.06 2.67a13.63 13.63 0 0 1 4.86 7.08c.13.44.09.91-.12 1.32a1.72 1.72 0 0 1-2.3.73 1.69 1.69 0 0 1-.84-1.03c-.62-2.13-1.9-4-3.66-5.32a9.92 9.92 0 0 0-6.06-2c-2.17-.01-4.28.7-6.02 2.02a10.26 10.26 0 0 0-3.64 5.3c-.1.35-.32.65-.6.87a1.7 1.7 0 0 1-.98.36Z" fill="#000"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+42
View File
@@ -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;
}
+107
View File
@@ -0,0 +1,107 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { authService } from "@/services/authService";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { LanguageProvider } from "@/contexts/LanguageContext";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import ServiceDetail from "./pages/ServiceDetail";
import Settings from "./pages/Settings";
import Profile from "./pages/Profile";
import NotFound from "./pages/NotFound";
// Create a Protected route component
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = authService.isAuthenticated();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
const queryClient = new QueryClient();
const App = () => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check authentication status when the app loads
const checkAuth = async () => {
try {
// Just check the auth state
authService.isAuthenticated();
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
if (isLoading) {
return <div className="flex items-center justify-center h-screen bg-background text-foreground">Loading...</div>;
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<LanguageProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/service/:id"
element={
<ProtectedRoute>
<ServiceDetail />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
</LanguageProvider>
</ThemeProvider>
</QueryClientProvider>
);
};
export default App;
+34
View File
@@ -0,0 +1,34 @@
// API routes handler
import realtime from './realtime';
/**
* Simple API router for client-side application
*/
const api = {
/**
* Handle API requests
*/
async handleRequest(path, method, body) {
console.log(`API request: ${method} ${path}`, body);
// Route to the appropriate handler
if (path === '/api/realtime') {
console.log("Routing to realtime handler");
return await realtime(body);
}
// Return 404 for unknown routes
console.error(`Endpoint not found: ${path}`);
return {
status: 404,
json: {
ok: false,
error_code: 404,
description: "Endpoint not found"
}
};
}
};
export default api;
+195
View File
@@ -0,0 +1,195 @@
// This file handles realtime notifications in a client-side environment
// In a production app, this would be a server-side endpoint
console.log("API Realtime endpoint loaded");
// Simple implementation that simulates sending notifications
export default async function handler(req) {
try {
console.log("Realtime API call received:", JSON.stringify({
...req,
botToken: req?.botToken ? "[REDACTED]" : undefined
}, null, 2));
// Make sure we're accessing the body correctly
const body = req || {};
const { type } = body;
if (!type) {
console.error("Missing notification type parameter");
return {
status: 400,
json: {
ok: false,
error_code: 400,
description: "Missing notification type"
}
};
}
// Handle Telegram notifications
if (type === "telegram") {
const { chatId, botToken, message } = body;
console.log("Telegram notification request details:");
console.log("- Chat ID:", chatId);
console.log("- Bot token available:", !!botToken);
console.log("- Message length:", message?.length || 0);
if (!chatId || !botToken || !message) {
console.error("Missing required parameters for Telegram notification", {
hasChatId: !!chatId,
hasBotToken: !!botToken,
hasMessage: !!message
});
return {
status: 400,
json: {
ok: false,
error_code: 400,
description: "Missing required Telegram parameters"
}
};
}
try {
console.log("Attempting to call real Telegram API");
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/sendMessage`;
console.log("Calling Telegram API:", telegramApiUrl.replace(botToken, "[REDACTED]"));
const response = await fetch(telegramApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chat_id: chatId,
text: message,
parse_mode: 'HTML'
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`Telegram API error (${response.status}):`, errorText);
try {
// Try to parse error as JSON if possible
const errorJson = JSON.parse(errorText);
return {
status: response.status,
json: errorJson
};
} catch (e) {
// If parsing fails, return the raw error
return {
status: response.status,
json: {
ok: false,
error_code: response.status,
description: `Telegram API error: ${errorText}`
}
};
}
}
const result = await response.json();
console.log("Telegram API response:", JSON.stringify(result, null, 2));
if (!result.ok) {
console.error("Telegram API error:", result);
return {
status: response.status,
json: result
};
}
console.log("Successfully sent message to Telegram!");
return {
status: 200,
json: {
ok: true,
result: result.result,
description: "Message sent successfully to Telegram"
}
};
} catch (error) {
console.error("Error calling Telegram API:", error);
// Return detailed error information
return {
status: 500,
json: {
ok: false,
error_code: 500,
description: `Error sending Telegram message: ${error instanceof Error ? error.message : "Unknown error"}`
}
};
}
}
// Handle Signal notifications
else if (type === "signal") {
const { signalNumber, message } = body;
if (!signalNumber || !message) {
console.error("Missing required parameters for Signal notification", {
hasSignalNumber: !!signalNumber,
hasMessage: !!message
});
return {
status: 400,
json: {
ok: false,
error_code: 400,
description: "Missing required Signal parameters"
}
};
}
// Simulate sending Signal message
console.log(`[SIMULATION] Sending Signal message to ${signalNumber}: ${message}`);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
// Return success response
return {
status: 200,
json: {
ok: true,
result: {
id: Math.floor(Math.random() * 10000),
timestamp: Date.now(),
delivered: true
},
description: "Signal message sent successfully (simulated)"
}
};
} else {
// Return error for unsupported notification type
console.error("Unsupported notification type:", type);
return {
status: 400,
json: {
ok: false,
error_code: 400,
description: `Unsupported notification type: ${type}`
}
};
}
} catch (error) {
console.error("Error in realtime handler:", error);
return {
status: 500,
json: {
ok: false,
error_code: 500,
description: error.message || "Internal server error"
}
};
}
}
@@ -0,0 +1,73 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { Service } from "@/services/serviceService";
import { StatusCards } from "./StatusCards";
import { ServiceFilters } from "./ServiceFilters";
import { ServicesTable } from "./ServicesTable";
import { AddServiceDialog } from "@/components/services/AddServiceDialog";
interface DashboardContentProps {
services: Service[];
isLoading: boolean;
error: Error | null;
}
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
const [filter, setFilter] = useState<string>("all");
const [searchTerm, setSearchTerm] = useState<string>("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
// Filter services based on search term and type filter
const filteredServices = services.filter(service => {
const matchesSearch = service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(service.url && service.url.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesFilter = filter === 'all' || service.type.toLowerCase() === filter.toLowerCase();
return matchesSearch && matchesFilter;
});
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
<p>Error loading service data.</p>
<Button onClick={() => window.location.reload()}>Retry</Button>
</div>
);
}
return (
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
<div className="flex flex-col flex-1">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-foreground">Overview</h2>
<Button
className="text-primary-foreground"
onClick={() => setIsAddDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-2" /> New Service
</Button>
</div>
<StatusCards services={services} />
<ServiceFilters
filter={filter}
setFilter={setFilter}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
servicesCount={filteredServices.length}
/>
<div className="flex-1 flex flex-col pb-6">
<ServicesTable services={filteredServices} />
</div>
</div>
<AddServiceDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
/>
</main>
);
};
@@ -0,0 +1,190 @@
import { Button } from "@/components/ui/button";
import { AuthUser } from "@/services/authService";
import { useTheme } from "@/contexts/ThemeContext";
import { Moon, PanelLeft, PanelLeftClose, Sun, Globe, FileText, Github, Twitter, MessageSquare, Bell, User, Settings, LogOut, Grid3x3 } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useNavigate } from "react-router-dom";
import { useSystemSettings } from "@/hooks/useSystemSettings";
interface HeaderProps {
currentUser: AuthUser | null;
onLogout: () => void;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
}
export const Header = ({
currentUser,
onLogout,
sidebarCollapsed,
toggleSidebar
}: HeaderProps) => {
const { theme, toggleTheme } = useTheme();
const { language, setLanguage, t } = useLanguage();
const [greeting, setGreeting] = useState<string>("");
const { systemName } = useSystemSettings();
const navigate = useNavigate();
// Set greeting based on time of day
useEffect(() => {
const updateGreeting = () => {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) {
setGreeting(t("goodMorning"));
} else if (hour >= 12 && hour < 18) {
setGreeting(t("goodAfternoon"));
} else {
setGreeting(t("goodEvening"));
}
};
updateGreeting();
// Update greeting if language changes
// You could add a timer to update the greeting throughout the day
// but that's typically unnecessary since most sessions aren't active across time periods
}, [language, t]);
// Log avatar data for debugging
useEffect(() => {
if (currentUser) {
console.log("Avatar URL in Header:", currentUser.avatar);
}
}, [currentUser]);
// Prepare avatar URL - ensure it displays correctly if it's a local profile image
let avatarUrl = '';
if (currentUser?.avatar) {
// If it's a relative path from the public folder, make sure it's resolved properly
if (currentUser.avatar.startsWith('/upload/profile/')) {
avatarUrl = currentUser.avatar;
} else {
avatarUrl = currentUser.avatar;
}
console.log("Final avatar URL:", avatarUrl);
}
return (
<header className="relative bg-background border-b border-border px-6 flex justify-between items-center py-[12px] overflow-hidden">
{/* Grid Pattern Overlay - Similar to StatusCards */}
<div className="absolute inset-0 z-0">
<div
className="w-full h-full"
style={{
backgroundImage: `linear-gradient(${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px),
linear-gradient(90deg, ${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
>
<div className="w-full h-full backdrop-blur-[1px]"></div>
</div>
</div>
{/* Header Content */}
<div className="flex items-center gap-4 z-10">
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</Button>
<div className="flex items-center space-x-2">
<Grid3x3 className="h-5 w-5 text-green-500" />
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 </h1>
</div>
</div>
<div className="flex items-center space-x-4 z-10">
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border" onClick={toggleTheme}>
<span className="sr-only">Toggle theme</span>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">{t("language")}</span>
<Globe className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
{t("english")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
{t("khmer")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Documentation */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">{t("documentation")}</span>
<FileText className="w-4 h-4" />
</Button>
{/* GitHub */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">GitHub</span>
<Github className="w-4 h-4" />
</Button>
{/* X (Twitter) */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">X (Twitter)</span>
<Twitter className="w-4 h-4" />
</Button>
{/* Discord (replaced with MessageSquare) */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">Discord</span>
<MessageSquare className="w-4 h-4" />
</Button>
{/* Notifications */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">{t("notifications")}</span>
<Bell className="w-4 h-4" />
</Button>
{/* User Profile Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="h-8 w-8 cursor-pointer ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
</AvatarFallback>}
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-3 p-2">
<Avatar className="h-10 w-10">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
</AvatarFallback>}
</Avatar>
<div className="flex flex-col space-y-1">
<span className="font-medium">{currentUser?.name || 'User'}</span>
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/profile")}>
<User className="mr-2 h-4 w-4" />
<span>{t("profile")}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/settings")}>
<Settings className="mr-2 h-4 w-4" />
<span>{t("settings")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout} className="text-red-500 focus:text-red-500">
<LogOut className="mr-2 h-4 w-4" />
<span>{t("logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
};
@@ -0,0 +1,54 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Plus } from "lucide-react";
interface ServiceFiltersProps {
filter: string;
setFilter: (value: string) => void;
searchTerm: string;
setSearchTerm: (value: string) => void;
servicesCount: number;
}
export const ServiceFilters = ({
filter,
setFilter,
searchTerm,
setSearchTerm,
servicesCount
}: ServiceFiltersProps) => {
return (
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center">
<h3 className="text-xl font-semibold mr-2 text-foreground">Currently Monitoring</h3>
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
{servicesCount}
</span>
</div>
<div className="flex space-x-4">
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-40 bg-card border-border">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="HTTP">HTTP</SelectItem>
<SelectItem value="PING">PING</SelectItem>
<SelectItem value="TCP">TCP</SelectItem>
<SelectItem value="DNS">DNS</SelectItem>
</SelectContent>
</Select>
<div className="relative">
<Input
className="w-72 bg-card border-border"
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</div>
);
};
@@ -0,0 +1,15 @@
import { Service } from "@/types/service.types";
import { ServicesTableContainer } from "@/components/services/ServicesTableContainer";
interface ServicesTableProps {
services: Service[];
}
export const ServicesTable = ({ services }: ServicesTableProps) => {
return (
<div className="flex-1 flex flex-col h-full">
<ServicesTableContainer services={services} />
</div>
);
};
@@ -0,0 +1,141 @@
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, Settings, User, UserCog, Bell, FileClock, Database, RefreshCw, Info, ChevronDown, BookOpen } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { Link, useLocation } from "react-router-dom";
import { useState, useEffect } from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useLanguage } from "@/contexts/LanguageContext";
interface SidebarProps {
collapsed: boolean;
}
export const Sidebar = ({
collapsed
}: SidebarProps) => {
const {
theme
} = useTheme();
const {
t
} = useLanguage();
const location = useLocation();
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
// Update active settings item based on URL
useEffect(() => {
if (location.pathname === '/settings') {
const params = new URLSearchParams(location.search);
const panel = params.get('panel');
if (panel) {
setActiveSettingsItem(panel);
}
}
}, [location]);
const handleSettingsItemClick = (item: string) => {
setActiveSettingsItem(item);
};
const getMenuItemClasses = (isActive: boolean) => {
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
};
// New larger icon size for the main menu
const mainIconSize = "h-6 w-6";
return <div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}>
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
<span className="text-white font-bold">C</span>
</div>
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
</div>
<nav className="my-2 mx-1 py-1 px-1">
<Link to="/dashboard" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/dashboard' ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Globe className={`${mainIconSize} text-purple-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("uptimeMonitoring")}</span>}
</Link>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Boxes className={`${mainIconSize} text-blue-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Radar className={`${mainIconSize} text-cyan-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Calendar className={`${mainIconSize} text-emerald-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<BarChart2 className={`${mainIconSize} text-amber-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<LineChart className={`${mainIconSize} text-rose-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("reports")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<FileText className={`${mainIconSize} text-indigo-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("apiDocumentation")}</span>}
</div>
</nav>
{!collapsed && <div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
<div className="flex items-center">
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
</div>
<div className="flex items-center">
<Settings className="h-4 w-4 mr-1" />
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
<ScrollArea className="h-full">
<div className="space-y-2 pr-4">
<Link to={`/settings?panel=general`} className={getMenuItemClasses(activeSettingsItem === 'general')} onClick={() => handleSettingsItemClick('general')}>
<Settings className="h-4 w-4 mr-2" />
<span className="text-sm">{t("generalSettings")}</span>
</Link>
<Link to={`/settings?panel=users`} className={getMenuItemClasses(activeSettingsItem === 'users')} onClick={() => handleSettingsItemClick('users')}>
<User className="h-4 w-4 mr-2" />
<span className="text-sm">{t("userManagement")}</span>
</Link>
<Link to={`/settings?panel=notifications`} className={getMenuItemClasses(activeSettingsItem === 'notifications')} onClick={() => handleSettingsItemClick('notifications')}>
<Bell className="h-4 w-4 mr-2" />
<span className="text-sm">{t("notificationSettings")}</span>
</Link>
<Link to={`/settings?panel=templates`} className={getMenuItemClasses(activeSettingsItem === 'templates')} onClick={() => handleSettingsItemClick('templates')}>
<BookOpen className="h-4 w-4 mr-2" />
<span className="text-sm">{t("alertsTemplates")}</span>
</Link>
<div className={getMenuItemClasses(false)}>
<Database className="h-4 w-4 mr-2" />
<span className="text-sm">{t("dataRetention")}</span>
</div>
<Link to={`/settings?panel=about`} className={getMenuItemClasses(activeSettingsItem === 'about')} onClick={() => handleSettingsItemClick('about')}>
<Info className="h-4 w-4 mr-2" />
<span className="text-sm">{t("aboutSystem")}</span>
</Link>
</div>
</ScrollArea>
</div>
</CollapsibleContent>
</Collapsible>
</div>}
{collapsed && <div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
<Link to="/settings">
<Settings className={`${mainIconSize} text-purple-400`} />
</Link>
</div>}
</div>;
};
@@ -0,0 +1,152 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react";
import { Service } from "@/services/serviceService";
import { useTheme } from "@/contexts/ThemeContext";
interface StatusCardsProps {
services: Service[];
}
export const StatusCards = ({ services }: StatusCardsProps) => {
// Count services by status
const upServices = services.filter(s => s.status === "up").length;
const downServices = services.filter(s => s.status === "down").length;
const pausedServices = services.filter(s => s.status === "paused").length;
const warningServices = services.filter(s => s.responseTime > 1000).length;
// Get current theme to adjust card styles
const { theme } = useTheme();
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 w-full">
{/* Up Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
: "linear-gradient(135deg, #43a047 0%, #66bb6a 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">UP SERVICES</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{upServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<ArrowUp className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
{/* Down Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
: "linear-gradient(135deg, #e53935 0%, #ef5350 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">DOWN SERVICES</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{downServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<ArrowDown className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
{/* Paused Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
: "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">PAUSED SERVICES</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{pausedServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<Pause className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
{/* Warning Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
: "linear-gradient(135deg, #ff9800 0%, #ffb74d 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">WARNING SERVICES</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{warningServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<AlertTriangle className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,191 @@
import { useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { Eye, EyeOff } from "lucide-react";
import { pb } from "@/lib/pocketbase";
import { authService } from "@/services/authService";
// Password change form schema
const passwordFormSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string().min(8, "Confirm password is required"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type PasswordFormValues = z.infer<typeof passwordFormSchema>;
interface ChangePasswordFormProps {
userId: string;
}
export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const { toast } = useToast();
const form = useForm<PasswordFormValues>({
resolver: zodResolver(passwordFormSchema),
defaultValues: {
currentPassword: "",
newPassword: "",
confirmPassword: "",
},
});
async function onSubmit(data: PasswordFormValues) {
setIsSubmitting(true);
try {
// PocketBase requires the old password along with the new one
await pb.collection('users').update(userId, {
oldPassword: data.currentPassword,
password: data.newPassword,
passwordConfirm: data.confirmPassword,
});
// Refresh auth data to ensure token remains valid
await authService.refreshUserData();
toast({
title: "Password updated",
description: "Your password has been changed successfully.",
});
// Reset the form
form.reset();
} catch (error) {
console.error("Password change error:", error);
let errorMessage = "Failed to update password. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
}
toast({
title: "Password change failed",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showCurrentPassword ? "text" : "password"}
placeholder="Your current password"
{...field}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
{showCurrentPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">
{showCurrentPassword ? "Hide password" : "Show password"}
</span>
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder="New password"
{...field}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowNewPassword(!showNewPassword)}
>
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">
{showNewPassword ? "Hide password" : "Show password"}
</span>
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm new password"
{...field}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">
{showConfirmPassword ? "Hide password" : "Show password"}
</span>
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Change Password"}
</Button>
</form>
</Form>
);
}
@@ -0,0 +1,74 @@
import { useState } from "react";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { User } from "@/services/userService";
import { UserProfileDetails } from "./UserProfileDetails";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChangePasswordForm } from "./ChangePasswordForm";
import { UpdateProfileForm } from "./UpdateProfileForm";
interface ProfileContentProps {
currentUser: User | null;
}
export function ProfileContent({ currentUser }: ProfileContentProps) {
const [activeTab, setActiveTab] = useState("details");
if (!currentUser) {
return (
<Card>
<CardHeader>
<CardTitle>User Profile</CardTitle>
<CardDescription>Your profile information could not be loaded</CardDescription>
</CardHeader>
</Card>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">My Profile</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left column - Profile summary card */}
<Card className="md:col-span-1">
<CardHeader>
<CardTitle>Profile Summary</CardTitle>
</CardHeader>
<CardContent>
<UserProfileDetails user={currentUser} />
</CardContent>
</Card>
{/* Right column - Profile tabs for edit and password change */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>My Account</CardTitle>
<CardDescription>Manage your account settings</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="details">Profile Details</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
</TabsList>
<TabsContent value="details" className="pt-4">
<UpdateProfileForm user={currentUser} />
</TabsContent>
<TabsContent value="security" className="pt-4">
<ChangePasswordForm userId={currentUser.id} />
</TabsContent>
</Tabs>
</CardContent>
<CardFooter className="text-sm text-muted-foreground">
Last updated: {new Date(currentUser.updated).toLocaleString()}
</CardFooter>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
import { useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { User, userService } from "@/services/userService";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { authService } from "@/services/authService";
// Profile update form schema
const profileFormSchema = z.object({
full_name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
username: z.string().min(3, {
message: "Username must be at least 3 characters.",
}),
email: z.string().email({
message: "Please enter a valid email address.",
}),
});
type ProfileFormValues = z.infer<typeof profileFormSchema>;
interface UpdateProfileFormProps {
user: User;
}
export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
// Initialize the form with current user data
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema),
defaultValues: {
full_name: user.full_name || "",
username: user.username || "",
email: user.email || "",
},
});
async function onSubmit(data: ProfileFormValues) {
setIsSubmitting(true);
try {
await userService.updateUser(user.id, {
full_name: data.full_name,
username: data.username,
email: data.email,
});
// Refresh user data in auth context
await authService.refreshUserData();
toast({
title: "Profile updated",
description: "Your profile information has been updated successfully.",
});
} catch (error) {
console.error("Profile update error:", error);
let errorMessage = "Failed to update profile. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
}
toast({
title: "Update failed",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Your full name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="your.email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</form>
</Form>
);
}
@@ -0,0 +1,75 @@
import { User } from "@/services/userService";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Mail, User as UserIcon } from "lucide-react";
interface UserProfileDetailsProps {
user: User;
}
export function UserProfileDetails({ user }: UserProfileDetailsProps) {
// Format dates
const createdDate = new Date(user.created).toLocaleDateString();
// Get avatar or initials
const getInitials = () => {
if (user.full_name) {
return user.full_name.split(' ')
.map(name => name[0])
.join('')
.toUpperCase();
}
return user.username[0].toUpperCase();
};
return (
<div className="flex flex-col items-center space-y-4">
<Avatar className="h-32 w-32">
{user.avatar ? (
<AvatarImage src={user.avatar} alt={user.full_name || user.username} />
) : (
<AvatarFallback className="text-3xl bg-primary/20 text-primary">
{getInitials()}
</AvatarFallback>
)}
</Avatar>
<div className="space-y-1 text-center">
<h2 className="text-xl font-bold">{user.full_name || user.username}</h2>
<div className="flex items-center justify-center text-sm text-muted-foreground gap-1">
<Mail className="h-3 w-3" />
<span>{user.email}</span>
</div>
<div className="flex items-center justify-center text-sm text-muted-foreground gap-1">
<UserIcon className="h-3 w-3" />
<span>@{user.username}</span>
</div>
</div>
<div className="w-full pt-2">
<div className="flex flex-wrap justify-center gap-2 pt-2">
{user.role && (
<Badge variant="secondary" className="px-2 py-1">
{user.role}
</Badge>
)}
<Badge variant={user.isActive ? "default" : "outline"} className="px-2 py-1">
{user.isActive ? "Active" : "Inactive"}
</Badge>
{user.verified && (
<Badge className="bg-green-600 hover:bg-green-700 px-2 py-1">
Verified
</Badge>
)}
</div>
<div className="border-t border-border mt-4 pt-4">
<p className="text-sm text-muted-foreground">
Member since: {createdDate}
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ServiceForm } from "./ServiceForm";
import { ScrollArea } from "@/components/ui/scroll-area";
interface AddServiceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) {
const handleSuccess = () => {
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl">Create New Service</DialogTitle>
<DialogDescription className="text-gray-400">
Fill in the details to create a new service to monitor.
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="pr-2">
<ServiceForm onSuccess={handleSuccess} onCancel={handleCancel} />
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,156 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { format, subDays, subHours, subMonths, subWeeks, subYears } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export type DateRangeOption = '60min' | '24h' | '7d' | '30d' | '1y' | 'custom';
interface DateRangeFilterProps {
onRangeChange: (startDate: Date, endDate: Date, option: DateRangeOption) => void;
selectedOption?: DateRangeOption;
}
// Define a proper type for the date range
interface DateRange {
from: Date | undefined;
to: Date | undefined;
}
export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateRangeFilterProps) {
const [currentOption, setCurrentOption] = useState<DateRangeOption>(selectedOption);
const [customDateRange, setCustomDateRange] = useState<DateRange>({
from: undefined,
to: undefined,
});
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const handleOptionChange = (value: string) => {
const option = value as DateRangeOption;
setCurrentOption(option);
const now = new Date();
let startDate: Date;
switch (option) {
case '60min':
// Ensure we're getting exactly 60 minutes ago
startDate = new Date(now.getTime() - 60 * 60 * 1000);
console.log(`60min option selected: ${startDate.toISOString()} to ${now.toISOString()}`);
break;
case '24h':
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case '7d':
startDate = subDays(now, 7);
break;
case '30d':
startDate = subDays(now, 30);
break;
case '1y':
startDate = subYears(now, 1);
break;
case 'custom':
// Don't trigger onRangeChange for custom until both dates are selected
setIsCalendarOpen(true);
return;
default:
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
}
console.log(`DateRangeFilter: Option changed to ${option}, date range: ${startDate.toISOString()} to ${now.toISOString()}`);
onRangeChange(startDate, now, option);
};
// Handle custom date range selection
const handleCustomRangeSelect = (range: DateRange | undefined) => {
if (!range) {
return;
}
setCustomDateRange(range);
if (range.from && range.to) {
// Ensure that we have both from and to dates before triggering the change
const startOfDay = new Date(range.from);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(range.to);
endOfDay.setHours(23, 59, 59, 999);
console.log(`DateRangeFilter: Custom range selected: ${startOfDay.toISOString()} to ${endOfDay.toISOString()}`);
onRangeChange(startOfDay, endOfDay, 'custom');
setIsCalendarOpen(false);
}
};
return (
<div className="flex items-center space-x-2">
<Select value={currentOption} onValueChange={handleOptionChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="60min">Last 60 minutes</SelectItem>
<SelectItem value="24h">Last 24 hours</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="1y">Last year</SelectItem>
<SelectItem value="custom">Custom range</SelectItem>
</SelectContent>
</Select>
{currentOption === 'custom' && (
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
className={cn(
"w-[280px] justify-start text-left font-normal",
!customDateRange.from && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{customDateRange.from ? (
customDateRange.to ? (
<>
{format(customDateRange.from, "LLL dd, y")} -{" "}
{format(customDateRange.to, "LLL dd, y")}
</>
) : (
format(customDateRange.from, "LLL dd, y")
)
) : (
"Pick a date range"
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center">
<Calendar
mode="range"
selected={customDateRange}
onSelect={handleCustomRangeSelect}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
)}
</div>
);
}
@@ -0,0 +1,87 @@
import React from "react";
import { Clock, TimerOff } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface LastCheckedTimeProps {
lastCheckedTime: string;
status?: string;
interval?: number;
}
export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => {
// Format the time without seconds to display a static time
const formatTimeWithoutSeconds = (timeString: string) => {
try {
const date = new Date(timeString);
// Check if it's a valid date
if (isNaN(date.getTime())) {
// If it's already in HH:MM format, just return it
if (timeString.includes(':') && !timeString.includes(':00:')) {
return timeString;
}
return timeString;
}
// Format to only show hours and minutes (HH:MM)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch (e) {
return timeString;
}
};
// Get the formatted time without creating a new Date for paused services
const formattedTime = formatTimeWithoutSeconds(lastCheckedTime);
// Explicitly prevent real-time updates for paused services
const isPaused = status === "paused";
// Format the interval for display
const formatInterval = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
return `${minutes}min`;
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 text-sm text-gray-400 cursor-help">
{isPaused ? (
<TimerOff className="h-4 w-4" />
) : (
<Clock className="h-4 w-4" />
)}
<span>
{isPaused ? "Paused at " : ""}
{formattedTime}
</span>
</div>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">
{isPaused ? "Monitoring Paused" : "Last Check Details"}
</div>
<div>
{isPaused ? "No automatic checks" : `Checked at ${formattedTime}`}
</div>
{interval && !isPaused && (
<div>
Check interval: {formatInterval(interval)}
</div>
)}
<div className="text-gray-400 text-[10px]">
{new Date(lastCheckedTime).toLocaleString()}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -0,0 +1,15 @@
export function LoadingState() {
return (
<div className="flex items-center justify-center h-screen bg-background text-foreground">
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
<div className="relative flex items-center justify-center mb-4">
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
<h3 className="text-xl font-medium mb-1">Loading server data</h3>
<p className="text-muted-foreground">Please wait while we retrieve your information...</p>
</div>
</div>
);
}
@@ -0,0 +1,234 @@
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from "recharts";
import { UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTheme } from "@/contexts/ThemeContext";
import { useMemo } from "react";
import { format } from "date-fns";
interface ResponseTimeChartProps {
uptimeData: UptimeData[];
}
export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
const { theme } = useTheme();
// Format data for the chart with enhanced time formatting
const chartData = useMemo(() => {
if (!uptimeData || uptimeData.length === 0) return [];
// Sort by timestamp ascending (oldest to newest)
const sortedData = [...uptimeData].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Get time format based on data density
const isShortTimeRange = uptimeData.length > 0 &&
(new Date(uptimeData[0].timestamp).getTime() - new Date(uptimeData[uptimeData.length - 1].timestamp).getTime()) < 2 * 60 * 60 * 1000;
return sortedData.map(data => {
const timestamp = new Date(data.timestamp);
return {
time: isShortTimeRange
? format(timestamp, 'HH:mm:ss') // Include seconds for short time ranges like 60min
: format(timestamp, 'HH:mm'),
rawTime: timestamp.getTime(),
date: format(timestamp, 'MMM dd, yyyy'),
value: data.status === "paused" ? null : data.responseTime,
status: data.status,
};
});
}, [uptimeData]);
// Create a custom tooltip for the chart
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
// Set background color based on status
let statusColor = "bg-emerald-800";
let statusText = "Up";
if (data.status === "down") {
statusColor = "bg-red-800";
statusText = "Down";
} else if (data.status === "warning") {
statusColor = "bg-yellow-800";
statusText = "Warning";
} else if (data.status === "paused") {
statusColor = "bg-gray-800";
statusText = "Paused";
}
return (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} p-2 border ${theme === 'dark' ? 'border-gray-700' : 'border-gray-200'} rounded shadow-md`}>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{data.date}</p>
<div className={`flex items-center gap-2 mt-1 ${data.status === "paused" ? "opacity-70" : ""}`}>
<div className={`w-3 h-3 rounded-full ${statusColor}`}></div>
<span>{statusText}</span>
</div>
<p className="mt-1 font-mono text-sm">
{data.status === "paused" ? "Monitoring paused" :
data.value !== null ? `${data.value} ms` : "No data"}
</p>
</div>
);
}
return null;
};
// Compute status segments for different areas
const getStatusSegments = () => {
const segments = {
up: [] as any[],
down: [] as any[],
warning: [] as any[]
};
chartData.forEach(point => {
if (point.status === "paused") return;
if (point.status === "up") {
segments.up.push(point);
} else if (point.status === "down") {
segments.down.push(point);
} else if (point.status === "warning") {
segments.warning.push(point);
}
});
return segments;
};
const segments = getStatusSegments();
// Check if we have any data to display - be more lenient by checking raw uptimeData
const hasData = uptimeData.length > 0;
// Get date range for display
const dateRange = useMemo(() => {
if (!chartData.length) return { start: "", end: "" };
const sortedData = [...chartData].sort((a, b) => a.rawTime - b.rawTime);
return {
start: sortedData[0]?.date || "",
end: sortedData[sortedData.length - 1]?.date || ""
};
}, [chartData]);
// Display date range if different dates
const dateRangeDisplay = dateRange.start === dateRange.end
? dateRange.start
: `${dateRange.start} - ${dateRange.end}`;
return (
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
<span>Response Time History</span>
{hasData && (
<span className="text-sm font-normal text-muted-foreground">
{dateRangeDisplay}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{!hasData ? (
<div className="h-80 flex items-center justify-center text-muted-foreground">
<p>No data available for the selected time period.</p>
</div>
) : (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
<defs>
<linearGradient id="colorUp" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0.2}/>
</linearGradient>
<linearGradient id="colorDown" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#ef4444" stopOpacity={0.2}/>
</linearGradient>
<linearGradient id="colorWarning" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0.2}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
<XAxis
dataKey="time"
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
angle={-45}
textAnchor="end"
tick={{ fontSize: 10 }}
height={60}
interval="preserveStartEnd"
minTickGap={5}
/>
<YAxis
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
allowDecimals={false}
domain={['dataMin - 10', 'dataMax + 10']}
/>
<Tooltip content={<CustomTooltip />} />
{/* Area charts for different statuses */}
{segments.up.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.up}
stroke="#10b981"
fillOpacity={1}
fill="url(#colorUp)"
connectNulls
/>
)}
{segments.down.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.down}
stroke="#ef4444"
fillOpacity={1}
fill="url(#colorDown)"
connectNulls
/>
)}
{segments.warning.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.warning}
stroke="#f59e0b"
fillOpacity={1}
fill="url(#colorWarning)"
connectNulls
/>
)}
{/* Add reference lines for paused periods */}
{chartData.map((entry, index) =>
entry.status === 'paused' ? (
<ReferenceLine
key={`ref-${index}`}
x={entry.time}
stroke="#9ca3af"
strokeDasharray="3 3"
/>
) : null
)}
</AreaChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,77 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Service } from "@/types/service.types";
import { useTheme } from "@/contexts/ThemeContext";
import { Loader2 } from "lucide-react";
interface ServiceDeleteDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
selectedService: Service | null;
onConfirmDelete: () => Promise<void>;
isDeleting?: boolean;
}
export const ServiceDeleteDialog = ({
isOpen,
onOpenChange,
selectedService,
onConfirmDelete,
isDeleting = false,
}: ServiceDeleteDialogProps) => {
const { theme } = useTheme();
const handleConfirm = async () => {
if (!isDeleting) {
await onConfirmDelete();
}
};
return (
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
<AlertDialogContent className={`${theme === 'dark' ? 'bg-gray-900 text-white border-gray-800' : 'bg-background text-foreground border-border'}`}>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this service?</AlertDialogTitle>
<AlertDialogDescription className={theme === 'dark' ? 'text-gray-400' : 'text-muted-foreground'}>
This action cannot be undone. This will permanently delete{' '}
<span className={theme === 'dark' ? 'font-semibold text-white' : 'font-semibold text-foreground'}>
{selectedService?.name}
</span>{' '}
and all of its uptime records.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className={theme === 'dark' ? 'bg-gray-800 text-white border-gray-700 hover:bg-gray-700' : 'bg-secondary'}
disabled={isDeleting}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isDeleting}
className={theme === 'dark' ? 'bg-red-900 text-white hover:bg-red-800' : 'bg-red-600 text-white hover:bg-red-700'}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,6 @@
import { ServiceDetailContainer as NewServiceDetailContainer } from "./ServiceDetailContainer/index";
export const ServiceDetailContainer = () => {
return <NewServiceDetailContainer />;
};
@@ -0,0 +1,51 @@
import React from "react";
import { Sidebar } from "@/components/dashboard/Sidebar";
import { Header } from "@/components/dashboard/Header";
import { LoadingState } from "@/components/services/LoadingState";
import { ServiceNotFound } from "@/components/services/ServiceNotFound";
import { Service } from "@/types/service.types";
interface ServiceDetailWrapperProps {
children: React.ReactNode;
isLoading: boolean;
service: Service | null;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
currentUser: any;
handleLogout: () => void;
}
export const ServiceDetailWrapper = ({
children,
isLoading,
service,
sidebarCollapsed,
toggleSidebar,
currentUser,
handleLogout
}: ServiceDetailWrapperProps) => {
return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-col flex-1 min-w-0">
<Header
currentUser={currentUser}
onLogout={handleLogout}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/>
{isLoading ? (
<LoadingState />
) : !service ? (
<ServiceNotFound />
) : (
<div className="flex-1 overflow-auto">
{children}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,3 @@
export * from './useServiceData';
export * from './useRealTimeUpdates';
@@ -0,0 +1,87 @@
import { useEffect } from "react";
import { pb } from "@/lib/pocketbase";
import { Service, UptimeData } from "@/types/service.types";
interface UseRealTimeUpdatesProps {
serviceId: string | undefined;
startDate: Date;
endDate: Date;
setService: React.Dispatch<React.SetStateAction<Service | null>>;
setUptimeData: React.Dispatch<React.SetStateAction<UptimeData[]>>;
}
export const useRealTimeUpdates = ({
serviceId,
startDate,
endDate,
setService,
setUptimeData
}: UseRealTimeUpdatesProps) => {
// Listen for real-time updates to this service
useEffect(() => {
if (!serviceId) return;
console.log(`Setting up real-time updates for service: ${serviceId}`);
try {
// Subscribe to the service record for real-time updates
const subscription = pb.collection('services').subscribe(serviceId, function(e) {
console.log("Service updated:", e.record);
// Update our local state with the new data
if (e.record) {
setService(prev => {
if (!prev) return null;
return {
...prev,
status: e.record.status || prev.status,
responseTime: e.record.response_time || e.record.responseTime || prev.responseTime,
uptime: e.record.uptime || prev.uptime,
lastChecked: e.record.last_checked || e.record.lastChecked || prev.lastChecked,
};
});
}
});
// Subscribe to uptime data updates
const uptimeSubscription = pb.collection('uptime_data').subscribe('*', function(e) {
if (e.record && e.record.service_id === serviceId) {
console.log("New uptime data:", e.record);
// Add the new uptime data to our list if it's within the selected date range
const timestamp = new Date(e.record.timestamp);
if (timestamp >= startDate && timestamp <= endDate) {
setUptimeData(prev => {
const newData: UptimeData = {
id: e.record.id,
serviceId: e.record.service_id,
timestamp: e.record.timestamp,
status: e.record.status,
responseTime: e.record.response_time || 0,
date: e.record.timestamp, // Adding required date property
uptime: e.record.uptime || 0 // Adding required uptime property
};
// Add at the beginning of the array to maintain newest first sorting
return [newData, ...prev];
});
}
}
});
// Clean up the subscriptions
return () => {
console.log(`Cleaning up subscriptions for service: ${serviceId}`);
try {
pb.collection('services').unsubscribe(serviceId);
pb.collection('uptime_data').unsubscribe('*');
} catch (error) {
console.error("Error cleaning up subscriptions:", error);
}
};
} catch (error) {
console.error("Error setting up real-time updates:", error);
}
}, [serviceId, startDate, endDate, setService, setUptimeData]);
};
@@ -0,0 +1,173 @@
import { useState, useEffect } from "react";
import { pb } from "@/lib/pocketbase";
import { Service, UptimeData } from "@/types/service.types";
import { useToast } from "@/hooks/use-toast";
import { useNavigate } from "react-router-dom";
import { uptimeService } from "@/services/uptimeService";
export const useServiceData = (serviceId: string | undefined, startDate: Date, endDate: Date) => {
const [service, setService] = useState<Service | null>(null);
const [uptimeData, setUptimeData] = useState<UptimeData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
const navigate = useNavigate();
// Handler for service status changes
const handleStatusChange = async (newStatus: "up" | "down" | "paused" | "warning") => {
if (!service || !serviceId) return;
try {
// Optimistic UI update
setService({ ...service, status: newStatus as Service["status"] });
// Update the service status in PocketBase
await pb.collection('services').update(serviceId, {
status: newStatus
});
toast({
title: "Status updated",
description: `Service status changed to ${newStatus}`,
});
} catch (error) {
console.error("Failed to update service status:", error);
// Revert the optimistic update
setService(prevService => prevService);
toast({
variant: "destructive",
title: "Update failed",
description: "Could not update service status. Please try again.",
});
}
};
// Function to fetch uptime data with date filters
const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange: string) => {
try {
console.log(`Fetching uptime data from ${start.toISOString()} to ${end.toISOString()}`);
// Set appropriate limits based on time range to ensure enough granularity
let limit = 200; // default
// Adjust limits based on selected range
if (selectedRange === '60min') {
limit = 300; // More points for shorter time ranges
} else if (selectedRange === '24h') {
limit = 200;
} else if (selectedRange === '7d') {
limit = 250;
} else if (selectedRange === '30d' || selectedRange === '1y') {
limit = 300; // More points for longer time ranges
}
console.log(`Using limit ${limit} for range ${selectedRange}`);
const history = await uptimeService.getUptimeHistory(serviceId, limit, start, end);
console.log(`Fetched ${history.length} uptime records for time range ${selectedRange}`);
if (history.length === 0) {
console.log("No data returned from API, checking if we need to fetch with a higher limit");
// If no data, try with a higher limit as fallback
if (limit < 500) {
const extendedHistory = await uptimeService.getUptimeHistory(serviceId, 500, start, end);
console.log(`Fallback: Fetched ${extendedHistory.length} uptime records with higher limit`);
if (extendedHistory.length > 0) {
setUptimeData(extendedHistory);
return extendedHistory;
}
}
}
// Sort data by timestamp (newest first)
const sortedHistory = [...history].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
setUptimeData(sortedHistory);
return sortedHistory;
} catch (error) {
console.error("Error fetching uptime data:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load uptime history. Please try again.",
});
return [];
}
};
// Initial data loading
useEffect(() => {
const fetchServiceData = async () => {
try {
if (!serviceId) {
setIsLoading(false);
return;
}
setIsLoading(true);
// Add a timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Request timed out")), 10000);
});
const fetchPromise = pb.collection('services').getOne(serviceId);
const serviceData = await Promise.race([fetchPromise, timeoutPromise]) as any;
const formattedService: Service = {
id: serviceData.id,
name: serviceData.name,
url: serviceData.url || "",
type: serviceData.service_type || serviceData.type || "HTTP",
status: serviceData.status || "paused",
responseTime: serviceData.response_time || serviceData.responseTime || 0,
uptime: serviceData.uptime || 0,
lastChecked: serviceData.last_checked || serviceData.lastChecked || new Date().toLocaleString(),
interval: serviceData.heartbeat_interval || serviceData.interval || 60,
retries: serviceData.max_retries || serviceData.retries || 3,
notificationChannel: serviceData.notification_id,
alertTemplate: serviceData.template_id,
alerts: serviceData.alerts || "unmuted"
};
setService(formattedService);
// Fetch uptime history with date range
await fetchUptimeData(serviceId, startDate, endDate, '24h');
} catch (error) {
console.error("Error fetching service:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load service data. Please try again.",
});
navigate("/dashboard");
} finally {
setIsLoading(false);
}
};
fetchServiceData();
}, [serviceId, navigate, toast]);
// Update data when date range changes
useEffect(() => {
if (serviceId && !isLoading) {
fetchUptimeData(serviceId, startDate, endDate, '24h');
}
}, [startDate, endDate]);
return {
service,
setService,
uptimeData,
setUptimeData,
isLoading,
handleStatusChange,
fetchUptimeData
};
};
@@ -0,0 +1,126 @@
import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { DateRangeOption } from "../DateRangeFilter";
import { authService } from "@/services/authService";
import { ServiceDetailContent } from "../ServiceDetailContent";
import { ServiceDetailWrapper } from "./ServiceDetailWrapper";
import { useServiceData, useRealTimeUpdates } from "./hooks";
import { toast } from "@/components/ui/use-toast";
export const ServiceDetailContainer = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// Ensure we use exact timestamp for startDate
const [startDate, setStartDate] = useState<Date>(() => {
const date = new Date();
date.setHours(date.getHours() - 24);
return date;
});
const [endDate, setEndDate] = useState<Date>(new Date());
const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h');
// State for sidebar collapse functionality (shared with Dashboard)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
// Check if there's a saved preference in localStorage
const saved = localStorage.getItem("sidebarCollapsed");
return saved ? JSON.parse(saved) : window.innerWidth < 768;
});
// Toggle sidebar and save preference
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(prev => {
const newState = !prev;
localStorage.setItem("sidebarCollapsed", JSON.stringify(newState));
return newState;
});
}, []);
// Get current user for header
const currentUser = authService.getCurrentUser();
useEffect(() => {
// Verify user is authenticated
if (!authService.isAuthenticated()) {
toast({
variant: "destructive",
title: "Authentication required",
description: "Please log in to view service details",
});
navigate("/login");
}
// Auto-collapse sidebar on small screens
const handleResize = () => {
if (window.innerWidth < 768 && !sidebarCollapsed) {
setSidebarCollapsed(true);
localStorage.setItem("sidebarCollapsed", JSON.stringify(true));
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [navigate, sidebarCollapsed]);
// Handler for logout (same as Dashboard)
const handleLogout = () => {
authService.logout();
navigate("/login");
};
// Use our custom hooks
const {
service,
uptimeData,
isLoading,
handleStatusChange,
fetchUptimeData,
setService,
setUptimeData
} = useServiceData(id, startDate, endDate);
// Set up real-time updates
useRealTimeUpdates({
serviceId: id,
startDate,
endDate,
setService,
setUptimeData
});
// Handle date range filter changes
const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => {
console.log(`Date range changed: ${start.toISOString()} to ${end.toISOString()}, option: ${option}`);
setStartDate(start);
setEndDate(end);
setSelectedRange(option);
// Refetch uptime data with new date range, passing the selected range option
if (id) {
fetchUptimeData(id, start, end, option);
}
}, [id, fetchUptimeData]);
return (
<ServiceDetailWrapper
isLoading={isLoading}
service={service}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
currentUser={currentUser}
handleLogout={handleLogout}
>
{service && (
<ServiceDetailContent
service={service}
uptimeData={uptimeData}
onDateRangeChange={handleDateRangeChange}
onStatusChange={handleStatusChange}
selectedDateOption={selectedRange}
/>
)}
</ServiceDetailWrapper>
);
};
@@ -0,0 +1,64 @@
import { Service, UptimeData } from "@/types/service.types";
import { ServiceHeader } from "@/components/services/ServiceHeader";
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
import { ResponseTimeChart } from "@/components/services/ResponseTimeChart";
import { LatestChecksTable } from "@/components/services/incident-history";
import { DateRangeFilter, DateRangeOption } from "@/components/services/DateRangeFilter";
import { Card, CardContent } from "@/components/ui/card";
import { AlertTriangle } from "lucide-react";
interface ServiceDetailContentProps {
service: Service;
uptimeData: UptimeData[];
onDateRangeChange: (start: Date, end: Date, option: DateRangeOption) => void;
onStatusChange: (newStatus: "up" | "down" | "paused" | "warning") => void;
selectedDateOption: DateRangeOption;
}
export const ServiceDetailContent = ({
service,
uptimeData,
onDateRangeChange,
onStatusChange,
selectedDateOption
}: ServiceDetailContentProps) => {
// Check if data is available
const hasUptimeData = uptimeData && uptimeData.length > 0;
return (
<div className="p-4 md:p-6 pb-0 h-full overflow-auto">
<ServiceHeader service={service} onStatusChange={onStatusChange} />
<ServiceStatsCards service={service} uptimeData={uptimeData} />
<div className="mb-4 md:mb-6 mt-6 md:mt-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 md:gap-0">
<h2 className="text-lg md:text-xl font-medium">Response Time History</h2>
<DateRangeFilter
onRangeChange={onDateRangeChange}
selectedOption={selectedDateOption}
/>
</div>
{!hasUptimeData && (
<Card className="mb-6 md:mb-8">
<CardContent className="flex items-center justify-center py-8 md:py-12">
<div className="text-center">
<AlertTriangle className="h-10 w-10 md:h-12 md:w-12 text-amber-500 mx-auto mb-2 md:mb-3 opacity-70" />
<h3 className="text-base md:text-lg font-medium mb-1">No uptime data available</h3>
<p className="text-muted-foreground max-w-md text-sm md:text-base">
There's no monitoring data for this service in the selected time period.
This could be because the service was recently added or monitoring is paused.
</p>
</div>
</CardContent>
</Card>
)}
{hasUptimeData && <ResponseTimeChart uptimeData={uptimeData} />}
<div className="pb-6">
<LatestChecksTable uptimeData={uptimeData} />
</div>
</div>
);
};
@@ -0,0 +1,77 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ServiceForm } from "./ServiceForm";
import { Service } from "@/types/service.types";
import { useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ServiceEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
service: Service | null;
}
export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) {
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset submission state when dialog opens/closes
useEffect(() => {
if (!open) {
setIsSubmitting(false);
}
}, [open]);
const handleSuccess = () => {
// Invalidate the services query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["services"] });
setIsSubmitting(false);
onOpenChange(false);
};
const handleCancel = () => {
if (!isSubmitting) {
onOpenChange(false);
}
};
// Only render the form if dialog is open and service data exists
// This prevents form validation errors when dialog is closed
return (
<Dialog open={open} onOpenChange={(newOpen) => {
// Only allow closing if not currently submitting
if (!isSubmitting || !newOpen) {
onOpenChange(newOpen);
}
}}>
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl">Edit Service</DialogTitle>
<DialogDescription className="text-gray-400">
Update the details of your monitored service.
</DialogDescription>
</DialogHeader>
{open && service && (
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="pr-2">
<ServiceForm
onSuccess={handleSuccess}
onCancel={handleCancel}
initialData={service}
isEdit={true}
onSubmitStart={() => setIsSubmitting(true)}
/>
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,142 @@
import { Form } from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { serviceSchema, ServiceFormData } from "./add-service/types";
import { ServiceBasicFields } from "./add-service/ServiceBasicFields";
import { ServiceTypeField } from "./add-service/ServiceTypeField";
import { ServiceConfigFields } from "./add-service/ServiceConfigFields";
import { ServiceNotificationFields } from "./add-service/ServiceNotificationFields";
import { ServiceFormActions } from "./add-service/ServiceFormActions";
import { serviceService } from "@/services/serviceService";
import { Service } from "@/types/service.types";
interface ServiceFormProps {
onSuccess: () => void;
onCancel: () => void;
initialData?: Service | null;
isEdit?: boolean;
onSubmitStart?: () => void;
}
export function ServiceForm({
onSuccess,
onCancel,
initialData,
isEdit = false,
onSubmitStart
}: ServiceFormProps) {
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form with default values
const form = useForm<ServiceFormData>({
resolver: zodResolver(serviceSchema),
defaultValues: {
name: "",
type: "http",
url: "",
interval: "60",
retries: "3",
notificationChannel: "",
alertTemplate: "",
},
mode: "onBlur",
});
// Populate form when initialData changes (separate from initialization)
useEffect(() => {
if (initialData && isEdit) {
// Reset the form with initial data values
form.reset({
name: initialData.name || "",
type: (initialData.type || "http").toLowerCase(),
url: initialData.url || "",
interval: String(initialData.interval || 60),
retries: String(initialData.retries || 3),
notificationChannel: initialData.notificationChannel || "",
alertTemplate: initialData.alertTemplate || "",
});
// Log for debugging
console.log("Populating form with URL:", initialData.url);
}
}, [initialData, isEdit, form]);
const handleSubmit = async (data: ServiceFormData) => {
if (isSubmitting) return;
setIsSubmitting(true);
if (onSubmitStart) onSubmitStart();
try {
console.log("Form data being submitted:", data); // Debug log for submitted data
if (isEdit && initialData) {
// Update existing service
await serviceService.updateService(initialData.id, {
name: data.name,
type: data.type,
url: data.url,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel === "none" ? "" : data.notificationChannel,
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
});
toast({
title: "Service updated",
description: `${data.name} has been updated successfully.`,
});
} else {
// Create new service
await serviceService.createService({
name: data.name,
type: data.type,
url: data.url,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel === "none" ? undefined : data.notificationChannel,
alertTemplate: data.alertTemplate === "default" ? undefined : data.alertTemplate,
});
toast({
title: "Service created",
description: `${data.name} has been added to monitoring.`,
});
}
onSuccess();
if (!isEdit) {
form.reset();
}
} catch (error) {
console.error(`Error ${isEdit ? 'updating' : 'creating'} service:`, error);
toast({
title: `Failed to ${isEdit ? 'update' : 'create'} service`,
description: `An error occurred while ${isEdit ? 'updating' : 'creating'} the service.`,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6">
<ServiceBasicFields form={form} />
<ServiceTypeField form={form} />
<ServiceConfigFields form={form} />
<ServiceNotificationFields form={form} />
<ServiceFormActions
isSubmitting={isSubmitting}
onCancel={onCancel}
submitLabel={isEdit ? "Update Service" : "Create Service"}
/>
</form>
</Form>
);
}
@@ -0,0 +1,83 @@
import { ArrowLeft, Globe, MoreVertical, FileText, Github, Twitter, MessageSquare, Bell } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/services/StatusBadge";
import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton";
import { Service } from "@/types/service.types";
import { useLanguage } from "@/contexts/LanguageContext";
import { cn } from "@/lib/utils";
interface ServiceHeaderProps {
service: Service;
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
}
export function ServiceHeader({ service, onStatusChange }: ServiceHeaderProps) {
const navigate = useNavigate();
const { t } = useLanguage();
return (
<div className="mb-6">
<Button
variant="ghost"
className="mb-4 pl-0 hover:bg-transparent"
onClick={() => navigate("/dashboard")}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t("back")}
</Button>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center">
<h1 className="text-2xl font-bold">{service.name}</h1>
{/* Pulsating Circle Animation */}
<div className="relative ml-2 flex items-center">
<span
className={cn(
"flex h-3 w-3 relative",
service.status === "up" ? "bg-green-500" :
service.status === "down" ? "bg-red-500" :
service.status === "warning" ? "bg-yellow-500" :
"bg-blue-500",
"rounded-full"
)}
/>
<span
className={cn(
"animate-ping absolute h-3 w-3",
service.status === "up" ? "bg-green-400" :
service.status === "down" ? "bg-red-400" :
service.status === "warning" ? "bg-yellow-400" :
"bg-blue-400",
"rounded-full opacity-75"
)}
/>
</div>
{service.url && (
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
>
<Globe className="h-3 w-3 mr-1" />
{service.url}
</a>
)}
</div>
<div className="flex items-center space-x-4">
<StatusBadge status={service.status} size="lg" />
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">More options</span>
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from "@/components/ui/dialog";
import { Service } from "@/types/service.types";
import { ServiceUptimeHistory } from "@/components/services/ServiceUptimeHistory";
import { useTheme } from "@/contexts/ThemeContext";
import { DateRangeFilter, DateRangeOption } from "@/components/services/DateRangeFilter";
interface ServiceHistoryDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
selectedService: Service | null;
}
export const ServiceHistoryDialog = ({
isOpen,
onOpenChange,
selectedService,
}: ServiceHistoryDialogProps) => {
const { theme } = useTheme();
const [startDate, setStartDate] = useState<Date>(new Date(Date.now() - 24 * 60 * 60 * 1000)); // Default to 24h ago
const [endDate, setEndDate] = useState<Date>(new Date());
// Reset date range when dialog opens to ensure fresh data
useEffect(() => {
if (isOpen) {
setStartDate(new Date(Date.now() - 24 * 60 * 60 * 1000));
setEndDate(new Date());
}
}, [isOpen]);
// Handle date range filter changes
const handleDateRangeChange = (start: Date, end: Date, option: DateRangeOption) => {
console.log(`ServiceHistoryDialog: Date range changed to ${start.toISOString()} - ${end.toISOString()}`);
setStartDate(start);
setEndDate(end);
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className={`${theme === 'dark' ? 'bg-black text-white border-gray-800' : 'bg-background text-foreground border-border'} sm:max-w-[800px]`}>
<DialogHeader>
<DialogTitle className="text-xl">
{selectedService?.name} - Uptime History
</DialogTitle>
<DialogDescription className={theme === 'dark' ? 'text-gray-400' : 'text-muted-foreground'}>
Showing the most recent uptime checks for this service.
{selectedService?.interval && (
<span> Checked every {selectedService.interval} seconds.</span>
)}
</DialogDescription>
</DialogHeader>
<div className="mb-4">
<DateRangeFilter onRangeChange={handleDateRangeChange} />
</div>
{selectedService && (
<ServiceUptimeHistory
serviceId={selectedService.id}
startDate={startDate}
endDate={endDate}
/>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,103 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Play, Pause } from "lucide-react";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast";
import { notificationService } from "@/services/notificationService";
interface ServiceMonitoringButtonProps {
service: Service;
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
}
export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMonitoringButtonProps) {
const [isMonitoring, setIsMonitoring] = useState(service.status !== "paused");
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
// Update local state when service prop changes
useEffect(() => {
setIsMonitoring(service.status !== "paused");
}, [service.status]);
const handleToggleMonitoring = async () => {
try {
setIsLoading(true);
if (isMonitoring) {
// Pause monitoring
console.log(`Pausing monitoring for service ${service.id} (${service.name})`);
await serviceService.pauseMonitoring(service.id);
setIsMonitoring(false);
if (onStatusChange) onStatusChange("paused");
// Send notification for paused status (only here, not in pauseMonitoring.ts)
if (service.alerts !== "muted") {
console.log("Sending pause notification from UI component");
// IMPORTANT: Direct call to the notification service to ensure a message is sent
await notificationService.sendNotification({
service: service,
status: "paused",
timestamp: new Date().toISOString(),
});
}
toast({
title: "Monitoring paused",
description: `Monitoring for ${service.name} has been paused.`,
});
} else {
// Start/resume monitoring
console.log(`Starting monitoring for service ${service.id} (${service.name})`);
// First ensure we update the status in the database to not be paused anymore
await serviceService.resumeMonitoring(service.id);
setIsMonitoring(true);
// Perform an immediate check
await serviceService.startMonitoringService(service.id);
toast({
title: "Monitoring resumed",
description: `Monitoring for ${service.name} has been resumed. First check is running now.`,
});
}
} catch (error) {
console.error("Error toggling monitoring:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to change monitoring status. Please try again.",
});
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="outline"
size="sm"
onClick={handleToggleMonitoring}
disabled={isLoading}
className={isMonitoring ? "bg-red-900/20 hover:bg-red-900/30" : "bg-green-900/20 hover:bg-green-900/30"}
>
{isLoading ? (
"Processing..."
) : isMonitoring ? (
<>
<Pause className="h-4 w-4 mr-2" />
Pause Monitoring
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Start Monitoring
</>
)}
</Button>
);
}
@@ -0,0 +1,25 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom";
export function ServiceNotFound() {
const navigate = useNavigate();
return (
<div className="flex items-center justify-center h-screen bg-background text-foreground">
<div className="text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-yellow-500" />
<h2 className="text-xl font-bold mt-4">Service Not Found</h2>
<p className="mt-2 text-muted-foreground">The service you're looking for doesn't exist or has been deleted.</p>
<Button
className="mt-4"
variant="outline"
onClick={() => navigate("/dashboard")}
>
Back to Dashboard
</Button>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import React from "react";
import { TableRow, TableCell } from "@/components/ui/table";
import { Service } from "@/types/service.types";
import { StatusBadge } from "./StatusBadge";
import { UptimeBar } from "./UptimeBar";
import { LastCheckedTime } from "./LastCheckedTime";
import {
ServiceRowActions,
ServiceRowHeader,
ServiceRowResponseTime
} from "./service-row";
import { useTheme } from "@/contexts/ThemeContext";
interface ServiceRowProps {
service: Service;
onViewDetail: (service: Service) => void;
onPauseResume: (service: Service) => Promise<void>;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
onMuteAlerts?: (service: Service) => Promise<void>;
}
export const ServiceRow = ({
service,
onViewDetail,
onPauseResume,
onEdit,
onDelete,
onMuteAlerts
}: ServiceRowProps) => {
const { theme } = useTheme();
const handleRowClick = () => {
onViewDetail(service);
};
// Get the timestamp to display - use only lastChecked since that's what's defined in the Service type
const displayTimestamp = service.lastChecked || new Date().toLocaleString();
return (
<TableRow
key={service.id}
className={`border-b ${theme === 'dark' ? 'border-gray-800 hover:bg-gray-900/60' : 'border-gray-200 hover:bg-gray-50'} cursor-pointer`}
onClick={handleRowClick}
>
<TableCell className="font-medium py-4" onClick={(e) => e.stopPropagation()}>
<ServiceRowHeader service={service} />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className={`text-base py-4 ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
{service.type}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<StatusBadge status={service.status} size="md" />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<ServiceRowResponseTime responseTime={service.responseTime} />
</TableCell>
<TableCell className="w-52 py-4" onClick={(e) => e.stopPropagation()}>
<UptimeBar uptime={service.uptime} status={service.status} serviceId={service.id} />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<LastCheckedTime
lastCheckedTime={displayTimestamp}
status={service.status}
interval={service.interval}
/>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<ServiceRowActions
service={service}
onViewDetail={onViewDetail}
onPauseResume={onPauseResume}
onEdit={onEdit}
onDelete={onDelete}
onMuteAlerts={onMuteAlerts}
/>
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,171 @@
import { Clock, Server, ArrowUp, ArrowDown } from "lucide-react";
import { Service, UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useMemo } from "react";
import { formatDistanceStrict } from "date-fns";
interface ServiceStatsCardsProps {
service: Service;
uptimeData: UptimeData[];
}
export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) {
// Calculate uptime percentage
const calculateUptimePercentage = () => {
if (uptimeData.length === 0) return 100;
const totalChecks = uptimeData.length;
const upChecks = uptimeData.filter(data => data.status === "up").length;
return Math.round((upChecks / totalChecks) * 100);
};
// Calculate total uptime/downtime
const uptimeStats = useMemo(() => {
if (uptimeData.length === 0) {
return {
totalUptimeFormatted: "N/A",
totalDowntimeFormatted: "N/A",
currentStatusDuration: "N/A"
};
}
// Sort data by timestamp for chronological analysis
const sortedData = [...uptimeData].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Get the status change events
const statusChanges = sortedData.reduce((changes, check, index) => {
if (index === 0 || (index > 0 && sortedData[index-1].status !== check.status)) {
changes.push(check);
}
return changes;
}, [] as UptimeData[]);
let totalUptime = 0;
let totalDowntime = 0;
let currentStatus = service.status;
let lastChangeTime = new Date();
// Calculate durations between status changes
for (let i = 0; i < statusChanges.length; i++) {
const currentChange = statusChanges[i];
const nextChange = statusChanges[i + 1];
if (nextChange) {
const duration = new Date(nextChange.timestamp).getTime() - new Date(currentChange.timestamp).getTime();
if (currentChange.status === "up") {
totalUptime += duration;
} else {
totalDowntime += duration;
}
} else {
// For the last status, calculate time until now
lastChangeTime = new Date(currentChange.timestamp);
currentStatus = currentChange.status;
}
}
// Add time from last change until now for current status
const now = new Date();
const sinceLastChange = now.getTime() - lastChangeTime.getTime();
if (currentStatus === "up") {
totalUptime += sinceLastChange;
} else if (currentStatus === "down") {
totalDowntime += sinceLastChange;
}
// Format durations
const formatDuration = (ms: number): string => {
if (ms < 1000) return "0s";
return formatDistanceStrict(0, ms, { addSuffix: false });
};
return {
totalUptimeFormatted: formatDuration(totalUptime),
totalDowntimeFormatted: formatDuration(totalDowntime),
currentStatusDuration: formatDuration(sinceLastChange)
};
}, [uptimeData, service.status]);
// Calculate average response time from recent checks
const calculateAverageResponseTime = () => {
const upChecks = uptimeData.filter(data => data.status === "up" && data.responseTime > 0);
if (upChecks.length === 0) return service.responseTime || 0;
const sum = upChecks.reduce((total, check) => total + check.responseTime, 0);
return Math.round((sum / upChecks.length) * 100) / 100; // Two decimal precision
};
const uptimePercentage = calculateUptimePercentage();
const avgResponseTime = calculateAverageResponseTime();
// Define upChecks here so we can use it in both the calculation and the JSX
const upChecks = uptimeData.filter(data => data.status === "up" && data.responseTime > 0);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card className="border-blue-400 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">Response Time</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-700 dark:text-white">
{service.responseTime}ms
</div>
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Last checked at {service.lastChecked}</p>
{avgResponseTime > 0 && avgResponseTime !== service.responseTime && (
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Avg: {avgResponseTime}ms (last {upChecks.length} up checks)</p>
)}
</CardContent>
</Card>
<Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">Uptime</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-700 dark:text-white">
{uptimePercentage}%
</div>
<p className="text-xs text-green-600 dark:text-green-200 mt-1">Based on last {uptimeData.length} checks</p>
<div className="flex flex-col space-y-1 mt-2">
<div className="flex items-center text-xs text-green-600 dark:text-green-200">
<ArrowUp className="h-3 w-3 mr-1 text-green-600 dark:text-green-400" />
<span>Total uptime: {uptimeStats.totalUptimeFormatted}</span>
</div>
<div className="flex items-center text-xs text-red-600 dark:text-red-200">
<ArrowDown className="h-3 w-3 mr-1 text-red-600 dark:text-red-400" />
<span>Total downtime: {uptimeStats.totalDowntimeFormatted}</span>
</div>
{service.status !== "paused" && (
<div className="flex items-center text-xs font-medium mt-1">
<span className={service.status === "up" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{service.status === "up" ? "Up" : "Down"} for {uptimeStats.currentStatusDuration}
</span>
</div>
)}
</div>
</CardContent>
</Card>
<Card className="border-purple-400 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">Monitoring Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200">
<Clock className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>Checked every {service.interval} seconds</span>
</div>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200 mt-1">
<Server className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>{service.type} monitoring</span>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,138 @@
import { useQuery } from "@tanstack/react-query";
import { UptimeData } from "@/types/service.types";
import { uptimeService } from "@/services/uptimeService";
import { format, parseISO } from "date-fns";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { useTheme } from "@/contexts/ThemeContext";
import { Check, X, AlertTriangle, Pause } from "lucide-react";
interface ServiceUptimeHistoryProps {
serviceId: string;
startDate?: Date;
endDate?: Date;
}
export function ServiceUptimeHistory({
serviceId,
startDate = new Date(Date.now() - 24 * 60 * 60 * 1000),
endDate = new Date()
}: ServiceUptimeHistoryProps) {
const { theme } = useTheme();
const { data: uptimeHistory, isLoading, error } = useQuery({
queryKey: ['uptimeHistory', serviceId, startDate?.toISOString(), endDate?.toISOString()],
queryFn: () => uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate),
enabled: !!serviceId,
refetchInterval: 5000, // Refresh UI every 5 seconds
});
if (isLoading) {
return (
<div className="py-4 text-center">
<p>Loading uptime history...</p>
</div>
);
}
if (error) {
return (
<div className="py-4 text-center">
<p>Error loading uptime history.</p>
</div>
);
}
if (!uptimeHistory || uptimeHistory.length === 0) {
return (
<div className="py-4 text-center">
<p>No uptime history available for the selected time period.</p>
</div>
);
}
// Function to get appropriate status badge styling
const getStatusBadge = (status: string) => {
switch(status) {
case 'up':
return {
variant: 'default' as const,
className: 'bg-emerald-800 text-white hover:bg-emerald-700',
icon: <Check className="h-3 w-3 mr-1" />
};
case 'warning':
return {
variant: 'outline' as const,
className: 'bg-yellow-800/80 text-yellow-300 border-yellow-700 hover:bg-yellow-800',
icon: <AlertTriangle className="h-3 w-3 mr-1" />
};
case 'paused':
return {
variant: 'outline' as const,
className: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800',
icon: <Pause className="h-3 w-3 mr-1" />
};
default:
return {
variant: 'destructive' as const,
className: '',
icon: <X className="h-3 w-3 mr-1" />
};
}
};
return (
<div className="mt-4 bg-card rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-b border-border">
<TableHead className="text-muted-foreground font-medium">Time</TableHead>
<TableHead className="text-muted-foreground font-medium">Status</TableHead>
<TableHead className="text-muted-foreground font-medium">Response Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uptimeHistory.map((record) => {
const statusBadge = getStatusBadge(record.status);
const isResponseTimeHigh = record.responseTime >= 1000;
return (
<TableRow key={record.id} className="border-b border-border">
<TableCell>
{format(parseISO(record.timestamp), 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell>
<Badge
variant={statusBadge.variant}
className={statusBadge.className}
>
<span className="flex items-center">
{statusBadge.icon}
{record.status === 'up' ? 'Up' :
record.status === 'down' ? 'Down' :
record.status === 'warning' ? 'Warning' : 'Paused'}
</span>
</Badge>
</TableCell>
<TableCell>
<div className="font-mono flex items-center gap-1.5">
{record.responseTime > 0 ? (
<>
<span className={isResponseTimeHigh ? "text-amber-500 font-semibold" : ""}>
{record.responseTime}ms
</span>
{isResponseTimeHigh && (
<AlertTriangle className="h-3 w-3 text-amber-500" />
)}
</>
) : 'N/A'}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,94 @@
import { useEffect } from "react";
import { Service } from "@/types/service.types";
import { ServicesTableView } from "./ServicesTableView";
import { ServiceDeleteDialog } from "./ServiceDeleteDialog";
import { ServiceHistoryDialog } from "./ServiceHistoryDialog";
import { ServiceEditDialog } from "./ServiceEditDialog";
import { useServiceActions, useDialogState } from "./hooks";
interface ServicesTableContainerProps {
services: Service[];
}
export const ServicesTableContainer = ({ services }: ServicesTableContainerProps) => {
const {
services: localServices,
selectedService,
isDeleting,
setSelectedService,
updateServices,
handleViewDetail,
handlePauseResume,
handleEdit,
handleDelete,
confirmDelete,
handleMuteAlerts
} = useServiceActions(services);
const {
isHistoryDialogOpen,
isDeleteDialogOpen,
isEditDialogOpen,
setIsHistoryDialogOpen,
setIsDeleteDialogOpen,
handleEditDialogChange,
handleDeleteDialogChange
} = useDialogState();
// Update local services state when props change
useEffect(() => {
updateServices(services);
}, [services]);
// Handler functions that combine local state management
const onEdit = (service: Service) => {
const selectedService = handleEdit(service);
setTimeout(() => {
handleEditDialogChange(true);
}, 0);
};
const onDelete = (service: Service) => {
handleDelete(service);
setIsDeleteDialogOpen(true);
};
const openHistoryDialog = (service: Service) => {
setSelectedService(service);
setIsHistoryDialogOpen(true);
};
return (
<div className="flex-1 flex flex-col h-full">
<ServicesTableView
services={localServices}
onViewDetail={handleViewDetail}
onPauseResume={handlePauseResume}
onEdit={onEdit}
onDelete={onDelete}
onMuteAlerts={handleMuteAlerts}
/>
<ServiceHistoryDialog
isOpen={isHistoryDialogOpen}
onOpenChange={setIsHistoryDialogOpen}
selectedService={selectedService}
/>
<ServiceDeleteDialog
isOpen={isDeleteDialogOpen}
onOpenChange={(open) => handleDeleteDialogChange(open, isDeleting)}
selectedService={selectedService}
onConfirmDelete={confirmDelete}
isDeleting={isDeleting}
/>
<ServiceEditDialog
open={isEditDialogOpen}
onOpenChange={handleEditDialogChange}
service={selectedService}
/>
</div>
);
}
@@ -0,0 +1,68 @@
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Service } from "@/types/service.types";
import { ServiceRow } from "@/components/services/ServiceRow";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServicesTableViewProps {
services: Service[];
onViewDetail: (service: Service) => void;
onPauseResume: (service: Service) => Promise<void>;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
onMuteAlerts?: (service: Service) => Promise<void>;
}
export const ServicesTableView = ({
services,
onViewDetail,
onPauseResume,
onEdit,
onDelete,
onMuteAlerts
}: ServicesTableViewProps) => {
const { theme } = useTheme();
const { t } = useLanguage();
return (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg overflow-hidden border border-border flex-1 flex flex-col shadow-sm`}>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'} sticky top-0 z-10`}>
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceName")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceType")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceStatus")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("responseTime")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("uptime")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("lastChecked")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.length > 0 ? (
services.map((service) => (
<ServiceRow
key={service.id}
service={service}
onViewDetail={onViewDetail}
onPauseResume={onPauseResume}
onEdit={onEdit}
onDelete={onDelete}
onMuteAlerts={onMuteAlerts}
/>
))
) : (
<TableRow>
<TableCell colSpan={7} className={`text-center py-8 text-base ${theme === 'dark' ? 'text-gray-300' : 'text-gray-500'}`}>
{t("noServices")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
@@ -0,0 +1,71 @@
import React from "react";
import { Check, X, Pause, AlertTriangle } from "lucide-react";
export interface StatusBadgeProps {
status: string;
size?: "sm" | "md" | "lg";
}
export const StatusBadge = ({ status, size = "sm" }: StatusBadgeProps) => {
// Determine the sizing classes based on the size prop
const getSizeClasses = () => {
switch (size) {
case "lg":
return "px-3 py-1.5 text-sm gap-1.5";
case "md":
return "px-2.5 py-1 text-sm gap-1.5";
case "sm":
default:
return "px-2 py-0.5 text-xs gap-0.5";
}
};
const getIconSize = () => {
switch (size) {
case "lg":
return "h-4 w-4";
case "md":
return "h-4 w-4";
case "sm":
default:
return "h-3 w-3";
}
};
const sizeClasses = getSizeClasses();
const iconSize = getIconSize();
switch (status) {
case "up":
return (
<div className={`flex items-center bg-emerald-950/60 dark:bg-emerald-950/60 text-emerald-400 font-medium rounded-full w-fit ${sizeClasses}`}>
<Check className={iconSize} />
<span>Up</span>
</div>
);
case "down":
return (
<div className={`flex items-center bg-red-950/60 dark:bg-red-950/60 text-red-400 font-medium rounded-full w-fit ${sizeClasses}`}>
<X className={iconSize} />
<span>Down</span>
</div>
);
case "warning":
return (
<div className={`flex items-center bg-yellow-950/60 dark:bg-yellow-950/60 text-yellow-400 font-medium rounded-full w-fit ${sizeClasses}`}>
<AlertTriangle className={iconSize} />
<span>Warning</span>
</div>
);
case "paused":
return (
<div className={`flex items-center bg-gray-200/30 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 font-medium rounded-full w-fit ${sizeClasses}`}>
<Pause className={iconSize} />
<span>Paused</span>
</div>
);
default:
return null;
}
};
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from "react";
import { Progress } from "@/components/ui/progress";
import { Check, X, AlertTriangle, Pause, Clock, Info, RefreshCcw } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger
} from "@/components/ui/hover-card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { uptimeService } from "@/services/uptimeService";
import { UptimeData } from "@/types/service.types";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
interface UptimeBarProps {
uptime: number;
status: string;
serviceId?: string; // Optional serviceId to fetch history
}
export const UptimeBar = ({ uptime, status, serviceId }: UptimeBarProps) => {
const { theme } = useTheme();
const [historyItems, setHistoryItems] = useState<UptimeData[]>([]);
// Fetch real uptime history data if serviceId is provided with improved caching and error handling
const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({
queryKey: ['uptimeHistory', serviceId],
queryFn: () => serviceId ? uptimeService.getUptimeHistory(serviceId, 20) : Promise.resolve([]),
enabled: !!serviceId,
refetchInterval: 30000, // Refresh every 30 seconds
staleTime: 15000, // Consider data fresh for 15 seconds
placeholderData: (previousData) => previousData, // Show previous data while refetching
retry: 3, // Retry failed requests three times
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff with max 10s
});
// Update history items when data changes
useEffect(() => {
if (uptimeData && uptimeData.length > 0) {
setHistoryItems(uptimeData);
} else if (status === "paused" || (uptimeData && uptimeData.length === 0)) {
// For paused services with no history, or empty history data, show all as paused
const statusValue = (status === "up" || status === "down" || status === "warning" || status === "paused")
? status
: "paused"; // Default to paused if not a valid status
const placeholderHistory: UptimeData[] = Array(20).fill(null).map((_, index) => ({
id: `placeholder-${index}`,
serviceId: serviceId || "",
timestamp: new Date().toISOString(),
status: statusValue as "up" | "down" | "warning" | "paused",
responseTime: 0
}));
setHistoryItems(placeholderHistory);
}
}, [uptimeData, serviceId, status]);
// Get appropriate color classes for each status type
const getStatusColor = (itemStatus: string) => {
switch(itemStatus) {
case "up":
return theme === "dark" ? "bg-emerald-500" : "bg-emerald-500";
case "down":
return theme === "dark" ? "bg-red-500" : "bg-red-500";
case "warning":
return theme === "dark" ? "bg-yellow-500" : "bg-yellow-500";
case "paused":
default:
return theme === "dark" ? "bg-gray-500" : "bg-gray-400";
}
};
// Get status label
const getStatusLabel = (itemStatus: string): string => {
switch(itemStatus) {
case "up": return "Online";
case "down": return "Offline";
case "warning": return "Degraded";
case "paused": return "Paused";
default: return "Unknown";
}
};
// Format timestamp for display
const formatTimestamp = (timestamp: string): string => {
try {
return new Date(timestamp).toLocaleString([], {
hour: '2-digit',
minute: '2-digit',
month: 'short',
day: 'numeric'
});
} catch (e) {
return timestamp;
}
};
// If still loading and no history, show improved loading state
if ((isLoading || isFetching) && historyItems.length === 0) {
// Show skeleton loading UI instead of text
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`skeleton-${index}`}
className={`h-5 w-1.5 rounded-sm bg-muted animate-pulse`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground w-16 h-4 bg-muted animate-pulse rounded"></span>
<span className="text-muted-foreground w-24 h-4 bg-muted animate-pulse rounded"></span>
</div>
</div>
);
}
// If there's an error and no history, show improved error state with retry button
if (error && historyItems.length === 0) {
// Provide visual error state that matches the design system
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`error-${index}`}
className={`h-5 w-1.5 rounded-sm bg-gray-700 opacity-40`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{Math.round(uptime)}% uptime</span>
<button
onClick={() => refetch()}
className="text-xs text-red-400 flex items-center gap-1 hover:text-red-300 transition-colors"
>
<X className="h-3 w-3" /> Connection error
<RefreshCcw className="h-3 w-3 ml-1" />
</button>
</div>
</div>
);
}
// Ensure we always have 20 items by padding with the last known status
const displayItems = [...historyItems];
if (displayItems.length < 20) {
const lastItem = displayItems.length > 0 ? displayItems[displayItems.length - 1] : null;
const lastStatus = lastItem ? lastItem.status :
(status === "up" || status === "down" || status === "warning" || status === "paused") ?
status as "up" | "down" | "warning" | "paused" : "paused";
const paddingItems: UptimeData[] = Array(20 - displayItems.length).fill(null).map((_, index) => ({
id: `padding-${index}`,
serviceId: serviceId || "",
timestamp: new Date().toISOString(),
status: lastStatus,
responseTime: 0
}));
displayItems.push(...paddingItems);
}
// Limit to 20 items for display
const limitedItems = displayItems.slice(0, 20);
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{limitedItems.map((item, index) => (
<Tooltip key={item.id || `status-${index}`}>
<TooltipTrigger asChild>
<div
className={`h-5 w-1.5 rounded-sm ${getStatusColor(item.status)} cursor-pointer hover:opacity-80 transition-opacity`}
/>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">{getStatusLabel(item.status)}</div>
<div>
{item.status !== "paused" && item.status !== "down" ?
`${item.responseTime}ms` :
"No response"}
</div>
<div className="text-gray-400">
{formatTimestamp(item.timestamp)}
</div>
</div>
</TooltipContent>
</Tooltip>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{Math.round(uptime)}% uptime
</span>
<span className="text-xs text-muted-foreground">
Last 20 checks
</span>
</div>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,55 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceBasicFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) {
return (
<>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Service Name</FormLabel>
<FormControl>
<Input
placeholder="Service Name"
className="bg-black border-gray-700"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Service URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com"
className="bg-black border-gray-700"
{...field}
onChange={(e) => {
console.log("URL field changed:", e.target.value);
field.onChange(e);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
@@ -0,0 +1,51 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceConfigFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
return (
<>
<FormField
control={form.control}
name="interval"
render={({ field }) => (
<FormItem>
<FormLabel>Heartbeat Interval</FormLabel>
<FormControl>
<Input
type="number"
placeholder="60"
className="bg-black border-gray-700"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="retries"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum Retries</FormLabel>
<FormControl>
<Input
type="number"
placeholder="3"
className="bg-black border-gray-700"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</>
);
}
@@ -0,0 +1,143 @@
import { Form } from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { serviceSchema, ServiceFormData } from "./types";
import { ServiceBasicFields } from "./ServiceBasicFields";
import { ServiceTypeField } from "./ServiceTypeField";
import { ServiceConfigFields } from "./ServiceConfigFields";
import { ServiceNotificationFields } from "./ServiceNotificationFields";
import { ServiceFormActions } from "./ServiceFormActions";
import { serviceService } from "@/services/serviceService";
import { Service } from "@/types/service.types";
interface ServiceFormProps {
onSuccess: () => void;
onCancel: () => void;
initialData?: Service | null;
isEdit?: boolean;
onSubmitStart?: () => void;
}
export function ServiceForm({
onSuccess,
onCancel,
initialData,
isEdit = false,
onSubmitStart
}: ServiceFormProps) {
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form with default values
const form = useForm<ServiceFormData>({
resolver: zodResolver(serviceSchema),
defaultValues: {
name: "",
type: "http",
url: "",
interval: "60",
retries: "3",
notificationChannel: "",
alertTemplate: "",
},
mode: "onBlur",
});
// Populate form when initialData changes (separate from initialization)
useEffect(() => {
if (initialData && isEdit) {
// Reset the form with initial data values
form.reset({
name: initialData.name || "",
type: (initialData.type || "http").toLowerCase(),
url: initialData.url || "",
interval: String(initialData.interval || 60),
retries: String(initialData.retries || 3),
notificationChannel: initialData.notificationChannel || "",
alertTemplate: initialData.alertTemplate || "",
});
// Log for debugging
console.log("Populating form with URL:", initialData.url);
}
}, [initialData, isEdit, form]);
const handleSubmit = async (data: ServiceFormData) => {
if (isSubmitting) return;
setIsSubmitting(true);
if (onSubmitStart) onSubmitStart();
try {
console.log("Form data being submitted:", data); // Debug log for submitted data
if (isEdit && initialData) {
// Update existing service
await serviceService.updateService(initialData.id, {
name: data.name,
type: data.type,
url: data.url, // Ensure URL is included here
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel || undefined,
alertTemplate: data.alertTemplate || undefined,
});
toast({
title: "Service updated",
description: `${data.name} has been updated successfully.`,
});
} else {
// Create new service
await serviceService.createService({
name: data.name,
type: data.type,
url: data.url, // Ensure URL is included here
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel || undefined,
alertTemplate: data.alertTemplate || undefined,
});
toast({
title: "Service created",
description: `${data.name} has been added to monitoring.`,
});
}
onSuccess();
if (!isEdit) {
form.reset();
}
} catch (error) {
console.error(`Error ${isEdit ? 'updating' : 'creating'} service:`, error);
toast({
title: `Failed to ${isEdit ? 'update' : 'create'} service`,
description: `An error occurred while ${isEdit ? 'updating' : 'creating'} the service.`,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-4">
<ServiceBasicFields form={form} />
<ServiceTypeField form={form} />
<ServiceConfigFields form={form} />
<ServiceNotificationFields form={form} />
<ServiceFormActions
isSubmitting={isSubmitting}
onCancel={onCancel}
submitLabel={isEdit ? "Update Service" : "Create Service"}
/>
</form>
</Form>
);
}
@@ -0,0 +1,51 @@
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { MouseEvent } from "react";
interface ServiceFormActionsProps {
isSubmitting: boolean;
onCancel: () => void;
submitLabel?: string;
}
export function ServiceFormActions({
isSubmitting,
onCancel,
submitLabel = "Create Service"
}: ServiceFormActionsProps) {
const handleCancel = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!isSubmitting) {
onCancel();
}
};
return (
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
onClick={handleCancel}
variant="outline"
disabled={isSubmitting}
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
submitLabel
)}
</Button>
</div>
);
}
@@ -0,0 +1,139 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
import { useQuery } from "@tanstack/react-query";
import { templateService } from "@/services/templateService";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { useState, useEffect } from "react";
interface ServiceNotificationFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceNotificationFields({ form }: ServiceNotificationFieldsProps) {
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
// Get the current form values for debugging
const notificationChannel = form.watch("notificationChannel");
const alertTemplate = form.watch("alertTemplate");
console.log("Current notification values:", {
notificationChannel,
alertTemplate
});
// Fetch alert configurations for notification channels
const { data: alertConfigsData } = useQuery({
queryKey: ['alertConfigs'],
queryFn: () => alertConfigService.getAlertConfigurations(),
});
// Fetch templates for template selection
const { data: templates } = useQuery({
queryKey: ['templates'],
queryFn: () => templateService.getTemplates(),
});
// Update alert configs when data is loaded
useEffect(() => {
if (alertConfigsData) {
setAlertConfigs(alertConfigsData);
// Debug log to check what alert configs are loaded
console.log("Loaded alert configurations:", alertConfigsData);
}
}, [alertConfigsData]);
// Log when form values change to debug
useEffect(() => {
console.log("Notification values changed:", {
notificationChannel: form.getValues("notificationChannel"),
alertTemplate: form.getValues("alertTemplate")
});
}, [form.watch("notificationChannel"), form.watch("alertTemplate")]);
return (
<>
<FormField
control={form.control}
name="notificationChannel"
render={({ field }) => {
// Important: We need to preserve the actual value for notification channel
const fieldValue = field.value || "";
const displayValue = fieldValue === "" ? "none" : fieldValue;
console.log("Rendering notification channel field with value:", {
fieldValue,
displayValue
});
return (
<FormItem>
<FormLabel>Notification Channel</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
console.log("Notification channel changed to:", value);
field.onChange(value === "none" ? "" : value);
}}
value={displayValue}
>
<SelectTrigger className="bg-black border-gray-700">
<SelectValue placeholder="Select a notification channel" />
</SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700">
<SelectItem value="none">None</SelectItem>
{alertConfigs.map((config) => (
<SelectItem key={config.id} value={config.id || ""}>
{config.notify_name} ({config.notification_type})
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="alertTemplate"
render={({ field }) => {
// Don't convert existing values to "default"
const displayValue = field.value || "default";
console.log("Rendering alert template field with value:", displayValue);
return (
<FormItem>
<FormLabel>Alert Template</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
console.log("Alert template changed to:", value);
field.onChange(value === "default" ? "" : value);
}}
value={displayValue}
>
<SelectTrigger className="bg-black border-gray-700">
<SelectValue placeholder="Select an alert template" />
</SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700">
<SelectItem value="default">Default</SelectItem>
{templates?.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
);
}}
/>
</>
);
}
@@ -0,0 +1,58 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Globe } from "lucide-react";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceTypeFieldProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
return (
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Service Type</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger className="bg-black border-gray-700">
<SelectValue>
{field.value === "http" && (
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
<span>HTTP/S</span>
</div>
)}
{field.value !== "http" && "Select a service type"}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700">
<SelectItem value="http">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
<span>HTTP/S</span>
</div>
<p className="text-xs text-gray-400 mt-1">
Monitor websites and REST APIs with HTTP/HTTPS protocol
</p>
</div>
</SelectItem>
<SelectItem value="ping">PING</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="dns">DNS</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
);
}
@@ -0,0 +1,3 @@
export * from "./ServiceForm";
export * from "./types";
@@ -0,0 +1,14 @@
import { z } from "zod";
export const serviceSchema = z.object({
name: z.string().min(1, "Service name is required"),
type: z.string().min(1, "Service type is required"),
url: z.string().min(1, "Service URL is required"),
interval: z.string().min(1, "Heartbeat interval is required"),
retries: z.string().min(1, "Maximum retries is required"),
notificationChannel: z.string().optional(),
alertTemplate: z.string().optional(),
});
export type ServiceFormData = z.infer<typeof serviceSchema>;
@@ -0,0 +1,3 @@
export * from './useServiceActions';
export * from './useDialogState';
@@ -0,0 +1,30 @@
import { useState } from "react";
export function useDialogState() {
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const handleEditDialogChange = (open: boolean) => {
setIsEditDialogOpen(open);
};
const handleDeleteDialogChange = (open: boolean, isDeleting: boolean = false) => {
// Only allow closing if not currently deleting
if (!isDeleting || !open) {
setIsDeleteDialogOpen(open);
}
};
return {
isHistoryDialogOpen,
isDeleteDialogOpen,
isEditDialogOpen,
setIsHistoryDialogOpen,
setIsDeleteDialogOpen,
setIsEditDialogOpen,
handleEditDialogChange,
handleDeleteDialogChange
};
}
@@ -0,0 +1,205 @@
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { pb } from "@/lib/pocketbase";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { recordMuteStatusChange } from "@/services/monitoring/utils/notificationUtils";
export function useServiceActions(initialServices: Service[]) {
const [services, setServices] = useState<Service[]>(initialServices);
const [selectedService, setSelectedService] = useState<Service | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
// Update services state when props change
const updateServices = (newServices: Service[]) => {
if (JSON.stringify(services) !== JSON.stringify(newServices)) {
setServices(newServices);
}
};
const handleViewDetail = (service: Service) => {
navigate(`/service/${service.id}`);
};
const handlePauseResume = async (service: Service) => {
try {
if (service.status === "paused") {
// Resume monitoring
await serviceService.startMonitoringService(service.id);
toast({
title: "Service resumed",
description: `${service.name} monitoring has been resumed successfully.`,
});
// Update local state - ensure status is properly typed as "up"
const updatedServices = services.map(s =>
s.id === service.id ? { ...s, status: "up" as const } : s
);
setServices(updatedServices);
} else {
// Pause monitoring
await serviceService.pauseMonitoring(service.id);
// Get the pause time and update local state
const pauseTime = new Date().toISOString();
const updatedServices = services.map(s =>
s.id === service.id ? { ...s, status: "paused" as const, lastChecked: pauseTime } : s
);
setServices(updatedServices);
toast({
title: "Service paused",
description: `${service.name} monitoring has been paused successfully.`,
});
}
// Invalidate the services query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["services"] });
} catch (error) {
console.error("Error updating service status:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to update service status. Please try again.",
});
}
};
const handleEdit = (service: Service) => {
setSelectedService({...service}); // Create a copy to avoid reference issues
return service;
};
const handleDelete = (service: Service) => {
setSelectedService(service);
return service;
};
// Modified to return Promise<void> instead of Promise<boolean>
const confirmDelete = async (): Promise<void> => {
if (!selectedService || isDeleting) return;
try {
setIsDeleting(true);
// First try to pause monitoring for this service to prevent any concurrency issues
if (selectedService.status !== "paused") {
await serviceService.pauseMonitoring(selectedService.id);
}
// Set a timeout to prevent hanging UI
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Delete request timed out")), 10000);
});
const deletePromise = pb.collection('services').delete(selectedService.id);
await Promise.race([deletePromise, timeoutPromise]);
toast({
title: "Service deleted",
description: `${selectedService.name} has been deleted successfully.`,
});
// Update local state
const updatedServices = services.filter(s => s.id !== selectedService.id);
setServices(updatedServices);
// Invalidate the services query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["services"] });
setSelectedService(null);
} catch (error) {
console.error("Error deleting service:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete service. Please try again.",
});
} finally {
setIsDeleting(false);
}
};
const handleMuteAlerts = async (service: Service) => {
try {
// Check alerts status - check both fields for backward compatibility
const isMuted = service.alerts === "muted" || service.muteAlerts === true;
// Toggle the mute alerts status for this specific service
const newMuteStatus = !isMuted;
console.log(`${newMuteStatus ? "Muting" : "Unmuting"} alerts for service ${service.id} (${service.name})`);
// First update the local state immediately for better UI responsiveness
// Using proper type casting to ensure TypeScript knows we're creating valid Service objects
const updatedServices = services.map(s => {
if (s.id === service.id) {
return {
...s,
muteAlerts: newMuteStatus,
alerts: newMuteStatus ? "muted" as const : "unmuted" as const
};
}
return s;
});
setServices(updatedServices);
// Record the mute status change (this will also update the service record)
await recordMuteStatusChange(service.id, service.name, newMuteStatus);
// Show a toast message
toast({
title: newMuteStatus ? "Alerts muted" : "Alerts unmuted",
description: `Notifications for ${service.name} are now ${newMuteStatus ? "muted" : "enabled"}.`,
});
// Immediately invalidate the services query to trigger a refetch
// This ensures our local state matches the database state
await queryClient.invalidateQueries({ queryKey: ["services"] });
} catch (error) {
console.error("Error updating alert settings:", error);
// Revert the local state change if the server update failed
const revertedServices = services.map(s => {
if (s.id === service.id) {
return {
...s,
muteAlerts: service.muteAlerts,
alerts: service.alerts
};
}
return s;
});
setServices(revertedServices);
toast({
variant: "destructive",
title: "Error",
description: `Failed to ${!service.muteAlerts ? "mute" : "unmute"} alerts for ${service.name}. Please try again.`,
});
}
};
return {
services,
selectedService,
isDeleting,
setSelectedService,
updateServices,
handleViewDetail,
handlePauseResume,
handleEdit,
handleDelete,
confirmDelete,
handleMuteAlerts
};
}
@@ -0,0 +1,19 @@
interface EmptyStateProps {
statusFilter: string;
}
export function EmptyState({ statusFilter }: EmptyStateProps) {
return (
<div className="text-center py-8 text-muted-foreground">
{statusFilter === "all"
? "No incidents recorded in selected time period"
: `No ${
statusFilter === "up" ? "uptime" :
statusFilter === "down" ? "downtime" :
statusFilter === "warning" ? "warning" :
"paused"
} incidents recorded in selected time period`}
</div>
);
}
@@ -0,0 +1,61 @@
import { format } from "date-fns";
import { UptimeData } from "@/types/service.types";
import { getStatusInfo } from "./utils";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface IncidentTableProps {
incidents: UptimeData[];
}
export function IncidentTable({ incidents }: IncidentTableProps) {
const { theme } = useTheme();
const { t } = useLanguage();
if (incidents.length === 0) {
return null;
}
return (
<Table className={theme === 'dark' ? 'text-gray-200' : 'text-gray-700'}>
<TableHeader className={theme === 'dark' ? 'bg-gray-800/50' : 'bg-gray-50'}>
<TableRow className={theme === 'dark' ? 'border-gray-700' : 'border-gray-200'}>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{incidents.map((check, index) => {
const statusInfo = getStatusInfo(check.status);
const timestamp = new Date(check.timestamp);
return (
<TableRow key={check.id || `incident-${index}`} className={theme === 'dark' ? 'border-gray-800 hover:bg-gray-800/30' : 'border-gray-200 hover:bg-gray-50'}>
<TableCell>
<div className="flex flex-col">
<span>{format(timestamp, 'MMM dd, yyyy')}</span>
<span className={`text-xs ${theme === 'dark' ? 'text-gray-400' : 'text-gray-500'}`}>
{format(timestamp, 'h:mm a')}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{statusInfo.badge}
</div>
</TableCell>
<TableCell className={`font-mono ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
{check.status !== "paused" && check.responseTime > 0
? `${check.responseTime}ms`
: "N/A"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
@@ -0,0 +1,94 @@
import { useState, useEffect, useMemo } from "react";
import { UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { StatusFilterTabs } from "./StatusFilterTabs";
import { TablePagination } from "./TablePagination";
import { EmptyState } from "./EmptyState";
import { IncidentTable } from "./IncidentTable";
import { StatusFilter, PageSize } from "./types";
import { getStatusChangeEvents } from "./utils";
import { useTheme } from "@/contexts/ThemeContext";
export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] }) {
// Get current theme
const { theme } = useTheme();
// Filter state
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState<PageSize>("25");
// Reset to first page when filters change
useEffect(() => {
setCurrentPage(1);
}, [statusFilter, pageSize]);
// Filter incidents by status
const incidents = useMemo(() => {
const statusChanges = getStatusChangeEvents(uptimeData);
console.log(`Total status changes: ${statusChanges.length}`);
console.log(`Status types in incidents: ${[...new Set(statusChanges.map(i => i.status))].join(', ')}`);
if (statusFilter === "all") return statusChanges;
return statusChanges.filter(incident => incident.status === statusFilter);
}, [uptimeData, statusFilter]);
// Calculate pagination
const { paginatedIncidents, totalPages } = useMemo(() => {
if (pageSize === "all") {
return {
paginatedIncidents: incidents,
totalPages: 1,
};
}
const itemsPerPage = parseInt(pageSize, 10);
const pages = Math.ceil(incidents.length / itemsPerPage);
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
return {
paginatedIncidents: incidents.slice(start, end),
totalPages: Math.max(1, pages),
};
}, [incidents, currentPage, pageSize]);
// Calculate items per page for pagination display
const itemsPerPage = pageSize === "all" ? incidents.length : parseInt(pageSize, 10);
console.log(`Status Filter: ${statusFilter}, Incidents: ${incidents.length}, Includes paused: ${incidents.some(i => i.status === 'paused')}`);
return (
<Card className={`mb-6 transition-colors ${theme === 'dark' ? 'bg-card border-border' : 'bg-white border-gray-200'}`}>
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<CardTitle className="text-card-foreground">
<span>Incident History</span>
</CardTitle>
<StatusFilterTabs statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
</div>
</CardHeader>
<CardContent>
{incidents.length === 0 ? (
<EmptyState statusFilter={statusFilter} />
) : (
<>
<IncidentTable incidents={paginatedIncidents} />
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
totalItems={incidents.length}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
/>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,66 @@
import { Activity, AlertTriangle, CheckCircle, Pause, X } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusFilter } from "./types";
import { useTheme } from "@/contexts/ThemeContext";
interface StatusFilterTabsProps {
statusFilter: StatusFilter;
onStatusFilterChange: (value: StatusFilter) => void;
}
export function StatusFilterTabs({
statusFilter,
onStatusFilterChange
}: StatusFilterTabsProps) {
// Get current theme to apply appropriate styling
const { theme } = useTheme();
return (
<Tabs
value={statusFilter}
onValueChange={value => onStatusFilterChange(value as StatusFilter)}
className="w-full"
>
<TabsList className={`grid grid-cols-5 w-full max-w-md rounded-full ${
theme === 'dark' ? 'bg-secondary' : 'bg-slate-100'
}`}>
<TabsTrigger
value="all"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<Activity className="h-4 w-4" />
<span>All</span>
</TabsTrigger>
<TabsTrigger
value="up"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<CheckCircle className="h-4 w-4" />
<span>Up</span>
</TabsTrigger>
<TabsTrigger
value="down"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<X className="h-4 w-4" />
<span>Down</span>
</TabsTrigger>
<TabsTrigger
value="warning"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<AlertTriangle className="h-4 w-4" />
<span>Warning</span>
</TabsTrigger>
<TabsTrigger
value="paused"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<Pause className="h-4 w-4" />
<span>Paused</span>
</TabsTrigger>
</TabsList>
</Tabs>
);
}
@@ -0,0 +1,118 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { PageSize } from "./types";
interface TablePaginationProps {
currentPage: number;
totalPages: number;
pageSize: PageSize;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: PageSize) => void;
}
export function TablePagination({
currentPage,
totalPages,
pageSize,
totalItems,
itemsPerPage,
onPageChange,
onPageSizeChange,
}: TablePaginationProps) {
// Generate page numbers
const getPageNumbers = () => {
const pages = [];
const maxVisiblePages = 5;
const halfVisible = Math.floor(maxVisiblePages / 2);
let startPage = Math.max(1, currentPage - halfVisible);
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
return (
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<Select
value={pageSize}
onValueChange={(value) => onPageSizeChange(value as PageSize)}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder="25" />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="250">250</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{pageSize === 'all'
? `Showing all ${totalItems} items`
: `Showing ${Math.min((currentPage - 1) * itemsPerPage + 1, totalItems)}-${Math.min(currentPage * itemsPerPage, totalItems)} of ${totalItems} items`}
</span>
</div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{getPageNumbers().map((page) => (
<PaginationItem key={page}>
<PaginationLink
isActive={page === currentPage}
onClick={() => onPageChange(page)}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}
@@ -0,0 +1,11 @@
// Export components
export { LatestChecksTable } from './LatestChecksTable';
export { StatusFilterTabs } from './StatusFilterTabs';
export { TablePagination } from './TablePagination';
export { IncidentTable } from './IncidentTable';
export { EmptyState } from './EmptyState';
// Export types and utils
export * from './types';
export * from './utils';
@@ -0,0 +1,16 @@
import { UptimeData } from "@/types/service.types";
export type StatusFilter = "all" | "up" | "down" | "paused" | "warning";
export type PageSize = "10" | "25" | "50" | "100" | "250" | "all";
export interface LatestChecksTableProps {
uptimeData: UptimeData[];
}
export interface StatusInfo {
icon: JSX.Element;
text: string;
textColor: string;
badge: JSX.Element;
}
@@ -0,0 +1,89 @@
import { CheckCircle, AlertTriangle, X, Pause } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { UptimeData } from "@/types/service.types";
import { format } from "date-fns";
import { StatusInfo } from "./types";
// Get appropriate icon and style for each status
export const getStatusInfo = (status: string): StatusInfo => {
switch (status) {
case "up":
return {
icon: <CheckCircle className="h-4 w-4 text-green-500 mr-2" />,
text: "Up",
textColor: "text-green-500",
badge: <Badge variant="default" className="bg-emerald-800 text-white hover:bg-emerald-700">Up</Badge>
};
case "warning":
return {
icon: <AlertTriangle className="h-4 w-4 text-yellow-500 mr-2" />,
text: "Warning",
textColor: "text-yellow-500",
badge: <Badge variant="outline" className="bg-yellow-800/80 text-yellow-300 border-yellow-700 hover:bg-yellow-800">Warning</Badge>
};
case "paused":
return {
icon: <Pause className="h-4 w-4 text-gray-500 mr-2" />,
text: "Paused",
textColor: "text-gray-500",
badge: <Badge variant="outline" className="bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800">Paused</Badge>
};
default: // down
return {
icon: <X className="h-4 w-4 text-red-500 mr-2" />,
text: "Down",
textColor: "text-red-500",
badge: <Badge variant="destructive">Down</Badge>
};
}
};
// Filter the uptimeData to only include status changes (incidents)
export const getStatusChangeEvents = (uptimeData: UptimeData[]): UptimeData[] => {
if (!uptimeData.length) return [];
// First sort the data by timestamp (newest first for display)
const sortedData = [...uptimeData].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// We need to find all status changes, including to/from paused
const statusChanges: UptimeData[] = [];
// Always include the most recent check as a baseline
if (sortedData.length > 0) {
statusChanges.push(sortedData[0]);
}
// Compare each check with the previous one to detect status changes
for (let i = 0; i < sortedData.length - 1; i++) {
const currentCheck = sortedData[i];
const nextCheck = sortedData[i + 1]; // This is actually the "older" check
// If the status changed, add the "older" check to our incidents list
// This shows what the status changed TO
if (currentCheck.status !== nextCheck.status) {
statusChanges.push(nextCheck);
}
}
console.log(`Found ${statusChanges.length} status changes, including paused status changes: ${statusChanges.some(i => i.status === 'paused')}`);
return statusChanges;
};
// Get date range for display
export const getDateRangeDisplay = (incidents: UptimeData[]): string => {
if (!incidents.length) return "";
const sortedData = [...incidents].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
const start = format(new Date(sortedData[0].timestamp), 'MMM dd, yyyy');
const end = format(new Date(sortedData[sortedData.length - 1].timestamp), 'MMM dd, yyyy');
// Display date range if different dates
return start === end ? start : `${start} - ${end}`;
};
@@ -0,0 +1,15 @@
// Export all service components for easier imports
export * from './ServiceHeader';
export * from './ServiceStatsCards';
export * from './ResponseTimeChart';
export * from './incident-history/LatestChecksTable';
export * from './LoadingState';
export * from './ServiceNotFound';
export * from './ServiceMonitoringButton';
export * from './AddServiceDialog';
export * from './ServicesTableContainer';
export * from './ServicesTableView';
export * from './ServiceDeleteDialog';
export * from './ServiceHistoryDialog';
export * from './ServiceEditDialog';
@@ -0,0 +1,184 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { MoreHorizontal, Eye, Play, Pause, Edit, Bell, BellOff, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast";
interface ServiceRowActionsProps {
service: Service;
onViewDetail: (service: Service) => void;
onPauseResume: (service: Service) => Promise<void>;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
onMuteAlerts?: (service: Service) => Promise<void>;
}
export const ServiceRowActions = ({
service,
onViewDetail,
onPauseResume,
onEdit,
onDelete,
onMuteAlerts
}: ServiceRowActionsProps) => {
const { toast } = useToast();
// Handle pause/resume directly from dropdown
const handlePauseResume = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
if (service.status === "paused") {
// Resume monitoring
console.log(`Resuming monitoring for service ${service.id} (${service.name}) from dropdown`);
// First ensure we update the status
await serviceService.resumeMonitoring(service.id);
// Then start monitoring service (performs an immediate check)
await serviceService.startMonitoringService(service.id);
toast({
title: "Monitoring resumed",
description: `Monitoring for ${service.name} has been resumed. First check is running now.`,
});
} else {
// Pause monitoring
console.log(`Pausing monitoring for service ${service.id} (${service.name}) from dropdown`);
await serviceService.pauseMonitoring(service.id);
toast({
title: "Monitoring paused",
description: `Monitoring for ${service.name} has been paused.`,
});
}
// Call the parent handler to refresh the UI
onPauseResume(service);
} catch (error) {
console.error("Error toggling monitoring:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to change monitoring status. Please try again.",
});
}
};
// Check alerts status - check both fields for backward compatibility
const alertsMuted = service.alerts === "muted" || service.muteAlerts === true;
// Handle mute/unmute alerts
const handleMuteAlerts = async (e: React.MouseEvent) => {
e.stopPropagation();
if (onMuteAlerts) {
try {
console.log(`Attempting to ${alertsMuted ? 'unmute' : 'mute'} alerts for service ${service.id} (${service.name})`);
await onMuteAlerts(service);
} catch (error) {
console.error("Error toggling alerts:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to change alert settings. Please try again.",
});
}
}
};
return (
<div className="flex space-x-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title="More options"
className="opacity-70 hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48 bg-gray-900 border border-gray-800 text-white"
>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={(e) => {
e.stopPropagation();
onViewDetail(service);
}}
>
<Eye className="h-4 w-4" />
<span>View Detail</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={handlePauseResume}
>
{service.status === "paused" ? (
<>
<Play className="h-4 w-4" />
<span>Resume Monitoring</span>
</>
) : (
<>
<Pause className="h-4 w-4" />
<span>Pause Monitoring</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit(service);
}}
>
<Edit className="h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={handleMuteAlerts}
>
{alertsMuted ? (
<>
<Bell className="h-4 w-4" />
<span>Unmute Alerts</span>
</>
) : (
<>
<BellOff className="h-4 w-4" />
<span>Mute Alerts</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
className="flex items-center gap-2 text-red-500 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={(e) => {
e.stopPropagation();
onDelete(service);
}}
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
@@ -0,0 +1,61 @@
import React from "react";
import { BellOff } from "lucide-react";
import { Service } from "@/types/service.types";
interface ServiceRowHeaderProps {
service: Service;
}
export const ServiceRowHeader = ({ service }: ServiceRowHeaderProps) => {
// Display URL for HTTP services, hostname for others
const shouldDisplayFullUrl = service.type.toLowerCase() === "http";
let serviceSubtitle = "";
// Check alerts status - check both fields for backward compatibility
const alertsMuted = service.alerts === "muted" || service.muteAlerts === true;
if (service.url) {
try {
const url = service.url;
// If the URL doesn't start with http:// or https://, add https:// prefix
const formattedUrl = (!url.startsWith('http://') && !url.startsWith('https://'))
? `https://${url}`
: url;
try {
// Now try to parse it as a URL
const urlObj = new URL(formattedUrl);
if (shouldDisplayFullUrl) {
serviceSubtitle = formattedUrl;
} else {
serviceSubtitle = urlObj.hostname;
}
} catch (urlError) {
// If URL parsing still fails, just show the original URL
serviceSubtitle = url;
}
} catch (e) {
// If any other error occurs, just show the original URL
serviceSubtitle = service.url;
console.log("Error processing URL:", e);
}
}
return (
<div className="flex items-center gap-2">
<div>
<div className="text-base font-medium">{service.name}</div>
{service.url && (
<div className="text-sm text-gray-500 mt-1">{serviceSubtitle}</div>
)}
</div>
{/* Add a visual indicator if alerts are muted for this service */}
{alertsMuted && (
<div className="ml-1" title="Alerts muted">
<BellOff className="h-4 w-4 text-gray-400" />
</div>
)}
</div>
);
};
@@ -0,0 +1,27 @@
import React from "react";
import { AlertTriangle } from "lucide-react";
interface ServiceRowResponseTimeProps {
responseTime: number;
}
export const ServiceRowResponseTime = ({ responseTime }: ServiceRowResponseTimeProps) => {
// Determine if response time is high (≥ 1000ms)
const isResponseTimeHigh = responseTime >= 1000;
return (
<div className="font-mono text-base flex items-center gap-1.5">
{responseTime > 0 ? (
<>
<span className={isResponseTimeHigh ? "text-amber-500 font-semibold" : ""}>
{responseTime}ms
</span>
{isResponseTimeHigh && (
<AlertTriangle className="h-4 w-4 text-amber-500" />
)}
</>
) : 'N/A'}
</div>
);
};
@@ -0,0 +1,4 @@
export * from './ServiceRowActions';
export * from './ServiceRowHeader';
export * from './ServiceRowResponseTime';
@@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Settings } from "lucide-react";
import { settingsService, type GeneralSettings } from "@/services/settingsService";
import { useToast } from "@/hooks/use-toast";
const GeneralSettingsPanel = () => {
const { toast } = useToast();
const [formData, setFormData] = useState<Partial<GeneralSettings>>({});
const [isEditing, setIsEditing] = useState(false);
const { data: settings, isLoading, error, refetch } = useQuery({
queryKey: ['generalSettings'],
queryFn: settingsService.getGeneralSettings,
});
useEffect(() => {
if (settings) {
setFormData(settings);
}
}, [settings]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSave = async () => {
if (!settings?.id || !formData) return;
try {
const result = await settingsService.updateGeneralSettings(settings.id, formData);
if (result) {
toast({
title: "Settings updated",
description: "Your settings have been updated successfully.",
variant: "default",
});
refetch();
setIsEditing(false);
}
} catch (error) {
console.error("Error updating settings:", error);
toast({
title: "Update failed",
description: "There was a problem updating your settings.",
variant: "destructive",
});
}
};
if (isLoading) {
return <div className="p-4">Loading settings...</div>;
}
if (error) {
return <div className="p-4 text-red-500">Error loading settings</div>;
}
return (
<div className="p-4">
<Card>
<CardHeader>
<CardTitle>System Settings</CardTitle>
<CardDescription>
Configure your system settings and preferences
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="system_name">System Name</Label>
<Input
id="system_name"
name="system_name"
value={formData?.system_name || ''}
onChange={handleChange}
disabled={!isEditing}
placeholder="My Monitoring System"
/>
</div>
</CardContent>
<CardFooter className="flex justify-between">
{isEditing ? (
<>
<Button variant="outline" onClick={() => setIsEditing(false)}>Cancel</Button>
<Button onClick={handleSave}>Save Changes</Button>
</>
) : (
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
)}
</CardFooter>
</Card>
</div>
);
};
export default GeneralSettingsPanel;
@@ -0,0 +1,91 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Github, FileText, Twitter, MessageCircle, Code2, ServerIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/contexts/LanguageContext";
import { useTheme } from "@/contexts/ThemeContext";
import { useSystemSettings } from "@/hooks/useSystemSettings";
export const AboutSystem: React.FC = () => {
const {
t
} = useLanguage();
const {
theme
} = useTheme();
const {
systemName
} = useSystemSettings();
return <div className="space-y-6 animate-fade-in">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
<p className="text-muted-foreground text-base leading-relaxed mt-2">{t('aboutCheckCle')}</p>
</div>
<Separator />
<div className="grid gap-8 md:grid-cols-2">
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
<CardHeader className="bg-muted/50 pb-4">
<CardTitle className="flex items-center gap-2">
<ServerIcon className={`h-5 w-5 ${theme === 'dark' ? 'text-sky-400' : 'text-sky-600'}`} />
<span className="font-thin text-xl">{t('systemDescription')}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-3 pt-2">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('systemVersion')}</span>
<span className="text-foreground font-medium">{t('version')} 1.0.0</span>
</div>
<Separator className="my-1" />
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('license')}</span>
<span className="text-foreground font-medium">{t('mitLicense')}</span>
</div>
<Separator className="my-1" />
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('releasedOn')}</span>
<span className="text-foreground font-medium">May 10, 2025</span>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
<CardHeader className="bg-muted/50 pb-4">
<CardTitle className="flex items-center gap-2">
<Code2 className={`h-5 w-5 ${theme === 'dark' ? 'text-emerald-400' : 'text-emerald-600'}`} />
<span>{t('links')}</span>
</CardTitle>
<CardDescription className="font-medium text-base">{systemName || 'CheckCle'} {t('resources').toLowerCase()}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="grid grid-cols-1 gap-3">
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://github.com/operacle/checkcle", "_blank")}>
<Github className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('viewOnGithub')}</span>
</Button>
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://docs.checkcle.io", "_blank")}>
<FileText className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('viewDocumentation')}</span>
</Button>
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://x.com/tlengoss", "_blank")}>
<Twitter className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('followOnX')}</span>
</Button>
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("#", "_blank")}>
<MessageCircle className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('joinDiscord')}</span>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>;
};
export default AboutSystem;
@@ -0,0 +1,2 @@
export { default as AboutSystem } from './AboutSystem';
@@ -0,0 +1,89 @@
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { templateService } from "@/services/templateService";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCcw } from "lucide-react";
import { TemplateList } from "./TemplateList";
import { TemplateDialog } from "./TemplateDialog";
import { useToast } from "@/hooks/use-toast";
export const AlertsTemplates = () => {
const { toast } = useToast();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<string | null>(null);
const {
data: templates = [],
isLoading,
error,
refetch
} = useQuery({
queryKey: ['notification_templates'],
queryFn: templateService.getTemplates,
});
const handleAddTemplate = () => {
setEditingTemplate(null);
setIsDialogOpen(true);
};
const handleEditTemplate = (id: string) => {
setEditingTemplate(id);
setIsDialogOpen(true);
};
const handleRefresh = () => {
refetch();
toast({
title: "Refreshing",
description: "Updating template list...",
});
};
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Alert Templates</CardTitle>
<div className="flex space-x-2">
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
<RefreshCcw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button onClick={handleAddTemplate}>
<Plus className="h-4 w-4 mr-2" />
Add Template
</Button>
</div>
</CardHeader>
<CardContent>
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={handleEditTemplate}
refetchTemplates={refetch}
/>
)}
</CardContent>
<TemplateDialog
open={isDialogOpen}
templateId={editingTemplate}
onOpenChange={setIsDialogOpen}
onSuccess={() => {
refetch();
setIsDialogOpen(false);
}}
/>
</Card>
);
};
@@ -0,0 +1,123 @@
import React, { useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTemplateForm } from "./hooks/useTemplateForm";
import { BasicTemplateFields } from "./form/BasicTemplateFields";
import { MessagesTabContent } from "./form/MessagesTabContent";
import { PlaceholdersTabContent } from "./form/PlaceholdersTabContent";
import { Loader2, ChevronDown } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
interface TemplateDialogProps {
open: boolean;
templateId: string | null;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export const TemplateDialog: React.FC<TemplateDialogProps> = ({
open,
templateId,
onOpenChange,
onSuccess,
}) => {
const {
form,
isEditMode,
isLoadingTemplate,
isSubmitting,
onSubmit
} = useTemplateForm({
templateId,
open,
onOpenChange,
onSuccess
});
// For debugging purposes
useEffect(() => {
if (open) {
console.log("Template dialog opened. Edit mode:", isEditMode, "Template ID:", templateId);
// Log form values when they change
const subscription = form.watch((value) => {
console.log("Current form values:", value);
});
return () => subscription.unsubscribe();
}
}, [open, isEditMode, templateId, form]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{isEditMode ? "Edit Template" : "Add Template"}</DialogTitle>
</DialogHeader>
{isLoadingTemplate ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2">Loading template data...</span>
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 flex-1 flex flex-col">
<div className="relative flex-1">
<ScrollArea className="pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="space-y-6 pb-6 pr-4">
<BasicTemplateFields control={form.control} />
<Tabs defaultValue="messages">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="messages">Messages</TabsTrigger>
<TabsTrigger value="placeholders">Placeholders</TabsTrigger>
</TabsList>
<TabsContent value="messages" className="pt-4">
<MessagesTabContent control={form.control} />
</TabsContent>
<TabsContent value="placeholders" className="pt-4">
<PlaceholdersTabContent control={form.control} />
</TabsContent>
</Tabs>
</div>
</ScrollArea>
<div className="absolute bottom-2 right-4 text-muted-foreground opacity-60">
<ChevronDown className="h-4 w-4 animate-bounce" />
</div>
</div>
<DialogFooter className="mt-2 pt-2 border-t border-border">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || isLoadingTemplate}
className="relative"
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isSubmitting
? (isEditMode ? "Updating..." : "Creating...")
: (isEditMode ? "Update Template" : "Create Template")}
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,168 @@
import React, { useState } from "react";
import { NotificationTemplate, templateService } from "@/services/templateService";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Edit, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
interface TemplateListProps {
templates: NotificationTemplate[];
isLoading: boolean;
onEdit: (id: string) => void;
refetchTemplates: () => void;
}
export const TemplateList: React.FC<TemplateListProps> = ({
templates,
isLoading,
onEdit,
refetchTemplates,
}) => {
const { toast } = useToast();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<NotificationTemplate | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeletePrompt = (template: NotificationTemplate) => {
setTemplateToDelete(template);
setDeleteDialogOpen(true);
};
const handleDeleteTemplate = async () => {
if (!templateToDelete) return;
setIsDeleting(true);
try {
await templateService.deleteTemplate(templateToDelete.id);
toast({
title: "Template deleted",
description: `Template "${templateToDelete.name}" has been removed.`,
});
refetchTemplates();
} catch (error) {
console.error("Error deleting template:", error);
toast({
title: "Error",
description: "Failed to delete template. Please try again.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
setTemplateToDelete(null);
}
};
if (isLoading) {
return (
<div className="py-8 flex justify-center">
<div className="animate-pulse flex flex-col items-center">
<div className="h-4 bg-gray-200 rounded w-32 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-64"></div>
</div>
</div>
);
}
if (templates.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground mb-2">No templates found</p>
<p className="text-sm text-muted-foreground">
Create your first notification template to get started.
</p>
</div>
);
}
return (
<>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium">{template.name}</TableCell>
<TableCell>
<Badge variant="outline">{template.type}</Badge>
</TableCell>
<TableCell>
{new Date(template.created).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
size="sm"
variant="ghost"
onClick={() => onEdit(template.id)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeletePrompt(template)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Template</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the template "{templateToDelete?.name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDeleteTemplate();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
@@ -0,0 +1,61 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Control } from "react-hook-form";
interface BasicTemplateFieldsProps {
control: Control<any>;
}
export const BasicTemplateFields: React.FC<BasicTemplateFieldsProps> = ({ control }) => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Template Name</FormLabel>
<FormControl>
<Input
placeholder="Enter template name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Template Type</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select template type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="service">Service</SelectItem>
<SelectItem value="incident">Incident</SelectItem>
<SelectItem value="maintenance">Maintenance</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,124 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Control } from "react-hook-form";
import { Card, CardContent } from "@/components/ui/card";
interface MessagesTabContentProps {
control: Control<any>;
}
export const MessagesTabContent: React.FC<MessagesTabContentProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<FormField
control={control}
name="up_message"
render={({ field }) => (
<FormItem>
<FormLabel>Up Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is UP. Response time: ${response_time}ms"
className="min-h-24"
{...field}
/>
</FormControl>
<FormDescription>
Message sent when a service returns to UP status
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="down_message"
render={({ field }) => (
<FormItem>
<FormLabel>Down Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is DOWN. Status: ${status}"
className="min-h-24"
{...field}
/>
</FormControl>
<FormDescription>
Message sent when a service goes DOWN
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={control}
name="maintenance_message"
render={({ field }) => (
<FormItem>
<FormLabel>Maintenance Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is under maintenance"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="incident_message"
render={({ field }) => (
<FormItem>
<FormLabel>Incident Message</FormLabel>
<FormControl>
<Textarea
placeholder="Warning: Service ${service_name} has an incident"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="resolved_message"
render={({ field }) => (
<FormItem>
<FormLabel>Resolved Message</FormLabel>
<FormControl>
<Textarea
placeholder="Issue with service ${service_name} has been resolved"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,132 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface PlaceholdersTabContentProps {
control: Control<any>;
}
export const PlaceholdersTabContent: React.FC<PlaceholdersTabContentProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Template Placeholders</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="service_name_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Service Name Placeholder</FormLabel>
<FormControl>
<Input placeholder="${service_name}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service name in messages
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="response_time_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Response Time Placeholder</FormLabel>
<FormControl>
<Input placeholder="${response_time}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for response time in milliseconds
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="status_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Status Placeholder</FormLabel>
<FormControl>
<Input placeholder="${status}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service status (UP, DOWN, etc.)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="threshold_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Threshold Placeholder</FormLabel>
<FormControl>
<Input placeholder="${threshold}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for threshold values in alerts
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Placeholder Usage Guide</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
These placeholders will be replaced with actual values when notifications are sent:
</p>
<div className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-2">
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{service_name}"}</code>
<p className="text-xs text-muted-foreground mt-1">The name of the service</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{response_time}"}</code>
<p className="text-xs text-muted-foreground mt-1">Response time in milliseconds</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{status}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service status (UP, DOWN)</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{threshold}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service threshold value</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{url}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service URL</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{time}"}</code>
<p className="text-xs text-muted-foreground mt-1">Current date and time</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,4 @@
export * from './BasicTemplateFields';
export * from './MessagesTabContent';
export * from './PlaceholdersTabContent';
@@ -0,0 +1,2 @@
export * from './useTemplateForm';

Some files were not shown because too many files have changed in this diff Show More