chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:02 +08:00
commit 171e2a4a0d
1315 changed files with 266099 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
server/
**/node_modules
**/dist
**/.next
**/.turbo
**/.cache
**/coverage
**/*.log
+2
View File
@@ -0,0 +1,2 @@
# These owners will be the default owners for everything in
* @tolaleng
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: #[tolaleng]
patreon: # Replace with a single Patreon username
open_collective: #checkcle
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+94
View File
@@ -0,0 +1,94 @@
name: 🐛 Bug Report
description: Report a reproducible bug to help us improve.
title: "[Bug]: "
labels: [bug]
assignees: []
body:
- type: markdown
attributes:
value: |
**Thank you for taking the time to report a bug!**
Please complete the form below to help us reproduce and fix the issue more effectively.
- type: input
id: environment
attributes:
label: Environment
description: |
Describe the environment where the issue occurred.
(e.g., OS, browser, Node.js version, Docker version, etc.)
placeholder: "Example: Ubuntu 22.04, Chrome 125, Node.js 20.3.0"
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: |
A clear and concise description of the issue you encountered.
placeholder: "When I do X, Y happens instead of Z..."
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: |
Provide step-by-step instructions to reproduce the issue.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See the error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: "The app should..."
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots or Videos
description: |
If applicable, add screenshots or screen recordings to help illustrate the issue.
placeholder: "Drag and drop or paste screenshots here."
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs
description: |
If applicable, paste any relevant logs or error messages.
Please remove sensitive information before submitting.
render: shell
placeholder: |
```
Error: Something went wrong
at index.js:123:45
```
validations:
required: false
- type: checkboxes
id: terms
attributes:
label: Checklist
description: |
Please confirm the following before submitting:
options:
- label: I have searched existing issues to avoid creating duplicates.
required: true
- label: I have provided sufficient information to reproduce the issue.
required: true
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
+49
View File
@@ -0,0 +1,49 @@
name: 📝 Documentation Improvement
description: Suggest improvements or report issues in documentation.
title: "[Docs]: "
labels: [documentation]
assignees: []
body:
- type: markdown
attributes:
value: |
Thank you for helping improve our documentation!
- type: textarea
id: description
attributes:
label: What part of the documentation needs improvement?
description: |
Describe the issue clearly.
placeholder: "The installation guide is missing steps for..."
validations:
required: true
- type: textarea
id: location
attributes:
label: Location
description: |
Provide a link or path to the affected documentation.
placeholder: "https://github.com/operacle/checkcle/docs/INSTALL.md"
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested Change
description: |
How would you improve it?
placeholder: "I suggest adding a section about..."
validations:
required: false
- type: checkboxes
id: confirmation
attributes:
label: Checklist
options:
- label: I have searched existing issues for similar documentation problems.
required: true
@@ -0,0 +1,60 @@
name: 🚀 Feature Request
description: Suggest an idea to improve this project.
title: "[Feature]: "
labels: [enhancement]
assignees: []
body:
- type: markdown
attributes:
value: |
**Thank you for suggesting a feature!**
Please fill out this form so we can better understand your idea.
- type: textarea
id: description
attributes:
label: Feature Description
description: A clear and concise description of the feature youd like to see.
placeholder: "I would like to have..."
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation
description: Please explain why this feature would be useful.
placeholder: "This feature would help because..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds?
placeholder: "I have tried..."
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.
placeholder: "Links to related issues or references..."
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: |
Before submitting, please confirm:
options:
- label: I have searched existing issues to make sure this feature hasnt been requested yet.
required: true
- label: I have described the feature clearly and provided supporting details.
required: true
+54
View File
@@ -0,0 +1,54 @@
name: ❓ Ask for Help
description: Ask a question or request guidance related to this project.
title: "[Question]: "
labels: [question]
assignees: []
body:
- type: markdown
attributes:
value: |
**Thanks for reaching out!**
Please provide as much detail as possible so we can better assist you.
- type: textarea
id: question
attributes:
label: What do you need help with?
description: Clearly describe your question or what you're trying to achieve.
placeholder: |
I am trying to do X, but I'm not sure how to...
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: |
Provide any relevant context, related issues, pull requests, or documentation links that could help us understand your question better.
placeholder: "Mention related issues, links to docs, or previous discussions..."
validations:
required: false
- type: textarea
id: environment
attributes:
label: Environment Details
description: |
If relevant, provide details about your environment (e.g., OS, browser, Node.js version).
placeholder: "Example: Windows 11, Node.js v20.3.0, Firefox 125"
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: |
Before submitting, please confirm the following:
options:
- label: I have searched existing issues and discussions for a solution.
required: true
- label: I have read the relevant documentation.
required: true
+51
View File
@@ -0,0 +1,51 @@
name: 🛡️ Security Issue
description: Report a potential security vulnerability.
title: "[Security]: "
labels: [security]
assignees: []
body:
- type: markdown
attributes:
value: |
⚠️ **IMPORTANT: Please do NOT disclose sensitive security details in this public issue.**
If you believe you have found a security vulnerability, **please report it privately** to help keep users safe.
📧 **Disclosure Process:**
- Email: [security@checkcle.io](mailto:security@checkcle.io)
- Or follow our [SECURITY.md](../../blob/main/SECURITY.md) policy.
You can use this issue only to let maintainers know that you have sent or will send a report.
- type: textarea
id: summary
attributes:
label: Summary
description: |
Briefly describe the type of vulnerability you believe you have found (without including sensitive details).
placeholder: "Example: Possible SQL injection in the user login endpoint."
validations:
required: true
- type: textarea
id: contact
attributes:
label: Contact Information
description: |
Provide an email or other way for maintainers to contact you if we need clarification.
placeholder: "your.email@example.com"
validations:
required: true
- type: checkboxes
id: confirmation
attributes:
label: Confirmation
description: |
Please confirm:
options:
- label: I will not share sensitive exploit details in this issue.
required: true
- label: I have sent or will send a full report to the private disclosure contact.
required: true
@@ -0,0 +1,38 @@
name: 🌎 Translation Request
description: Request a translation or report translation issues.
title: "[Translation]: "
labels: [translation]
assignees: []
body:
- type: markdown
attributes:
value: |
Help us make this project accessible in more languages!
- type: input
id: language
attributes:
label: Language
description: What language is this about?
placeholder: "Example: Spanish"
validations:
required: true
- type: textarea
id: description
attributes:
label: Details
description: |
Describe what youd like to have translated or what issue you found.
placeholder: "Please translate the Getting Started guide..."
validations:
required: true
- type: checkboxes
id: confirmation
attributes:
label: Checklist
options:
- label: I have searched existing issues for similar requests.
required: true
@@ -0,0 +1,59 @@
name: Build and Publish Frontend Image
on:
push:
branches: [ "develop", "feature/**" ]
paths:
- 'application/**'
- '.github/workflows/build-frontend-image.yml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/checkcle-frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: application
file: application/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
+29
View File
@@ -0,0 +1,29 @@
# 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?
pocketbase.exe
application/src/lib/pocketbase.ts
server/pb_data/
Dockerfile
application/src/lib/pocketbase-prod.ts
+4
View File
@@ -0,0 +1,4 @@
fileignoreconfig:
- filename: application/package-lock.json
checksum: 3253a06ef27a634ec2b8a8fe9c156288b9fcf57024623c7ddc3d321f2568e498
version: "1.0"
+81
View File
@@ -0,0 +1,81 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders of the **CheckCle** project pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all project spaces, and also applies when an individual is officially representing the project in public spaces. Examples include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at:
📧 **Email:** [hello@checkcle.io](mailto:hello@checkcle.io)
All complaints will be reviewed and investigated promptly and fairly.
All project maintainers are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
**1. Correction**
- *Community Impact:* Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
- *Consequence:* A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
**2. Warning**
- *Community Impact:* A violation through a single incident or series of actions.
- *Consequence:* A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
**3. Temporary Ban**
- *Community Impact:* A serious violation of community standards, including sustained inappropriate behavior.
- *Consequence:* A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
**4. Permanent Ban**
- *Community Impact:* Demonstrating a pattern of violation of community standards, including sustained harassment, or showing no remorse after being warned.
- *Consequence:* A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at:
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
For answers to common questions about this code of conduct, see:
https://www.contributor-covenant.org/faq
[homepage]: https://www.contributor-covenant.org
+181
View File
@@ -0,0 +1,181 @@
## 🌐 Select Language
<table align="center">
<tr>
<td align="center">
<a href="CONTRIBUTING.md">
<img src="https://flagcdn.com/24x18/gb.png" alt="English" />
<br/><strong>English</strong>
</a>
</td>
<td align="center">
<a href="docs/CONTRIBUTING_km.md">
<img src="https://flagcdn.com/24x18/kh.png" alt="Khmer" />
<br/><strong>ខ្មែរ</strong>
</a>
</td>
<td align="center">
<a href="docs/CONTRIBUTING_ja.md">
<img src="https://flagcdn.com/24x18/jp.png" alt="Japanese" />
<br/><strong>Japanese</strong>
</a>
</td>
</tr>
</table>
<p align="center">
Thank you to all our contributors, users, and supporters for making this project thrive.
</p>
<p align="center">
🚀 <strong>Stay tuned for more updates, features, and improvements.</strong>
</p>
# 🛠️ 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/yourusername/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
If you're not using localhost, please run with this (./pocketbase serve --http=0.0.0.0:8090 --dir pb_data)
```
### 4. Start Service Check Operation
```bash
#Server Backend
Start Service Operation (Check Service for PING, HTTP, TCP, DNS)
cd server/service-operation
go run main.go (you do not need to change the .env while it's the localhost connection)
```
### 5. Start Distributed Regional Agent
```bash
#### 1. Fork the Repository
Click "Fork" on [GitHub](https://github.com/operacle/Distributed-Regional-Monitoring) to create your own copy.
#### 2. Clone Your Fork
git clone https://github.com/yourusername/Distributed-Regional-Monitoring.git
cd Distributed-Regional-Monitoring
#### 3. Install Go Service (make sure you have installed the Go Service)
Copy .env.example -> .env
Change regional agent configuration in .env file
and Run: go run main.go
```
---
## ✅ 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 `develop` 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! 💙
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 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.
+194
View File
@@ -0,0 +1,194 @@
## 🌐 Select Language
<table align="center">
<tr>
<td align="center">
<a href="README.md">
<img src="https://flagcdn.com/24x18/gb.png" alt="English" />
<br/><strong>English</strong>
</a>
</td>
<td align="center">
<a href="docs/README_km.md">
<img src="https://flagcdn.com/24x18/kh.png" alt="Khmer" />
<br/><strong>ខ្មែរ</strong>
</a>
</td>
<td align="center">
<a href="docs/README_ja.md">
<img src="https://flagcdn.com/24x18/jp.png" alt="Japanese" />
<br/><strong>Japanese</strong>
</a>
</td>
<td align="center">
<a href="docs/README_zhcn.md">
<img src="https://flagcdn.com/24x18/cn.png" alt="Chinese" />
<br/><strong>Chinese</strong>
</a>
</td>
</tr>
</table>
<p align="center">
Thank you to all our contributors, users, and supporters for making this project thrive.
</p>
<p align="center">
🚀 <strong>Stay tuned for more updates, features, and improvements.</strong>
</p>
![CheckCle Platform](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/server-detail-page.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)
User: admin@example.com | Passwd: Admin123456
## 🌟 Core Features
### 📝 Roadmap : [DEVELOPMENT_ROADMAP](docs/DEVELOPMENT_ROADMAP.md)
### 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
- Distributed Regional Monitoring
- 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, Slack, Matrix, and more
- Reports & Analytics
- Settings Panel (User Management, Data Retention, Multi-language, Themes (Dark & Light Mode), Notification and channels and alert templates).
## #️⃣ Getting Started
### Current Architecture Support
* ✅ x86_64 PCs, laptops, servers (amd64)
* ✅ Modern Raspberry Pi 3/4/5 with (64-bit OS), Apple Silicon Macs (arm64)
### Install CheckCle using one of the following methods:
1. Install with Docker Compose Configuration (Recommended)
```bash
version: '3.9'
services:
checkcle:
image: operacle/checkcle:latest
container_name: checkcle
restart: unless-stopped
ports:
- "8090:8090" # Web Application
volumes:
- /opt/pb_data:/mnt/pb_data # Host directory mapped to container path
ulimits:
nofile:
soft: 4096
hard: 8192
```
2. Install with docker run. Just copy ready docker run command below
```bash
docker run -d \
--name checkcle \
--restart unless-stopped \
-p 8090:8090 \
-v /opt/pb_data:/mnt/pb_data \
--ulimit nofile=4096:8192 \
operacle/checkcle:latest
```
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.io
###
![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-1.4.png)
![Service Detail Page](https://cdn.checkcle.io/images/uptime/uptime-regional-detail.png)
![checkcle-server-instance](https://cdn.checkcle.io/images/server/server-list.png)
![SSL Monitoring](https://cdn.checkcle.io/images/ssl-domain/ssl-list.png)
![Notification System](https://cdn.checkcle.io/general/powerfull_notification.png)
## 🌟 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
---
## Sponsors
Sponsorships will no longer be accepted. From now on, support will only be accepted in the form of ecosystem and community partnerships that provide infrastructure such as cloud servers, domains, or hosting credits.
If youre a tech company interested in supporting CheckCle, please contact the author directly at tolaleng@checkcle.io
### 🤝 Ecosystem & Community Partner
<div style="display: flex; align-items: center; gap: 10px;">
<a href="https://github.com/gitbookio">
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
width="75" height="75"
style="border-radius: 50%;"
alt="GitBook Logo" />
</a>
<a href="https://www.cloudflare.com">
<img src="https://cdn.checkcle.io/images/sponsor/cloudflare-checkcle_logo.png"
height="60"
alt="Cloudflare Logo" />
</a>
<a href="https://m.do.co/c/0c27ef82475f">
<img src="https://cdn.checkcle.io/images/sponsor/digitalocean_checkcle.png"
height="50"
alt="DigitalOcean Logo" />
</a>
</a>
<a href="https://www.jetbrains.com/">
<img src="https://cdn.checkcle.io/images/sponsor/jetbrains.png"
height="50"
alt="Jetbrains Logo" />
</a>
</div>
---
## 👥 Contributors
Thank you for contributing and continuously making CheckCle better, you're awesome 🫶
[![](https://contrib.rocks/image?repo=operacle/checkcle)](https://github.com/operacle/checkcle/graphs/contributors)
---
## 🤝 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 [CheckCle](https://github.com/operacle/checkcle.git) repo, share it on socials, and invite others to contribute!
---
## 🌍 Stay Connected
- Website: [checkcle.io](https://checkcle.io)
- Documentation: [docs.checkcle.io](https://docs.checkcle.io) | Big thanks to [GitBook](https://github.com/gitbookio) for sponsoring the OSS site plan for CheckCle!
- Chat on Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
- Follow us on X: [@asqrm_io](https://x.com/asqrm_io)
## 📜 License
CheckCle is released under the MIT License.
---
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`operacle/checkcle`
- 原始仓库:https://github.com/operacle/checkcle
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+81
View File
@@ -0,0 +1,81 @@
# 🔐 Security Policy
## 📦 Project: [checkcle](https://github.com/operacle/checkcle)
**checkcle** is a self-hosted uptime and server monitoring tool built with TypeScript and Go.
We care about the security and privacy of users running this project in production environments.
---
## 📣 Reporting a Vulnerability
If you believe you have found a security vulnerability in this project:
- **DO NOT** open a public issue to report it.
- Please report it responsibly via one of the following methods:
### 🔐 Preferred: [Report a Vulnerability via GitHub](https://github.com/operacle/checkcle/security/advisories/new)
- Use the GitHub security advisory form (private and secure).
- Attach as much detail as possible:
- Description of the issue
- Affected version or commit hash
- Reproduction steps
- Impact and any potential mitigations
- Logs or screenshots (if available)
### 📧 Alternatively: Contact the Maintainer
- Email: `security@checkcle.io`
- Optionally include a PGP public key for encrypted messages
We aim to respond within **35 business days**.
---
## ✅ Supported Versions
We support the latest stable release of `checkcle`. Security patches may also be applied to recent versions at our discretion.
| Version | Supported |
|---------|-----------|
| `develop` (latest) | ✅ Yes |
| `main` (latest) | ✅ Yes |
| Older versions | ⚠️ Best-effort |
| Pre-release or forks | ❌ No |
---
## 🔍 Security Practices
CheckCle follows these practices to improve overall security:
- 🔎 Regular vulnerability scanning (npm audit for JavaScript dependencies, govulncheck for Go modules)
- ⛓️ Dependency pinning (package-lock.json and Go modules)
- ✅ Type-safe code in TypeScript and memory-safe design in Go
- 🧪 Continuous testing and CI pipelines
- 🔐 No data is stored or transmitted unless explicitly configured by the user
- 🧑‍💻 All code contributions are reviewed before merging
---
## ⚠️ Known Security Limitations
- Outbound HTTPS requests: CheckCle agents perform outbound HTTPS connections to send metric data to the backend server. Avoid deploying in untrusted or high-risk environments without appropriate network policies and monitoring.
- The data may be lost upon system restarts or crashes. Always ensure that backup (pb_data) and recovery mechanisms are in place in production environments.
---
## 📄 License
This project is released under the [MIT License](./LICENSE). Use at your own risk. The Creator and contributors are not liable for misuse, data loss, or operational impact resulting from use of the software.
---
## 🙌 Acknowledgements
We appreciate responsible disclosures from the community. Your efforts help us make the open-source ecosystem safer for everyone.
Thanks & Regards,
— [Tola Leng](https://github.com/tolaleng)
+6
View File
@@ -0,0 +1,6 @@
.git
node_modules
dist
.cache
*.log
+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",
},
}
);
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CheckCle</title>
<meta name="description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
<meta name="author" content="Tola Leng" />
<meta property="og:title" content="checkcle-an-open-source" />
<meta property="og:description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@checkcle_oss" />
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6942
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-query": "^5.56.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"input-otp": "^1.2.4",
"jspdf": "^3.0.1",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"pocketbase": "^0.26.8",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@tailwindcss/typography": "^0.5.15",
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 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: /
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

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

After

Width:  |  Height:  |  Size: 2.1 KiB

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

After

Width:  |  Height:  |  Size: 2.1 KiB

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

After

Width:  |  Height:  |  Size: 3.3 KiB

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

After

Width:  |  Height:  |  Size: 2.8 KiB

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

After

Width:  |  Height:  |  Size: 3.7 KiB

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

After

Width:  |  Height:  |  Size: 3.3 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 3.7 KiB

+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;
}
+72
View File
@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from '@/components/ui/sonner';
import { ThemeProvider } from './contexts/ThemeContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { SidebarProvider } from './contexts/SidebarContext';
import Index from './pages/Index';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import InstanceMonitoring from './pages/InstanceMonitoring';
import ContainerMonitoring from './pages/ContainerMonitoring';
import ServiceDetail from './pages/ServiceDetail';
import SslDomain from './pages/SslDomain';
import ScheduleIncident from './pages/ScheduleIncident';
import OperationalPage from './pages/OperationalPage';
import RegionalMonitoring from './pages/RegionalMonitoring';
import Settings from './pages/Settings';
import Profile from './pages/Profile';
import NotFound from './pages/NotFound';
import PublicStatusPage from './pages/PublicStatusPage';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import ServerDetail from './pages/ServerDetail';
function App() {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000
}
}
}));
return (
<BrowserRouter>
<ThemeProvider>
<LanguageProvider>
<SidebarProvider>
<QueryClientProvider client={queryClient}>
<Toaster />
<Routes>
{/* Public routes */}
<Route path="/public/:slug" element={<PublicStatusPage />} />
{/* Protected routes */}
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Index /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/instance-monitoring" element={<ProtectedRoute><InstanceMonitoring /></ProtectedRoute>} />
<Route path="/server-detail/:serverId" element={<ProtectedRoute><ServerDetail /></ProtectedRoute>} />
<Route path="/container-monitoring" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
<Route path="/container-monitoring/:serverId" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
<Route path="/service/:id" element={<ProtectedRoute><ServiceDetail /></ProtectedRoute>} />
<Route path="/ssl-domain" element={<ProtectedRoute><SslDomain /></ProtectedRoute>} />
<Route path="/schedule-incident" element={<ProtectedRoute><ScheduleIncident /></ProtectedRoute>} />
<Route path="/operational-page" element={<ProtectedRoute><OperationalPage /></ProtectedRoute>} />
<Route path="/regional-monitoring" element={<ProtectedRoute><RegionalMonitoring /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="*" element={<NotFound />} />
</Routes>
</QueryClientProvider>
</SidebarProvider>
</LanguageProvider>
</ThemeProvider>
</BrowserRouter>
);
}
export default App;
+86
View File
@@ -0,0 +1,86 @@
// API routes handler
import realtime from './realtime';
import settingsApi from './settings';
/**
* Simple API router for client-side application
*/
const api = {
/**
* Handle API requests
*/
async handleRequest(path, method, body) {
// console.log(`API request: ${method} ${path}`, body);
// Route to the appropriate handler
if (path === '/api/realtime') {
// console.log("Routing to realtime handler");
return await realtime(body);
} else if (path === '/api/settings' || path.startsWith('/api/settings/')) {
// console.log("Routing to settings handler");
return await settingsApi(body, path);
}
// Return 404 for unknown routes
// console.error(`Endpoint not found: ${path}`);
return {
status: 404,
json: {
ok: false,
error_code: 404,
description: "Endpoint not found"
}
};
}
};
// Mock fetch override for development
const originalFetch = window.fetch;
window.fetch = async (url, options = {}) => {
// Check if this is an API request to our mock endpoints
if (typeof url === 'string' && url.startsWith('/api/')) {
// console.log('Intercepting API request:', url, options);
try {
let body = {};
// Properly handle different body types
if (options.body) {
if (typeof options.body === 'string') {
body = JSON.parse(options.body);
} else {
// Handle ReadableStream or other BodyInit types
const bodyText = await new Response(options.body).text();
body = bodyText ? JSON.parse(bodyText) : {};
}
}
const result = await api.handleRequest(url, options.method || 'GET', body);
// Create a proper Response object
return new Response(JSON.stringify(result.json), {
status: result.status,
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error('Error in API handler:', error);
return new Response(JSON.stringify({
success: false,
message: 'Internal server error'
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
// For all other requests, use the original fetch
return originalFetch(url, options);
};
export default api;
+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,26 @@
import { getAuthHeaders, getBaseUrl } from '../utils';
import { SettingsApiResponse } from '../types';
export const getSettings = async (): Promise<SettingsApiResponse> => {
try {
const response = await fetch(`${getBaseUrl()}/api/settings`, {
method: 'GET',
headers: getAuthHeaders(),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const settings = await response.json();
return {
status: 200,
json: { success: true, data: settings },
};
} catch (error) {
console.error('Error fetching settings:', error);
return {
status: 500,
json: { success: false, message: 'Failed to fetch settings' },
};
}
};
@@ -0,0 +1,204 @@
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
import { SettingsApiResponse } from '../types';
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
let subject = 'Test Email from CheckCle';
let htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">Test Email</h2>
<p>This is a test email from your monitoring system.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">
Sent from CheckCle Monitoring System<br>
Template: ${template}<br>
${data.collection ? `Collection: ${data.collection}` : ''}
</p>
</div>
</body>
</html>
`;
switch (template) {
case 'verification':
subject = 'Email Verification Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #10b981;">Email Verification Test</h2>
<p>This is a test of the email verification template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Verification Email</p>
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
case 'password-reset':
subject = 'Password Reset Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #f59e0b;">Password Reset Test</h2>
<p>This is a test of the password reset template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Password Reset Email</p>
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
case 'email-change':
subject = 'Email Change Confirmation Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
<p>This is a test of the email change confirmation template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Email Change Confirmation</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
}
return { subject, htmlBody };
};
export const sendTestEmail = async (data: any): Promise<SettingsApiResponse> => {
console.log('sendTestEmail function called with data:', data);
try {
// Validate required fields
if (!data || typeof data !== 'object') {
console.log('Invalid request data - not object');
return {
status: 200,
json: { success: false, message: 'Invalid request data' },
};
}
if (!data.email || typeof data.email !== 'string') {
console.log('Email address missing or invalid type');
return {
status: 200,
json: { success: false, message: 'Email address is required and must be a string' },
};
}
if (!validateEmail(data.email)) {
console.log('Invalid email format:', data.email);
return {
status: 200,
json: { success: false, message: 'Invalid email address format' },
};
}
console.log('Email validation passed for:', data.email);
const headers = getAuthHeaders();
const baseUrl = getBaseUrl();
// Get current SMTP settings first
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
method: 'GET',
headers,
});
if (!settingsResponse.ok) {
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
return {
status: 200,
json: { success: false, message: 'Failed to get SMTP settings' },
};
}
const settingsData = await settingsResponse.json();
console.log('Retrieved settings data:', settingsData);
const smtpSettings = settingsData?.smtp;
if (!smtpSettings || !smtpSettings.enabled) {
console.log('SMTP not enabled or missing');
return {
status: 200,
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
};
}
if (!smtpSettings.host || !smtpSettings.username) {
console.log('SMTP configuration incomplete - missing host or username');
return {
status: 200,
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
};
}
if (!smtpSettings.password) {
console.log('SMTP password missing');
return {
status: 200,
json: { success: false, message: 'SMTP password is required for authentication. Please configure the SMTP password.' },
};
}
// Create test email content based on template
const template = data.template || 'basic';
const { subject, htmlBody } = createEmailTemplate(template, data);
console.log('Test email prepared successfully:', {
to: data.email,
subject: subject,
template: template,
smtpHost: smtpSettings.host,
smtpPort: smtpSettings.port || 587
});
// Simulate processing time
console.log('Simulating email send...');
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Email send simulation completed');
return {
status: 200,
json: {
success: true,
message: `Test email sent successfully to ${data.email}`,
},
};
} catch (error) {
console.error('Error in sendTestEmail function:', error);
return {
status: 200,
json: {
success: false,
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
},
};
}
};
@@ -0,0 +1,238 @@
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
import { SettingsApiResponse } from '../types';
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
let subject = 'Test Email from CheckCle';
let htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">Test Email</h2>
<p>This is a test email from your monitoring system.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">
Sent from CheckCle Monitoring System<br>
Template: ${template}<br>
${data.collection ? `Collection: ${data.collection}` : ''}
</p>
</div>
</body>
</html>
`;
switch (template) {
case 'verification':
subject = 'Email Verification Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #10b981;">Email Verification Test</h2>
<p>This is a test of the email verification template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Verification Email</p>
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
case 'password-reset':
subject = 'Password Reset Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #f59e0b;">Password Reset Test</h2>
<p>This is a test of the password reset template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Password Reset Email</p>
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
case 'email-change':
subject = 'Email Change Confirmation Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
<p>This is a test of the email change confirmation template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Email Change Confirmation</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
}
return { subject, htmlBody };
};
export const testEmail = async (data: any): Promise<SettingsApiResponse> => {
console.log('testEmail function called with data:', data);
try {
// Validate required fields
if (!data || typeof data !== 'object') {
console.log('Invalid request data - not object');
return {
status: 200,
json: { success: false, message: 'Invalid request data' },
};
}
if (!data.email || typeof data.email !== 'string') {
console.log('Email address missing or invalid type');
return {
status: 200,
json: { success: false, message: 'Email address is required and must be a string' },
};
}
if (!validateEmail(data.email)) {
console.log('Invalid email format:', data.email);
return {
status: 200,
json: { success: false, message: 'Invalid email address format' },
};
}
console.log('Email validation passed for:', data.email);
const headers = getAuthHeaders();
const baseUrl = getBaseUrl();
// Get current SMTP settings first
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
method: 'GET',
headers,
});
if (!settingsResponse.ok) {
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
return {
status: 200,
json: { success: false, message: 'Failed to get SMTP settings' },
};
}
const settingsData = await settingsResponse.json();
console.log('Retrieved settings data:', settingsData);
const smtpSettings = settingsData?.smtp;
if (!smtpSettings || !smtpSettings.enabled) {
console.log('SMTP not enabled or missing');
return {
status: 200,
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
};
}
if (!smtpSettings.host || !smtpSettings.username) {
console.log('SMTP configuration incomplete - missing host or username');
return {
status: 200,
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
};
}
// Create test email content based on template
const template = data.template || 'basic';
const { subject, htmlBody } = createEmailTemplate(template, data);
console.log('Test email prepared successfully:', {
to: data.email,
subject: subject,
template: template,
smtpHost: smtpSettings.host,
smtpPort: smtpSettings.port || 587
});
// Send actual email using the correct PocketBase API endpoint
console.log('Sending actual email via PocketBase...');
// Fix the payload structure to match PocketBase API expectations
const emailPayload = {
email: data.email, // Use 'email' instead of 'to'
template: template, // Add the template field
subject: subject,
html: htmlBody,
};
console.log('Email payload:', emailPayload);
const emailResponse = await fetch(`${baseUrl}/api/settings/test/email`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(emailPayload),
});
if (!emailResponse.ok) {
console.error('Failed to send email, status:', emailResponse.status);
const errorText = await emailResponse.text();
console.error('Email send error response:', errorText);
return {
status: 200,
json: { success: false, message: 'Failed to send email. Please check your SMTP configuration.' },
};
}
// Handle 204 No Content response (successful but no body)
if (emailResponse.status === 204) {
console.log('Email sent successfully (204 No Content)');
return {
status: 200,
json: {
success: true,
message: `Test email sent successfully to ${data.email}`,
},
};
}
// For other successful responses, try to parse JSON
const emailResult = await emailResponse.json();
console.log('Email sent successfully:', emailResult);
return {
status: 200,
json: {
success: true,
message: `Test email sent successfully to ${data.email}`,
},
};
} catch (error) {
console.error('Error in testEmail function:', error);
return {
status: 200,
json: {
success: false,
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
},
};
}
};
@@ -0,0 +1,31 @@
import { getAuthHeaders, getBaseUrl } from '../utils';
import { SettingsApiResponse } from '../types';
export const testEmailConnection = async (data: any): Promise<SettingsApiResponse> => {
try {
const response = await fetch(`${getBaseUrl()}/api/settings/test-email`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const result = await response.json();
return {
status: 200,
json: {
success: result.success || false,
message:
result.message || (result.success ? 'Connection successful' : 'Connection failed'),
},
};
} catch (error) {
console.error('Error testing email connection:', error);
return {
status: 500,
json: { success: false, message: 'Failed to test email connection' },
};
}
};
@@ -0,0 +1,38 @@
import { getAuthHeaders, getBaseUrl } from '../utils';
import { SettingsApiResponse } from '../types';
export const updateSettings = async (data: any): Promise<SettingsApiResponse> => {
try {
const headers = getAuthHeaders();
const baseUrl = getBaseUrl();
let response = await fetch(`${baseUrl}/api/settings`, {
method: 'PATCH',
headers,
body: JSON.stringify(data),
});
if (!response.ok && (response.status === 404 || response.status === 405)) {
response = await fetch(`${baseUrl}/api/settings`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
}
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const updatedSettings = await response.json();
return {
status: 200,
json: { success: true, data: updatedSettings },
};
} catch (error) {
console.error('Error updating settings:', error);
return {
status: 500,
json: { success: false, message: 'Failed to update settings' },
};
}
};
+42
View File
@@ -0,0 +1,42 @@
import { getSettings } from './actions/getSettings';
import { updateSettings } from './actions/updateSettings';
import { testEmailConnection } from './actions/testEmailConnection';
import { testEmail } from './actions/testEmail';
/**
* Settings API handler
*/
const settingsApi = async (body: any, path?: string) => {
// console.log('Settings API called with path:', path, 'body:', body);
// Handle test email endpoint specifically
if (path === '/api/settings/test/email') {
console.log('Handling test email request');
return await testEmail(body);
}
// Handle regular settings API with action-based routing
const action = body?.action;
// console.log('Settings API called with action:', action, 'data:', body?.data);
switch (action) {
case 'getSettings':
return await getSettings();
case 'updateSettings':
return await updateSettings(body.data);
case 'testEmailConnection':
return await testEmailConnection(body.data);
default:
console.error('Unknown action:', action);
return {
status: 400,
json: { success: false, message: 'Unknown action' },
};
}
};
export default settingsApi;
+25
View File
@@ -0,0 +1,25 @@
export interface SettingsApiRequest {
action: string;
data?: any;
}
export interface SettingsApiResponse {
status: number;
json: {
success: boolean;
data?: any;
message?: string;
};
}
export interface SmtpSettings {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
authMethod?: string;
tls?: boolean;
localName?: string;
}
+24
View File
@@ -0,0 +1,24 @@
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
export const getAuthHeaders = (): Record<string, string> => {
const authToken = pb.authStore.token;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
};
export const getBaseUrl = (): string => {
return getCurrentEndpoint();
};
export const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
@@ -0,0 +1,49 @@
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center p-6">
<h2 className="text-2xl font-bold text-destructive mb-4">Something went wrong</h2>
<p className="text-muted-foreground mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,259 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from '@/components/ui/use-toast';
import { Mail, ArrowLeft } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
import { getCurrentEndpoint } from '@/lib/pocketbase';
interface ForgotPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) {
const [step, setStep] = useState<'request' | 'confirm'>('request');
const [email, setEmail] = useState('');
const [token, setToken] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [loading, setLoading] = useState(false);
const { t } = useLanguage();
const handleRequestReset = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const apiUrl = getCurrentEndpoint();
const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to send reset email');
}
toast({
title: "Reset Email Sent",
description: "Please check your email for password reset instructions.",
});
setStep('confirm');
} catch (error) {
console.error('Password reset request error:', error);
toast({
variant: "destructive",
title: "Reset Failed",
description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.",
});
} finally {
setLoading(false);
}
};
const handleConfirmReset = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== passwordConfirm) {
toast({
variant: "destructive",
title: "Password Mismatch",
description: "Passwords do not match. Please try again.",
});
return;
}
if (password.length < 6) {
toast({
variant: "destructive",
title: "Password Too Short",
description: "Password must be at least 6 characters long.",
});
return;
}
setLoading(true);
try {
const apiUrl = getCurrentEndpoint();
const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
password,
passwordConfirm
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to reset password');
}
toast({
title: "Password Reset Successful",
description: "Your password has been reset successfully. You can now log in with your new password.",
});
onOpenChange(false);
// Reset form state
setStep('request');
setEmail('');
setToken('');
setPassword('');
setPasswordConfirm('');
} catch (error) {
console.error('Password reset confirmation error:', error);
toast({
variant: "destructive",
title: "Reset Failed",
description: error instanceof Error ? error.message : "Failed to reset password. Please try again.",
});
} finally {
setLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset form state when closing
setStep('request');
setEmail('');
setToken('');
setPassword('');
setPasswordConfirm('');
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
</DialogTitle>
<DialogDescription>
{step === 'request'
? 'Enter your email address and we\'ll send you a reset link.'
: 'Enter the reset token from your email and your new password.'
}
</DialogDescription>
</DialogHeader>
{step === 'request' ? (
<form onSubmit={handleRequestReset} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
Email Address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Mail className="h-4 w-4 text-muted-foreground" />
</div>
<Input
id="reset-email"
placeholder="your.email@provider.com"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10"
/>
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !email}
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
>
{loading ? 'Sending...' : 'Send Reset Email'}
</Button>
</div>
</form>
) : (
<form onSubmit={handleConfirmReset} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
Reset Token
</label>
<Input
id="reset-token"
placeholder="Enter token from email"
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
New Password
</label>
<Input
id="new-password"
placeholder="••••••••••••"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
Confirm Password
</label>
<Input
id="confirm-password"
placeholder="••••••••••••"
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required
minLength={6}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setStep('request')}
className="flex-1"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Button
type="submit"
disabled={loading || !token || !password || !passwordConfirm}
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
>
{loading ? 'Resetting...' : 'Reset Password'}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,16 @@
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { authService } from '@/services/authService';
interface ProtectedRouteProps {
children: ReactNode;
}
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
if (!authService.isAuthenticated()) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
@@ -0,0 +1,75 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { Service } from "@/services/serviceService";
import { StatusCards } from "./StatusCards";
import { ServiceFilters } from "./ServiceFilters";
import { ServicesTable } from "./ServicesTable";
import { AddServiceDialog } from "@/components/services/AddServiceDialog";
import { useLanguage } from "@/contexts/LanguageContext";
interface DashboardContentProps {
services: Service[];
isLoading: boolean;
error: Error | null;
}
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
const { t } = useLanguage();
const [filter, setFilter] = useState<string>("all");
const [searchTerm, setSearchTerm] = useState<string>("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
// Filter services based on search term and type filter
const filteredServices = services.filter(service => {
const matchesSearch = service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(service.url && service.url.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesFilter = filter === 'all' || service.type.toLowerCase() === filter.toLowerCase();
return matchesSearch && matchesFilter;
});
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
<p>Error loading service data.</p>
<Button onClick={() => window.location.reload()}>{t('retry')}</Button>
</div>
);
}
return (
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
<div className="flex flex-col flex-1">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-foreground">{t('overview')}</h2>
<Button
className="text-primary-foreground"
onClick={() => setIsAddDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-2" /> {t('newService')}
</Button>
</div>
<StatusCards services={services} />
<ServiceFilters
filter={filter}
setFilter={setFilter}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
servicesCount={filteredServices.length}
/>
<div className="flex-1 flex flex-col pb-6">
<ServicesTable services={filteredServices} />
</div>
</div>
<AddServiceDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
/>
</main>
);
};
@@ -0,0 +1,207 @@
import { Button } from "@/components/ui/button";
import { AuthUser } from "@/services/authService";
import { useTheme } from "@/contexts/ThemeContext";
import {
Moon, PanelLeft, PanelLeftClose, Sun, Globe, FileText,
Github, Twitter, MessageSquare, Bell, User, Settings,
LogOut, Menu, X
} from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuSeparator
} from "@/components/ui/dropdown-menu";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useNavigate } from "react-router-dom";
import { useSystemSettings } from "@/hooks/useSystemSettings";
import { useSidebar } from "@/contexts/SidebarContext";
import { cn } from "@/lib/utils";
interface HeaderProps {
currentUser: AuthUser | null;
onLogout: () => void;
}
export const Header = ({
currentUser,
onLogout,
}: HeaderProps) => {
const { theme, toggleTheme } = useTheme();
const { language, setLanguage, t } = useLanguage();
const { sidebarCollapsed, toggleSidebar, isMobileOpen, toggleMobileMenu } = useSidebar();
const [greeting, setGreeting] = useState<string>("");
const { systemName } = useSystemSettings();
const navigate = useNavigate();
useEffect(() => {
const updateGreeting = () => {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) setGreeting(t("goodMorning"));
else if (hour >= 12 && hour < 18) setGreeting(t("goodAfternoon"));
else setGreeting(t("goodEvening"));
};
updateGreeting();
}, [language, t]);
const avatarUrl = currentUser?.avatar || '';
const openExternalLink = (url: string) => {
window.open(url, '_blank');
};
return (
<header className="relative bg-background border-b border-border px-4 lg:px-6 flex justify-between items-center h-16 shrink-0 z-30 overflow-hidden">
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 pointer-events-none opacity-20">
<div
className="w-full h-full"
style={{
backgroundImage: `linear-gradient(${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px),
linear-gradient(90deg, ${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
>
<div className="w-full h-full backdrop-blur-[1px]"></div>
</div>
</div>
<div className="flex items-center gap-2 lg:gap-4 z-10">
{/* Mobile Toggle */}
<Button variant="ghost" size="icon" onClick={toggleMobileMenu} className="lg:hidden h-9 w-9">
{isMobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
{/* Desktop Toggle */}
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="hidden lg:flex h-9 w-9">
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</Button>
<div className="flex items-center">
<h1 className="text-sm lg:text-lg font-medium truncate max-w-[150px] lg:max-w-none">
{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋
</h1>
</div>
</div>
<div className="flex items-center space-x-1 lg:space-x-3 z-10">
{/* External Links - Hidden on small screens */}
<div className="hidden md:flex items-center space-x-1 mr-2">
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://docs.checkcle.io')}
title={t("documentation")}
>
<FileText className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://github.com/operacle/checkcle')}
title="GitHub"
>
<Github className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://x.com/checkcle_oss')}
title="X (Twitter)"
>
<Twitter className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://discord.gg/xs9gbubGwX')}
title="Discord"
>
<MessageSquare className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
title={t("notifications")}
>
<Bell className="w-4 h-4" />
</Button>
</div>
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border hidden sm:flex" onClick={toggleTheme}>
<span className="sr-only">Toggle theme</span>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">{t("language")}</span>
<Globe className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
{t("english")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
{t("khmer")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
{t("german")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("ko")} className={language === "ko" ? "bg-accent" : ""}>
{t("korean")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}>
{t("japanese")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("zhcn")} className={language === "zhcn" ? "bg-accent" : ""}>
{t("simplifiedChinese")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="h-8 w-px bg-border mx-1 hidden sm:block" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="h-8 w-8 cursor-pointer border hover:ring-2 hover:ring-primary/20 transition-all">
{avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-3 p-2">
<Avatar className="h-10 w-10">
{avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
</Avatar>
<div className="flex flex-col space-y-0.5">
<span className="text-sm font-medium truncate">{currentUser?.name || 'User'}</span>
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/profile")}>
<User className="mr-2 h-4 w-4" />
<span>{t("profile")}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/settings")}>
<Settings className="mr-2 h-4 w-4" />
<span>{t("settings")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout} className="text-red-500 focus:text-red-500">
<LogOut className="mr-2 h-4 w-4" />
<span>{t("logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
};
@@ -0,0 +1,56 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Plus } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServiceFiltersProps {
filter: string;
setFilter: (value: string) => void;
searchTerm: string;
setSearchTerm: (value: string) => void;
servicesCount: number;
}
export const ServiceFilters = ({
filter,
setFilter,
searchTerm,
setSearchTerm,
servicesCount
}: ServiceFiltersProps) => {
const { t } = useLanguage();
return (
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center">
<h3 className="text-xl font-semibold mr-2 text-foreground">{t('currentlyMonitoring')}</h3>
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
{servicesCount}
</span>
</div>
<div className="flex space-x-4">
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-40 bg-card border-border">
<SelectValue placeholder={t('allTypes')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('allTypes')}</SelectItem>
<SelectItem value="HTTP">HTTP</SelectItem>
<SelectItem value="PING">PING</SelectItem>
<SelectItem value="TCP">TCP</SelectItem>
<SelectItem value="DNS">DNS</SelectItem>
</SelectContent>
</Select>
<div className="relative">
<Input
className="w-72 bg-card border-border"
placeholder={t('search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</div>
);
};
@@ -0,0 +1,15 @@
import { Service } from "@/types/service.types";
import { ServicesTableContainer } from "@/components/services/ServicesTableContainer";
interface ServicesTableProps {
services: Service[];
}
export const ServicesTable = ({ services }: ServicesTableProps) => {
return (
<div className="flex-1 flex flex-col h-full">
<ServicesTableContainer services={services} />
</div>
);
};
@@ -0,0 +1,44 @@
import React from "react";
import { useTheme } from "@/contexts/ThemeContext";
import { SidebarHeader } from "./sidebar/SidebarHeader";
import { MainNavigation } from "./sidebar/MainNavigation";
import { SettingsPanel } from "./sidebar/SettingsPanel";
import { useSidebar } from "@/contexts/SidebarContext";
import { cn } from "@/lib/utils";
export const Sidebar = () => {
const { theme } = useTheme();
const { sidebarCollapsed, isMobileOpen, toggleMobileMenu } = useSidebar();
return (
<>
{/* Mobile Overlay */}
{isMobileOpen && (
<div
className="fixed inset-0 bg-black/60 z-40 lg:hidden backdrop-blur-sm transition-opacity animate-in fade-in"
onClick={toggleMobileMenu}
/>
)}
{/* Sidebar Container */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 lg:static lg:block transition-all duration-300 ease-in-out border-r flex flex-col h-full",
theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border',
// Mobile state
isMobileOpen ? "translate-x-0 w-64" : "-translate-x-full lg:translate-x-0",
// Desktop state (collapsed/expanded)
!isMobileOpen && sidebarCollapsed ? "lg:w-16" : "lg:w-64"
)}
>
<SidebarHeader collapsed={!isMobileOpen && sidebarCollapsed} />
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar">
<MainNavigation collapsed={!isMobileOpen && sidebarCollapsed} />
</div>
<SettingsPanel collapsed={!isMobileOpen && sidebarCollapsed} />
</aside>
</>
);
};
@@ -0,0 +1,155 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react";
import { Service } from "@/services/serviceService";
import { useTheme } from "@/contexts/ThemeContext";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface StatusCardsProps {
services: Service[];
}
export const StatusCards = ({ services }: StatusCardsProps) => {
const { t } = useLanguage();
// Count services by status
const upServices = services.filter(s => s.status === "up").length;
const downServices = services.filter(s => s.status === "down").length;
const pausedServices = services.filter(s => s.status === "paused").length;
const warningServices = services.filter(s => s.responseTime > 1000).length;
// Get current theme to adjust card styles
const { theme } = useTheme();
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 w-full">
{/* Up Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #66bb6a 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">{t("upServices")}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{upServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<ArrowUp className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
{/* Down Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ef5350 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">{t("downServices")}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{downServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<ArrowDown className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
{/* Paused Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #42a5f5 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">{t("pausedServices")}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{pausedServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<Pause className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
{/* Warning Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ffb74d 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">{t("warningServices")}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{warningServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<AlertTriangle className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,27 @@
import React from "react";
import { MenuItem } from "./MenuItem";
import { mainMenuItems } from "./navigationData";
interface MainNavigationProps {
collapsed: boolean;
}
export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) => {
return (
<nav className="my-2 mx-1 py-1 px-1">
{mainMenuItems.map((item) => (
<MenuItem
key={item.id}
id={item.id}
path={item.path}
icon={item.icon}
translationKey={item.translationKey}
color={item.color}
hasNavigation={item.hasNavigation}
collapsed={collapsed}
/>
))}
</nav>
);
};
@@ -0,0 +1,66 @@
import React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface MenuItemProps {
id: string;
path: string | null;
icon: LucideIcon;
translationKey: string;
color: string;
hasNavigation: boolean;
collapsed: boolean;
}
export const MenuItem: React.FC<MenuItemProps> = ({
path,
icon: Icon,
translationKey,
color,
hasNavigation,
collapsed
}) => {
const { theme } = useTheme();
const { t } = useLanguage();
const location = useLocation();
const navigate = useNavigate();
const isActive = path && location.pathname === path;
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
if (hasNavigation && path) {
navigate(path);
}
};
return (
<div
className={cn(
"group relative flex items-center mb-1 rounded-lg cursor-pointer transition-all duration-200",
collapsed ? "p-3 justify-center" : "p-2.5 px-4",
isActive
? (theme === 'dark' ? 'bg-zinc-800 text-white' : 'bg-primary/10 text-primary')
: (theme === 'dark' ? 'text-zinc-400 hover:bg-zinc-800/50 hover:text-white' : 'text-zinc-600 hover:bg-zinc-100')
)}
onClick={handleClick}
title={collapsed ? t(translationKey) : ""}
>
<Icon className={cn("h-5 w-5 shrink-0 transition-colors", color, isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100")} />
{!collapsed && (
<span className="ml-3 font-medium tracking-wide text-sm truncate">
{t(translationKey)}
</span>
)}
{/* Small Indicator for active state */}
{isActive && !collapsed && (
<div className="absolute left-0 w-1 h-6 bg-primary rounded-r-full" />
)}
</div>
);
};
@@ -0,0 +1,100 @@
import React, { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { Settings, ChevronDown } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { settingsMenuItems } from "./navigationData";
interface SettingsPanelProps {
collapsed: boolean;
}
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
const { theme } = useTheme();
const { t } = useLanguage();
const location = useLocation();
const navigate = useNavigate();
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
// Update active settings item based on URL
useEffect(() => {
if (location.pathname === '/settings') {
const params = new URLSearchParams(location.search);
const panel = params.get('panel');
if (panel) {
setActiveSettingsItem(panel);
}
}
}, [location]);
const handleSettingsItemClick = (item: string) => {
setActiveSettingsItem(item);
};
const handleMenuItemClick = (path: string, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
// Use navigate instead of window.location to prevent full page reload
navigate(path, { replace: false });
};
const getMenuItemClasses = (isActive: boolean) => {
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
};
if (collapsed) {
const mainIconSize = "h-6 w-6";
return (
<div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
<div
onClick={(e) => handleMenuItemClick('/settings', e)}
className="cursor-pointer"
>
<Settings className={`${mainIconSize} text-purple-400`} />
</div>
</div>
);
}
return (
<div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
<div className="flex items-center">
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
</div>
<div className="flex items-center">
<Settings className="h-4 w-4 mr-1" />
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
<ScrollArea className="h-full">
<div className="space-y-2 pr-4">
{settingsMenuItems.map((item) => (
<div
key={item.id}
className={getMenuItemClasses(activeSettingsItem === item.id)}
onClick={(e) => {
handleMenuItemClick(`/settings?panel=${item.id}`, e);
handleSettingsItemClick(item.id);
}}
>
<item.icon className="h-4 w-4 mr-2" />
<span className="text-sm">{t(item.translationKey)}</span>
</div>
))}
</div>
</ScrollArea>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
@@ -0,0 +1,24 @@
import React from "react";
import { useTheme } from "@/contexts/ThemeContext";
interface SidebarHeaderProps {
collapsed: boolean;
}
export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
const { theme } = useTheme();
return (
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
<div className="h-8 w-8 bg-gray-600 rounded flex items-center justify-center mr-2">
<img
src="/favicon_sidebar.ico"
alt="CheckCle"
className="h-6 w-6"
/>
</div>
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
</div>
);
};
@@ -0,0 +1,6 @@
export { SidebarHeader } from './SidebarHeader';
export { MainNavigation } from './MainNavigation';
export { MenuItem } from './MenuItem';
export { SettingsPanel } from './SettingsPanel';
export { mainMenuItems, settingsMenuItems } from './navigationData';
@@ -0,0 +1,94 @@
import { Globe, Boxes, Layers, Calendar, BarChart2, LineChart, MapPin, Settings, User, Bell, Database, Info, BookOpen } from "lucide-react";
export const mainMenuItems = [
{
id: 'uptime-monitoring',
path: '/dashboard',
icon: Globe,
translationKey: 'uptimeMonitoring',
color: 'text-purple-400',
hasNavigation: true
},
{
id: 'instance-monitoring',
path: '/instance-monitoring',
icon: Boxes,
translationKey: 'instanceMonitoring',
color: 'text-blue-400',
hasNavigation: true
},
{
id: 'ssl-domain',
path: '/ssl-domain',
icon: Layers,
translationKey: 'sslDomain',
color: 'text-cyan-400',
hasNavigation: true
},
{
id: 'schedule-incident',
path: '/schedule-incident',
icon: Calendar,
translationKey: 'scheduleIncident',
color: 'text-emerald-400',
hasNavigation: true
},
{
id: 'operational-page',
path: '/operational-page',
icon: BarChart2,
translationKey: 'operationalPage',
color: 'text-amber-400',
hasNavigation: true
},
{
id: 'regional-monitoring',
path: '/regional-monitoring',
icon: MapPin,
translationKey: 'regionalMonitoring',
color: 'text-indigo-400',
hasNavigation: true
},
{
id: 'reports',
path: null,
icon: LineChart,
translationKey: 'reports',
color: 'text-rose-400',
hasNavigation: false
}
];
export const settingsMenuItems = [
{
id: 'general',
icon: Settings,
translationKey: 'generalSettings'
},
{
id: 'users',
icon: User,
translationKey: 'userManagement'
},
{
id: 'notifications',
icon: Bell,
translationKey: 'notificationSettings'
},
{
id: 'templates',
icon: BookOpen,
translationKey: 'alertsTemplates'
},
{
id: 'data-retention',
icon: Database,
translationKey: 'dataRetention'
},
{
id: 'about',
icon: Info,
translationKey: 'aboutSystem'
}
];
@@ -0,0 +1,99 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody } from "@/components/ui/table";
import { DockerContainer } from "@/types/docker.types";
import { DockerMetricsDialog } from "./DockerMetricsDialog";
import { useLanguage } from "@/contexts/LanguageContext";
import {
DockerTableSearch,
DockerTableHeader,
DockerTableRow,
DockerEmptyState
} from "./table";
interface DockerContainersTableProps {
containers: DockerContainer[];
isLoading: boolean;
onRefresh: () => void;
}
export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => {
const { t } = useLanguage();
const [searchTerm, setSearchTerm] = useState("");
const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null);
const [metricsDialogOpen, setMetricsDialogOpen] = useState(false);
const filteredContainers = containers.filter(container =>
container.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
container.docker_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
container.hostname.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleContainerAction = (action: string, containerId: string, containerName: string) => {
console.log(`${action} action for container ${containerName} (${containerId})`);
// TODO: Implement container actions
};
const handleRowClick = (container: DockerContainer) => {
setSelectedContainer(container);
setMetricsDialogOpen(true);
};
const handleViewMetrics = (container: DockerContainer) => {
setSelectedContainer(container);
setMetricsDialogOpen(true);
};
return (
<>
<Card className="w-full bg-transparent border-0 shadow-none">
<CardHeader className="pb-4 px-0">
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle className="text-lg sm:text-xl font-semibold">{t('dockerContainers', 'docker')}</CardTitle>
<DockerTableSearch
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onRefresh={onRefresh}
isLoading={isLoading}
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<div className="min-w-full inline-block align-middle">
<div className="overflow-hidden border border-border rounded-lg shadow-sm">
<Table>
<DockerTableHeader />
<TableBody>
{filteredContainers.length === 0 ? (
<DockerEmptyState searchTerm={searchTerm} />
) : (
filteredContainers.map((container) => (
<DockerTableRow
key={container.id}
container={container}
onRowClick={handleRowClick}
onContainerAction={handleContainerAction}
onViewMetrics={handleViewMetrics}
/>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
</CardContent>
</Card>
<DockerMetricsDialog
container={selectedContainer}
open={metricsDialogOpen}
onOpenChange={setMetricsDialogOpen}
/>
</>
);
};
@@ -0,0 +1,697 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, AreaChart, Area } from "recharts";
import { DockerContainer, DockerMetrics } from "@/types/docker.types";
import { dockerService } from "@/services/dockerService";
import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerMetricsDialogProps {
container: DockerContainer | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
const timeRangeOptions = [
{ value: '60m' as TimeRange, label: 'minutes60', hours: 1 },
{ value: '1d' as TimeRange, label: 'day1', hours: 24 },
{ value: '7d' as TimeRange, label: 'days7', hours: 24 * 7 },
{ value: '1m' as TimeRange, label: 'month1', hours: 24 * 30 },
{ value: '3m' as TimeRange, label: 'months3', hours: 24 * 90 },
];
export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => {
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const { theme } = useTheme();
const { t } = useLanguage();
const {
data: metrics = [],
isLoading,
error
} = useQuery({
queryKey: ['docker-metrics', container?.docker_id, timeRange],
queryFn: () => container ? dockerService.getContainerMetrics(container.docker_id) : Promise.resolve([]),
enabled: !!container && open,
refetchInterval: 30000
});
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const parseValueWithUnit = (value: string | number): { numeric: number; unit: string; original: string } => {
if (typeof value === 'number') {
return { numeric: value, unit: 'B', original: value.toString() };
}
const str = value.toString();
const match = str.match(/^([\d.]+)\s*([A-Za-z%]*)/);
if (match) {
const numeric = parseFloat(match[1]);
const unit = match[2] || '';
return { numeric, unit, original: str };
}
return { numeric: 0, unit: '', original: str };
};
const convertToBytes = (value: string | number): number => {
if (typeof value === 'number') return value;
const parsed = parseValueWithUnit(value);
const multipliers: { [key: string]: number } = {
'B': 1,
'KB': 1024,
'MB': 1024 * 1024,
'GB': 1024 * 1024 * 1024,
'TB': 1024 * 1024 * 1024 * 1024
};
const multiplier = multipliers[parsed.unit.toUpperCase()] || 1;
return parsed.numeric * multiplier;
};
const filterMetricsByTimeRange = (metrics: DockerMetrics[], timeRange: TimeRange): DockerMetrics[] => {
const now = new Date();
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
if (!selectedRange) return metrics;
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
return metrics.filter(metric => {
const metricTime = new Date(metric.timestamp);
return metricTime >= cutoffTime;
});
};
const formatChartData = (metrics: DockerMetrics[]) => {
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
return filteredMetrics.slice(0, 100).reverse().map((metric, index) => {
// Parse CPU usage
const cpuUsage = typeof metric.cpu_usage === 'string' ?
parseFloat(metric.cpu_usage.replace('%', '')) :
parseFloat(metric.cpu_usage) || 0;
// Parse memory values
const ramUsedBytes = convertToBytes(metric.ram_used);
const ramTotalBytes = convertToBytes(metric.ram_total);
const ramFreeBytes = convertToBytes(metric.ram_free);
const ramUsagePercent = ramTotalBytes > 0 ? (ramUsedBytes / ramTotalBytes) * 100 : 0;
// Parse disk values
const diskUsedBytes = convertToBytes(metric.disk_used);
const diskTotalBytes = convertToBytes(metric.disk_total);
const diskFreeBytes = convertToBytes(metric.disk_free);
const diskUsagePercent = diskTotalBytes > 0 ? (diskUsedBytes / diskTotalBytes) * 100 : 0;
// Network values
const networkRxBytes = metric.network_rx_bytes || 0;
const networkTxBytes = metric.network_tx_bytes || 0;
const networkRxSpeed = metric.network_rx_speed || 0;
const networkTxSpeed = metric.network_tx_speed || 0;
return {
timestamp: new Date(metric.timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
}),
// CPU
cpuUsage: Math.round(cpuUsage * 100) / 100,
cpuCores: parseInt(metric.cpu_cores) || 0,
cpuFree: 100 - cpuUsage,
// Memory
ramUsedBytes,
ramTotalBytes,
ramFreeBytes,
ramUsed: formatBytes(ramUsedBytes),
ramTotal: formatBytes(ramTotalBytes),
ramFree: formatBytes(ramFreeBytes),
ramUsagePercent: Math.round(ramUsagePercent * 100) / 100,
// Disk
diskUsedBytes,
diskTotalBytes,
diskFreeBytes,
diskUsed: formatBytes(diskUsedBytes),
diskTotal: formatBytes(diskTotalBytes),
diskFree: formatBytes(diskFreeBytes),
diskUsagePercent: Math.round(diskUsagePercent * 100) / 100,
// Network
networkRxBytes,
networkTxBytes,
networkRx: formatBytes(networkRxBytes),
networkTx: formatBytes(networkTxBytes),
networkRxSpeed: Math.round(networkRxSpeed * 100) / 100,
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
};
});
};
const chartData = formatChartData(metrics);
const latestMetric = chartData[chartData.length - 1];
const chartConfig = {
cpuUsage: {
label: "CPU Usage (%)",
color: theme === 'dark' ? "#3b82f6" : "#2563eb",
},
ramUsagePercent: {
label: "RAM Usage (%)",
color: theme === 'dark' ? "#10b981" : "#059669",
},
diskUsagePercent: {
label: "Disk Usage (%)",
color: theme === 'dark' ? "#f59e0b" : "#d97706",
},
networkRx: {
label: "Network RX",
color: theme === 'dark' ? "#8b5cf6" : "#7c3aed",
},
networkTx: {
label: "Network TX",
color: theme === 'dark' ? "#ef4444" : "#dc2626",
},
};
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
const MetricCard = ({ title, used, total, free, percentage, icon: Icon, color }: {
title: string;
used: string;
total: string;
free: string;
percentage: number;
icon: any;
color: string;
}) => (
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4" style={{ color }} />
<span className="text-sm font-medium">{title}</span>
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Used:</span>
<span className="font-mono">{used}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Free:</span>
<span className="font-mono">{free}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total:</span>
<span className="font-mono">{total}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Usage:</span>
<span className="font-mono">{percentage.toFixed(1)}%</span>
</div>
</div>
</div>
);
if (!container) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl max-h-[95vh] overflow-y-auto bg-background text-foreground">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
<Cpu className="h-4 w-4 text-primary" />
</div>
{t('containerMetricsTitle', 'docker', { name: container.name })}
</div>
<div className="flex items-center gap-2">
<Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}>
<SelectTrigger className="w-[140px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeRangeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label, 'docker')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogTitle>
<p className="text-sm text-muted-foreground">
{t('dockerId', 'docker')}: {container.docker_id} {container.hostname}
</p>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('loadingMetrics', 'docker')}</span>
</div>
) : error ? (
<div className="flex items-center justify-center h-96 text-muted-foreground">
<p>{t('errorLoadingMetrics', 'docker')}: {String((error as any)?.message ?? '')}</p>
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-96 text-muted-foreground">
<p>{t('noMetricsAvailable', 'docker')}</p>
</div>
) : (
<>
{/* Current Metrics Summary */}
{latestMetric && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard
title={t('cpu', 'docker')}
used={`${latestMetric.cpuUsage}%`}
total={`${latestMetric.cpuCores} cores`}
free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`}
percentage={latestMetric.cpuUsage}
icon={Cpu}
color={chartConfig.cpuUsage.color}
/>
<MetricCard
title={t('memoryTitle', 'docker')}
used={latestMetric.ramUsed}
total={latestMetric.ramTotal}
free={latestMetric.ramFree}
percentage={latestMetric.ramUsagePercent}
icon={MemoryStick}
color={chartConfig.ramUsagePercent.color}
/>
<MetricCard
title={t('diskTitle', 'docker')}
used={latestMetric.diskUsed}
total={latestMetric.diskTotal}
free={latestMetric.diskFree}
percentage={latestMetric.diskUsagePercent}
icon={HardDrive}
color={chartConfig.diskUsagePercent.color}
/>
<MetricCard
title={t('network', 'docker')}
used={`RX: ${latestMetric.networkRx}`}
total={`TX: ${latestMetric.networkTx}`}
free={`${t('networkSpeedKbs', 'docker').replace('(KB/s)', '')}: ${latestMetric.networkRxSpeed} KB/s`}
percentage={0}
icon={Network}
color={chartConfig.networkRx.color}
/>
</div>
)}
<Tabs defaultValue="cpu" className="w-full">
<TabsList className="grid w-full grid-cols-4 bg-muted">
<TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background">
<Cpu className="h-4 w-4" />
{t('cpu', 'docker')}
</TabsTrigger>
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background">
<MemoryStick className="h-4 w-4" />
{t('memoryTitle', 'docker')}
</TabsTrigger>
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background">
<HardDrive className="h-4 w-4" />
{t('diskTitle', 'docker')}
</TabsTrigger>
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background">
<Network className="h-4 w-4" />
{t('network', 'docker')}
</TabsTrigger>
</TabsList>
<TabsContent value="cpu" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">{t('cpuUsagePct', 'docker')}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Line
type="monotone"
dataKey="cpuUsage"
stroke={chartConfig.cpuUsage.color}
strokeWidth={2}
dot={{ r: 3, fill: chartConfig.cpuUsage.color }}
name={t('cpuUsagePct', 'docker')}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">{t('cpuUsageVsAvailable', 'docker')}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Area
type="monotone"
dataKey="cpuFree"
stackId="1"
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3}
name={t('cpuAvailablePct', 'docker')}
/>
<Area
type="monotone"
dataKey="cpuUsage"
stackId="1"
stroke={chartConfig.cpuUsage.color}
fill={chartConfig.cpuUsage.color}
fillOpacity={0.6}
name={t('cpuUsagePct', 'docker')}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="memory" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">{t('ramUsagePct', 'docker')}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Area
type="monotone"
dataKey="ramUsagePercent"
stackId="1"
stroke={chartConfig.ramUsagePercent.color}
fill={chartConfig.ramUsagePercent.color}
fillOpacity={0.6}
name="RAM Usage (%)"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">{t('memoryUsageBytes', 'docker')}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
tickFormatter={(value) => formatBytes(value)}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
formatter={(value, name) => [
name === t('usedMemory', 'docker') ? formatBytes(Number(value)) :
name === t('totalMemory', 'docker') ? formatBytes(Number(value)) : value,
name
]}
/>
<Area
type="monotone"
dataKey="ramTotalBytes"
stackId="1"
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3}
name={t('totalMemory', 'docker')}
/>
<Area
type="monotone"
dataKey="ramUsedBytes"
stackId="1"
stroke={chartConfig.ramUsagePercent.color}
fill={chartConfig.ramUsagePercent.color}
fillOpacity={0.6}
name={t('usedMemory', 'docker')}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="disk" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">{t('diskUsagePct', 'docker')}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Area
type="monotone"
dataKey="diskUsagePercent"
stackId="1"
stroke={chartConfig.diskUsagePercent.color}
fill={chartConfig.diskUsagePercent.color}
fillOpacity={0.6}
name={t('diskUsagePct', 'docker')}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">{t('diskUsageBytes', 'docker')}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
tickFormatter={(value) => formatBytes(value)}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
formatter={(value, name) => [
name === t('usedDisk', 'docker') ? formatBytes(Number(value)) :
name === t('totalDisk', 'docker') ? formatBytes(Number(value)) : value,
name
]}
/>
<Area
type="monotone"
dataKey="diskTotalBytes"
stackId="1"
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3}
name={t('totalDisk', 'docker')}
/>
<Area
type="monotone"
dataKey="diskUsedBytes"
stackId="1"
stroke={chartConfig.diskUsagePercent.color}
fill={chartConfig.diskUsagePercent.color}
fillOpacity={0.6}
name={t('usedDisk', 'docker')}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="network" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">{t('networkTraffic', 'docker')}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-64">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Line
type="monotone"
dataKey="networkRxBytes"
stroke={chartConfig.networkRx.color}
strokeWidth={2}
name={t('rxBytes', 'docker')}
dot={{ r: 2 }}
/>
<Line
type="monotone"
dataKey="networkTxBytes"
stroke={chartConfig.networkTx.color}
strokeWidth={2}
name={t('txBytes', 'docker')}
dot={{ r: 2 }}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">{t('networkSpeedKbs', 'docker')}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-64">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Line
type="monotone"
dataKey="networkRxSpeed"
stroke={chartConfig.networkRx.color}
strokeWidth={2}
name={t('rxSpeedKbs', 'docker')}
dot={{ r: 2 }}
/>
<Line
type="monotone"
dataKey="networkTxSpeed"
stroke={chartConfig.networkTx.color}
strokeWidth={2}
name={t('txSpeedKbs', 'docker')}
dot={{ r: 2 }}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,104 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Container, Play, Square, AlertTriangle } from "lucide-react";
import { DockerStats } from "@/types/docker.types";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerStatsCardsProps {
stats: DockerStats;
}
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
const { theme } = useTheme();
const { t } = useLanguage();
const cards = [
{
title: t('totalContainers', 'docker'),
value: stats.total,
icon: Container,
color: "text-blue-600",
gradient: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(59, 130, 246, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
},
{
title: t('running', 'docker'),
value: stats.running,
icon: Play,
color: "text-green-600",
gradient: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(16, 185, 129, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
},
{
title: t('stopped', 'docker'),
value: stats.stopped,
icon: Square,
color: "text-gray-600",
gradient: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(107, 114, 128, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
},
{
title: t('warning', 'docker'),
value: stats.warning,
icon: AlertTriangle,
color: "text-amber-600",
gradient: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(245, 158, 11, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #f59e0b 100%)"
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
{cards.map((card) => {
const IconComponent = card.icon;
return (
<Card
key={card.title}
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
style={{ background: card.gradient }}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div
className="w-full h-full"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
/>
</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white/70">
{card.title}
</CardTitle>
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
<IconComponent className="h-4 w-4 text-white" />
</div>
</CardHeader>
<CardContent className="relative z-10">
<div className="flex items-center justify-between">
<div className="text-2xl font-bold text-white">
{card.value}
</div>
<Badge
variant="outline"
className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30"
>
{t('containersLabel', 'docker')}
</Badge>
</div>
</CardContent>
</Card>
);
})}
</div>
);
};
@@ -0,0 +1,50 @@
import { Badge } from "@/components/ui/badge";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerStatusBadgeProps {
status: 'running' | 'stopped' | 'warning';
}
export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => {
const { t } = useLanguage();
const getStatusConfig = (status: string) => {
switch (status) {
case 'running':
return {
variant: 'default' as const,
className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200',
label: t('running', 'docker')
};
case 'stopped':
return {
variant: 'secondary' as const,
className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
label: t('stopped', 'docker')
};
case 'warning':
return {
variant: 'destructive' as const,
className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200',
label: t('warning', 'docker')
};
default:
return {
variant: 'outline' as const,
className: 'bg-gray-100 text-gray-600 border-gray-200',
label: t('unknown', 'docker')
};
}
};
const config = getStatusConfig(status);
return (
<Badge
variant={config.variant}
className={`${config.className} font-medium text-xs px-2 py-1`}
>
{config.label}
</Badge>
);
};
@@ -0,0 +1,25 @@
import { TableCell, TableRow } from "@/components/ui/table";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerEmptyStateProps {
searchTerm: string;
}
export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => {
const { t } = useLanguage();
return (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<div className="text-lg font-medium">
{searchTerm ? t('noContainersFound', 'docker') : t('noContainersRunning', 'docker')}
</div>
<div className="text-sm">
{searchTerm ? t('tryAdjustSearch', 'docker') : t('startSomeContainers', 'docker')}
</div>
</div>
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,43 @@
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react";
import { DockerContainer } from "@/types/docker.types";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerRowActionsProps {
container: DockerContainer;
containerStatus: 'running' | 'stopped' | 'warning';
onContainerAction: (action: string, containerId: string, containerName: string) => void;
onViewMetrics: (container: DockerContainer) => void;
}
export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => {
const { t } = useLanguage();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted">
<span className="sr-only">{t('openMenu', 'docker')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-popover border-border shadow-md">
<DropdownMenuItem
onClick={() => onViewMetrics(container)}
className="cursor-pointer hover:bg-muted"
>
<BarChart3 className="mr-2 h-4 w-4" />
{t('viewMetrics', 'docker')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onContainerAction('view-detail', container.id, container.name)}
className="cursor-pointer hover:bg-muted"
>
<Eye className="mr-2 h-4 w-4" />
{t('viewDetails', 'docker')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,21 @@
import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useLanguage } from "@/contexts/LanguageContext";
export const DockerTableHeader = () => {
const { t } = useLanguage();
return (
<TableHeader>
<TableRow className="border-border bg-muted/30">
<TableHead className="min-w-[200px] font-semibold">{t('container', 'docker')}</TableHead>
<TableHead className="min-w-[100px] font-semibold">{t('status', 'docker')}</TableHead>
<TableHead className="min-w-[140px] font-semibold">{t('cpuUsage', 'docker')}</TableHead>
<TableHead className="min-w-[160px] font-semibold">{t('memory', 'docker')}</TableHead>
<TableHead className="min-w-[160px] font-semibold">{t('disk', 'docker')}</TableHead>
<TableHead className="min-w-[100px] font-semibold">{t('uptime', 'docker')}</TableHead>
<TableHead className="min-w-[160px] font-semibold">{t('lastChecked', 'docker')}</TableHead>
<TableHead className="min-w-[80px] text-center font-semibold">{t('actions', 'docker')}</TableHead>
</TableRow>
</TableHeader>
);
};
@@ -0,0 +1,123 @@
import { TableCell, TableRow } from "@/components/ui/table";
import { Progress } from "@/components/ui/progress";
import { DockerContainer } from "@/types/docker.types";
import { DockerStatusBadge } from "../DockerStatusBadge";
import { DockerRowActions } from "./DockerRowActions";
import { dockerService } from "@/services/dockerService";
interface DockerTableRowProps {
container: DockerContainer;
onRowClick: (container: DockerContainer) => void;
onContainerAction: (action: string, containerId: string, containerName: string) => void;
onViewMetrics: (container: DockerContainer) => void;
}
export const DockerTableRow = ({ container, onRowClick, onContainerAction, onViewMetrics }: DockerTableRowProps) => {
const cpuPercentage = container.cpu_usage;
const memoryPercentage = Math.round((container.ram_used / container.ram_total) * 100);
const diskPercentage = Math.round((container.disk_used / container.disk_total) * 100);
const containerStatus = dockerService.getStatusFromDockerStatus(container.status);
const formatPercentage = (used: number, total: number) => {
if (total === 0) return "0%";
return `${Math.round((used / total) * 100)}%`;
};
const getUsageColor = (percentage: number) => {
if (percentage >= 90) return "text-red-500";
if (percentage >= 70) return "text-amber-500";
return "text-emerald-500";
};
const getProgressColor = (percentage: number) => {
if (percentage >= 90) return "bg-red-500";
if (percentage >= 70) return "bg-amber-500";
return "bg-emerald-500";
};
return (
<TableRow
className="hover:bg-muted/50 transition-colors border-border cursor-pointer"
onClick={() => onRowClick(container)}
>
<TableCell className="font-medium">
<div className="space-y-1">
<div className="font-semibold text-sm sm:text-base text-foreground">{container.name}</div>
<div className="text-xs sm:text-sm text-muted-foreground">
<div className="font-mono">{container.docker_id}</div>
</div>
</div>
</TableCell>
<TableCell>
<DockerStatusBadge status={containerStatus} />
</TableCell>
<TableCell>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Progress
value={cpuPercentage}
className="flex-1 h-2 bg-muted/50"
indicatorClassName={getProgressColor(cpuPercentage)}
/>
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(cpuPercentage)}`}>
{cpuPercentage}%
</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Progress
value={memoryPercentage}
className="flex-1 h-2 bg-muted/50"
indicatorClassName={getProgressColor(memoryPercentage)}
/>
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(memoryPercentage)}`}>
{formatPercentage(container.ram_used, container.ram_total)}
</span>
</div>
<div className="text-xs text-muted-foreground font-mono">
{dockerService.formatBytes(container.ram_used)} / {dockerService.formatBytes(container.ram_total)}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Progress
value={diskPercentage}
className="flex-1 h-2 bg-muted/50"
indicatorClassName={getProgressColor(diskPercentage)}
/>
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(diskPercentage)}`}>
{formatPercentage(container.disk_used, container.disk_total)}
</span>
</div>
<div className="text-xs text-muted-foreground font-mono">
{dockerService.formatBytes(container.disk_used)} / {dockerService.formatBytes(container.disk_total)}
</div>
</div>
</TableCell>
<TableCell>
<span className="text-xs sm:text-sm font-medium font-mono">
{dockerService.formatUptime(container.uptime)}
</span>
</TableCell>
<TableCell>
<span className="text-xs sm:text-sm text-muted-foreground">
{new Date(container.last_checked).toLocaleString()}
</span>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<DockerRowActions
container={container}
containerStatus={containerStatus}
onContainerAction={onContainerAction}
onViewMetrics={onViewMetrics}
/>
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,39 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, RefreshCw } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerTableSearchProps {
searchTerm: string;
onSearchChange: (value: string) => void;
onRefresh: () => void;
isLoading: boolean;
}
export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => {
const { t } = useLanguage();
return (
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('searchContainersPlaceholder', 'docker')}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 sm:w-64 bg-background border-border"
/>
</div>
<Button
onClick={onRefresh}
disabled={isLoading}
variant="outline"
size="default"
className="w-full sm:w-auto bg-background border-border hover:bg-muted"
>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
<span className="sm:inline">{t('refresh', 'docker')}</span>
</Button>
</div>
);
};
@@ -0,0 +1,6 @@
export { DockerTableSearch } from './DockerTableSearch';
export { DockerTableHeader } from './DockerTableHeader';
export { DockerTableRow } from './DockerTableRow';
export { DockerRowActions } from './DockerRowActions';
export { DockerEmptyState } from './DockerEmptyState';
@@ -0,0 +1,209 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Plus, X, Server } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useQuery } from '@tanstack/react-query';
import { serviceService } from '@/services/serviceService';
import { useLanguage } from "@/contexts/LanguageContext";
interface ComponentsSelectorProps {
selectedComponents: Partial<StatusPageComponentRecord>[];
onComponentsChange: (components: Partial<StatusPageComponentRecord>[]) => void;
onComponentDelete?: (componentId: string) => void;
}
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
const { t } = useLanguage();
const [showAddForm, setShowAddForm] = useState(false);
const [newComponent, setNewComponent] = useState({
name: '',
description: '',
service_id: '',
server_id: '',
display_order: selectedComponents.length + 1,
});
// Fetch uptime services for the dropdown
const { data: services = [] } = useQuery({
queryKey: ['services'],
queryFn: serviceService.getServices,
});
const addComponent = () => {
if (!newComponent.name.trim()) return;
const component: Partial<StatusPageComponentRecord> = {
...newComponent,
operational_status_id: '', // Will be set when page is created
};
onComponentsChange([...selectedComponents, component]);
setNewComponent({
name: '',
description: '',
service_id: '',
server_id: '',
display_order: selectedComponents.length + 2,
});
setShowAddForm(false);
};
const removeComponent = async (index: number) => {
const component = selectedComponents[index];
// If component has an ID, it exists in database and needs to be deleted
if (component.id && onComponentDelete) {
await onComponentDelete(component.id);
} else {
// For new components not yet saved, just remove from local state
const updated = selectedComponents.filter((_, i) => i !== index);
onComponentsChange(updated);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
{t('statusPageComponents')}
</CardTitle>
<CardDescription>
{t('addMonitoringComponentsDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedComponents.length > 0 && (
<div className="space-y-2">
<Label>{t('selectedComponents')}</Label>
<div className="space-y-2">
{selectedComponents.map((component, index) => (
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
<div className="font-medium">{component.name}</div>
{component.description && (
<div className="text-sm text-muted-foreground">{component.description}</div>
)}
<div className="flex gap-2 mt-1">
{component.service_id && (
<Badge variant="secondary" className="text-xs">
{t('service')}: {services.find(s => s.id === component.service_id)?.name || component.service_id}
</Badge>
)}
{component.server_id && (
<Badge variant="secondary" className="text-xs">
{t('server')}: {component.server_id}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeComponent(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{!showAddForm ? (
<Button
variant="outline"
onClick={() => setShowAddForm(true)}
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
{t('addComponent')}
</Button>
) : (
<div className="border rounded-lg p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="component-name">{t('componentName')}</Label>
<Input
id="component-name"
placeholder={t('componentNamePlaceholder')}
value={newComponent.name}
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
/>
</div>
<div>
<Label htmlFor="display-order">{t('displayOrder')}</Label>
<Input
id="display-order"
type="number"
value={newComponent.display_order}
onChange={(e) => setNewComponent({ ...newComponent, display_order: parseInt(e.target.value) || 1 })}
/>
</div>
</div>
<div>
<Label htmlFor="component-description">{t('descriptionOptional')}</Label>
<Textarea
id="component-description"
placeholder={t('descriptionPlaceholder')}
value={newComponent.description}
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="service-id">{t('uptimeServiceOptional')}</Label>
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
<SelectTrigger>
<SelectValue placeholder={t('selectUptimeService')} />
</SelectTrigger>
<SelectContent>
{services.map((service) => (
<SelectItem key={service.id} value={service.id}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
service.status === 'up' ? 'bg-green-500' :
service.status === 'down' ? 'bg-red-500' :
'bg-yellow-500'
}`} />
{service.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="server-id">{t('serverIdOptional')}</Label>
<Input
id="server-id"
placeholder={t('serverIdPlaceholder')}
value={newComponent.server_id}
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
{t('addComponent')}
</Button>
<Button variant="outline" onClick={() => setShowAddForm(false)}>
{t('cancel')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
};

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