commit 171e2a4a0d63ad7e050a182660d465438ebe03f1 Author: wehub-resource-sync Date: Mon Jul 13 12:31:02 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b9caf00 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +server/ +**/node_modules +**/dist +**/.next +**/.turbo +**/.cache +**/coverage +**/*.log + diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..78454b3 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# These owners will be the default owners for everything in +* @tolaleng diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2647363 --- /dev/null +++ b/.github/FUNDING.yml @@ -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'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a96ffec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a49eab2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..735acc2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e70309e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 youโ€™d 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 hasnโ€™t been requested yet. + required: true + - label: I have described the feature clearly and provided supporting details. + required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..f45e1e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/security_issue.yml b/.github/ISSUE_TEMPLATE/security_issue.yml new file mode 100644 index 0000000..31e140c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security_issue.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/translation_request.yml b/.github/ISSUE_TEMPLATE/translation_request.yml new file mode 100644 index 0000000..a156fca --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation_request.yml @@ -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 youโ€™d 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 diff --git a/.github/workflows/build-frontend-image.yml.dev b/.github/workflows/build-frontend-image.yml.dev new file mode 100644 index 0000000..93d3d4d --- /dev/null +++ b/.github/workflows/build-frontend-image.yml.dev @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e798e2d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.talismanrc b/.talismanrc new file mode 100644 index 0000000..07486f7 --- /dev/null +++ b/.talismanrc @@ -0,0 +1,4 @@ +fileignoreconfig: +- filename: application/package-lock.json + checksum: 3253a06ef27a634ec2b8a8fe9c156288b9fcf57024623c7ddc3d321f2568e498 +version: "1.0" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b0ca797 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c493080 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,181 @@ +## ๐ŸŒ Select Language + + + + + + + +
+ + English +
English +
+
+ + Khmer +
แžแŸ’แž˜แŸ‚แžš +
+
+ + Japanese +
Japanese +
+
+ + +

+ Thank you to all our contributors, users, and supporters for making this project thrive. +

+ +

+ ๐Ÿš€ Stay tuned for more updates, features, and improvements. +

+ +# ๐Ÿ› ๏ธ 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). + +Weโ€™ll 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 + +Weโ€™d love to hear your ideas! Open a [Discussion](https://github.com/operacle/checkcle/discussions) or Feature Request issue. Make sure itโ€™s 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 + +Weโ€™re excited to build CheckCle together โ€” a powerful monitoring platform **for the community, by the community**. Your support means the world! ๐Ÿ’™ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..3d3d1dc --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb16fe5 --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +## ๐ŸŒ Select Language + + + + + + + + +
+ + English +
English +
+
+ + Khmer +
แžแŸ’แž˜แŸ‚แžš +
+
+ + Japanese +
Japanese +
+
+ + Chinese +
Chinese +
+
+ +

+ Thank you to all our contributors, users, and supporters for making this project thrive. +

+ +

+ ๐Ÿš€ Stay tuned for more updates, features, and improvements. +

+ +![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 youโ€™re a tech company interested in supporting CheckCle, please contact the author directly at tolaleng@checkcle.io + +### ๐Ÿค Ecosystem & Community Partner +
+ + GitBook Logo + + + + Cloudflare Logo + + + DigitalOcean Logo + + + + Jetbrains Logo + +
+ +--- + + +## ๐Ÿ‘ฅ 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. + +--- diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..4407281 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub ๆฅๆบ่ฏดๆ˜Ž + +- ๅŽŸๅง‹้กน็›ฎ๏ผš`operacle/checkcle` +- ๅŽŸๅง‹ไป“ๅบ“๏ผšhttps://github.com/operacle/checkcle +- ๅฏผๅ…ฅๆ–นๅผ๏ผšไธŠๆธธ้ป˜่ฎคๅˆ†ๆ”ฏ็š„ๆœ€ๆ–ฐๅฟซ็…ง +- ๅŽŸไฝœ่€…ใ€็‰ˆๆƒๅ’Œ่ฎธๅฏ่ฏไฟกๆฏไปฅๅŽŸๅง‹ไป“ๅบ“ๅŠๆœฌไป“ๅบ“ LICENSE ไธบๅ‡† +- ๆœฌๆ–‡ไปถไป…็”จไบŽ่ฎฐๅฝ•ๆฅๆบ๏ผŒไธไปฃ่กจ WeHub ๆ˜ฏๅŽŸ้กน็›ฎไฝœ่€… diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ba8204e --- /dev/null +++ b/SECURITY.md @@ -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 **3โ€“5 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) diff --git a/application/.dockerignore b/application/.dockerignore new file mode 100644 index 0000000..0bacb13 --- /dev/null +++ b/application/.dockerignore @@ -0,0 +1,6 @@ +.git +node_modules +dist +.cache +*.log + diff --git a/application/.gitignore b/application/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/application/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/application/bun.lockb b/application/bun.lockb new file mode 100644 index 0000000..160304d Binary files /dev/null and b/application/bun.lockb differ diff --git a/application/components.json b/application/components.json new file mode 100644 index 0000000..f29e3f1 --- /dev/null +++ b/application/components.json @@ -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" + } +} \ No newline at end of file diff --git a/application/eslint.config.js b/application/eslint.config.js new file mode 100644 index 0000000..e67846f --- /dev/null +++ b/application/eslint.config.js @@ -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", + }, + } +); diff --git a/application/index.html b/application/index.html new file mode 100644 index 0000000..bf65603 --- /dev/null +++ b/application/index.html @@ -0,0 +1,25 @@ + + + + + + + CheckCle + + + + + + + + + + + + + + +
+ + + diff --git a/application/package-lock.json b/application/package-lock.json new file mode 100644 index 0000000..d08ffa2 --- /dev/null +++ b/application/package-lock.json @@ -0,0 +1,6942 @@ +{ + "name": "vite_react_shadcn_ts", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vite_react_shadcn_ts", + "version": "0.0.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz", + "integrity": "sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==", + "dev": true, + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", + "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz", + "integrity": "sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.0", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.1.tgz", + "integrity": "sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collapsible": "1.1.1", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz", + "integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", + "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.0.tgz", + "integrity": "sha512-dP87DM/Y7jFlPgUZTlhx6FF5CEzOiaxp2rBCKlaXlpH5Ip/9Fg5zZ9lDOQ5o/MOfUlf36eak14zoWYpgcgGoOg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.1.tgz", + "integrity": "sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.2.tgz", + "integrity": "sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.1.tgz", + "integrity": "sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.2.tgz", + "integrity": "sha512-99EatSTpW+hRYHt7m8wdDlLtkmTovEe8Z/hnxUPV+SKuuNL5HWNhQI4QSdjZqNSgXHay2z4M3Dym73j9p2Gx5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-menu": "2.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz", + "integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz", + "integrity": "sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", + "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.2.tgz", + "integrity": "sha512-Y5w0qGhysvmqsIy6nQxaPa6mXNKznfoGjOfBgzOjocLxr2XlSjqBMYQQL+FfyogsMuX+m8cZyQGYhJxvxUzO4w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", + "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz", + "integrity": "sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.2.tgz", + "integrity": "sha512-cKmj5Gte7LVyuz+8gXinxZAZECQU+N7aq5pw7kUPpx3xjnDXDbsdzHtCCD2W72bwzy74AvrqdYnKYS42ueskUQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.1.tgz", + "integrity": "sha512-egDo0yJD2IK8L17gC82vptkvW1jLeni1VuqCyzY727dSJdk5cDjINomouLoNk8RVF7g2aNIfENKWL4UzeU9c8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", + "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", + "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.0.tgz", + "integrity": "sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", + "integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.0.tgz", + "integrity": "sha512-q2jMBdsJ9zB7QG6ngQNzNwlvxLQqONyL58QbEGwuyRZZb/ARQwk3uQVbCF7GvQVOtV6EU/pDxAw3zRzJZI3rpQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", + "integrity": "sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.1.tgz", + "integrity": "sha512-bEzQoDW0XP+h/oGbutF5VMWJPAl/UU8IJjr7h02SOHDIIIxq+cep8nItVNoBV+OMmahCdqdF38FTpmXoqQUGvw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz", + "integrity": "sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", + "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", + "integrity": "sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", + "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", + "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-toggle": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.4.tgz", + "integrity": "sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", + "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", + "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.39.tgz", + "integrity": "sha512-jns6VFeOT49uoTKLWIEfiQqJAlyqldNAt80kAr8f7a5YjX0zgnG3RBiLMpksx4Ka4SlK4O6TJ/lumIM3Trp82g==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.13" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.7.39", + "@swc/core-darwin-x64": "1.7.39", + "@swc/core-linux-arm-gnueabihf": "1.7.39", + "@swc/core-linux-arm64-gnu": "1.7.39", + "@swc/core-linux-arm64-musl": "1.7.39", + "@swc/core-linux-x64-gnu": "1.7.39", + "@swc/core-linux-x64-musl": "1.7.39", + "@swc/core-win32-arm64-msvc": "1.7.39", + "@swc/core-win32-ia32-msvc": "1.7.39", + "@swc/core-win32-x64-msvc": "1.7.39" + }, + "peerDependencies": { + "@swc/helpers": "*" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.39.tgz", + "integrity": "sha512-o2nbEL6scMBMCTvY9OnbyVXtepLuNbdblV9oNJEFia5v5eGj9WMrnRQiylH3Wp/G2NYkW7V1/ZVW+kfvIeYe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.39.tgz", + "integrity": "sha512-qMlv3XPgtPi/Fe11VhiPDHSLiYYk2dFYl747oGsHZPq+6tIdDQjIhijXPcsUHIXYDyG7lNpODPL8cP/X1sc9MA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.39.tgz", + "integrity": "sha512-NP+JIkBs1ZKnpa3Lk2W1kBJMwHfNOxCUJXuTa2ckjFsuZ8OUu2gwdeLFkTHbR43dxGwH5UzSmuGocXeMowra/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.39.tgz", + "integrity": "sha512-cPc+/HehyHyHcvAsk3ML/9wYcpWVIWax3YBaA+ScecJpSE04l/oBHPfdqKUPslqZ+Gcw0OWnIBGJT/fBZW2ayw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.39.tgz", + "integrity": "sha512-8RxgBC6ubFem66bk9XJ0vclu3exJ6eD7x7CwDhp5AD/tulZslTYXM7oNPjEtje3xxabXuj/bEUMNvHZhQRFdqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.39.tgz", + "integrity": "sha512-3gtCPEJuXLQEolo9xsXtuPDocmXQx12vewEyFFSMSjOfakuPOBmOQMa0sVL8Wwius8C1eZVeD1fgk0omMqeC+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.39.tgz", + "integrity": "sha512-mg39pW5x/eqqpZDdtjZJxrUvQNSvJF4O8wCl37fbuFUqOtXs4TxsjZ0aolt876HXxxhsQl7rS+N4KioEMSgTZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.39.tgz", + "integrity": "sha512-NZwuS0mNJowH3e9bMttr7B1fB8bW5svW/yyySigv9qmV5VcQRNz1kMlCvrCLYRsa93JnARuiaBI6FazSeG8mpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.39.tgz", + "integrity": "sha512-qFmvv5UExbJPXhhvCVDBnjK5Duqxr048dlVB6ZCgGzbRxuarOlawCzzLK4N172230pzlAWGLgn9CWl3+N6zfHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.39.tgz", + "integrity": "sha512-o+5IMqgOtj9+BEOp16atTfBgCogVak9svhBpwsbcJQp67bQbxGYhAPPDW/hZ2rpSSF7UdzbY9wudoX9G4trcuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz", + "integrity": "sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz", + "integrity": "sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==", + "dev": true, + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.59.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.16.tgz", + "integrity": "sha512-crHn+G3ltqb5JG0oUv6q+PMz1m1YkjpASrXTU+sYWW9pLk0t2GybUHNRqYPZWhxgjPaVGC4yp92gSFEJgYEsPw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.59.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.16.tgz", + "integrity": "sha512-MuyWheG47h6ERd4PKQ6V8gDyBu3ThNG22e1fRVwvq6ap3EqsFhyuxCAwhNP/03m/mLg+DAb0upgbPaX6VB+CkQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.59.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.7.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz", + "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/type-utils": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.11.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.1.tgz", + "integrity": "sha512-vgWOY0i1EROUK0Ctg1hwhtC3SdcDjZcdit4Ups4aPkDcB1jYhmo+RMYWY87cmXMhvtD5uf8lV89j2w16vkdSVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@swc/core": "^1.7.26" + }, + "peerDependencies": { + "vite": "^4 || ^5" + } + }, + "node_modules/acorn": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.13.0.tgz", + "integrity": "sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001669", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", + "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.0.tgz", + "integrity": "sha512-c2KZL9lP4DjkN3hk/an4pWn5b5ZefhRJnAc42n6LJ19kSnbeRbdQZE5dSeE2LBol1OwJD3X1BQvFTAsa8ReeDA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.45", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.45.tgz", + "integrity": "sha512-vOzZS6uZwhhbkZbcRyiy99Wg+pYFV5hk+5YaECvx0+Z31NR3Tt5zS6dze2OepT6PCTzVzT0dIJItti+uAW5zmw==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.3.0.tgz", + "integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.3.0.tgz", + "integrity": "sha512-P1FlinFDcIvggcErRjNuVqnUR8anyo8vLMIH8Rthgofw7Nj8qTguCa2QjFAbzxAUTQTPNNjNL7yt0BGGinVdFw==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.3.0", + "embla-carousel-reactive-utils": "8.3.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.3.0.tgz", + "integrity": "sha512-EYdhhJ302SC4Lmkx8GRsp0sjUhEN4WyFXPOk0kGu9OXZSRMmcBlRgTvHcq8eKJE1bXWBsOi1T83B+BSSVZSmwQ==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.3.0" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.13.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.5", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.1.0", + "eslint-visitor-keys": "^4.1.0", + "espree": "^10.2.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0-rc-fb9a90fa48-20240614", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0-rc-fb9a90fa48-20240614.tgz", + "integrity": "sha512-xsiRwaDNF5wWNC4ZHLut+x/YcAxksUd9Rizt7LaEn3bV8VyYRpXnRJQlLOfYaVy9esk4DFP4zPPnoNVjq5Gc0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.14.tgz", + "integrity": "sha512-aXvzCTK7ZBv1e7fahFuR3Z/fyQQSIQ711yPgYRj+Oj64tyTgO4iQIDmYXDBqvSWQ/FA4OSCsXOStlF+noU0/NA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-scope": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", + "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/input-otp": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz", + "integrity": "sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jspdf": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz", + "integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.7", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf-autotable": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz", + "integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==", + "license": "MIT", + "peerDependencies": { + "jspdf": "^2 || ^3" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lucide-react": { + "version": "0.462.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz", + "integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next-themes": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", + "integrity": "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" + } + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pocketbase": { + "version": "0.26.8", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.8.tgz", + "integrity": "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", + "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.53.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.1.tgz", + "integrity": "sha512-6aiQeBda4zjcuaugWvim9WsGqisoUk+etmFEsSUMm451/Ic8L/UAb7sRtMj3V+Hdzm6mMjU1VhiSzYUZeBm0Vg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-remove-scroll": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.6", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", + "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.5.tgz", + "integrity": "sha512-JMSe18rYupmx+dzYcdfWYZ93ZdxqQmLum3xWDVSUMI0UVwl9bB9gUaFmPbxYoO4G+m5sqgdXQCYQxnOysytfnw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-router": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", + "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", + "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.20.0", + "react-router": "6.27.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", + "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.13.0.tgz", + "integrity": "sha512-sbfxjWQ+oLWSZEWmvbq/DFVdeRLqqA6d0CDjKx2PkxVVdoXo16jvENCE+u/x7HxOO+/fwx//nYRwb8p8X6s/lQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rollup": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonner": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz", + "integrity": "sha512-FBjhG/gnnbN6FY0jaNnqZOMmB73R+5IiyYAw8yBj7L54ER7HB3fOSE5OFiQiE2iXWxeXKvg6fIP4LtVppHEdJA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tailwind-merge": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz", + "integrity": "sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.11.0.tgz", + "integrity": "sha512-cBRGnW3FSlxaYwU8KfAewxFK5uzeOAp0l2KebIlPDOT5olVi65KDG/yjBooPBG0kGW/HLkoz1c/iuBFehcS3IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.11.0", + "@typescript-eslint/parser": "8.11.0", + "@typescript-eslint/utils": "8.11.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", + "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vaul": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz", + "integrity": "sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/application/package.json b/application/package.json new file mode 100644 index 0000000..deb0ea3 --- /dev/null +++ b/application/package.json @@ -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" + } +} diff --git a/application/postcss.config.js b/application/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/application/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/application/public/checkcle_logo.svg b/application/public/checkcle_logo.svg new file mode 100644 index 0000000..d112473 --- /dev/null +++ b/application/public/checkcle_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/application/public/favicon.ico b/application/public/favicon.ico new file mode 100644 index 0000000..d5dfc62 Binary files /dev/null and b/application/public/favicon.ico differ diff --git a/application/public/favicon_sidebar.ico b/application/public/favicon_sidebar.ico new file mode 100644 index 0000000..074b8f9 Binary files /dev/null and b/application/public/favicon_sidebar.ico differ diff --git a/application/public/placeholder.svg b/application/public/placeholder.svg new file mode 100644 index 0000000..e763910 --- /dev/null +++ b/application/public/placeholder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/application/public/robots.txt b/application/public/robots.txt new file mode 100644 index 0000000..6018e70 --- /dev/null +++ b/application/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/application/public/upload/data/pb_schema_latest.json b/application/public/upload/data/pb_schema_latest.json new file mode 100644 index 0000000..dc623b4 --- /dev/null +++ b/application/public/upload/data/pb_schema_latest.json @@ -0,0 +1,5661 @@ +[ + { + "id": "pbc_1938176441", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "alert_configurations", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "channel_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1104941267", + "max": 0, + "min": 0, + "name": "telegram_chat_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2654632018", + "max": 0, + "min": 0, + "name": "discord_webhook_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text904114323", + "max": 0, + "min": 0, + "name": "signal_number", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text709732016", + "max": 0, + "min": 0, + "name": "notify_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3787265959", + "max": 0, + "min": 0, + "name": "bot_token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2946855043", + "max": 0, + "min": 0, + "name": "slack_webhook_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select887233555", + "maxSelect": 1, + "name": "notification_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "telegram", + "signal", + "discord", + "slack", + "webhook", + "google_chat", + "email", + "ntfy", + "pushover", + "notifiarr", + "gotify" + ] + }, + { + "hidden": false, + "id": "select1358543748", + "maxSelect": 1, + "name": "enabled", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "true", + "false" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2962097998", + "max": 0, + "min": 0, + "name": "email_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3070882914", + "max": 0, + "min": 0, + "name": "email_sender_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2941943271", + "max": 0, + "min": 0, + "name": "smtp_port", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text393471132", + "max": 0, + "min": 0, + "name": "smtp_server", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2735050465", + "max": 0, + "min": 0, + "name": "google_chat_webhook_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1263996916", + "max": 0, + "min": 0, + "name": "signal_api_endpoint", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4212033756", + "max": 0, + "min": 0, + "name": "smtp_password", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text137129747", + "max": 0, + "min": 0, + "name": "webhook_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3590941513", + "max": 0, + "min": 0, + "name": "webhook_payload_template", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1654359675", + "max": 0, + "min": 0, + "name": "ntfy_endpoint", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2074277355", + "max": 0, + "min": 0, + "name": "api_token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1636223522", + "max": 0, + "min": 0, + "name": "user_key", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3401535476", + "max": 0, + "min": 0, + "name": "server_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2889059272", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "data_settings", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "number1569706847", + "max": null, + "min": null, + "name": "retention_days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1419719274", + "max": null, + "min": null, + "name": "server_retention_days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3508549248", + "max": null, + "min": null, + "name": "uptime_retention_days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2800013036", + "max": 0, + "min": 0, + "name": "backup", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_285611395", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "dns_data", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2554192817", + "max": 0, + "min": 0, + "name": "query_type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3731179111", + "max": 0, + "min": 0, + "name": "resolve_ip", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2323266619", + "max": 0, + "min": 0, + "name": "msg_size", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3069659470", + "max": 0, + "min": 0, + "name": "question", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3671935525", + "max": 0, + "min": 0, + "name": "answer", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1257859836", + "max": 0, + "min": 0, + "name": "authority", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2273667377", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3168803865", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "docker_metrics", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "docker_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text933879452", + "max": 0, + "min": 0, + "name": "ram_total", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4038915663", + "max": 0, + "min": 0, + "name": "ram_used", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3302315167", + "max": 0, + "min": 0, + "name": "ram_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1461425251", + "max": 0, + "min": 0, + "name": "cpu_cores", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2062996636", + "max": 0, + "min": 0, + "name": "cpu_usage", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1525183142", + "max": 0, + "min": 0, + "name": "cpu_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2583550350", + "max": 0, + "min": 0, + "name": "disk_total", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1976676007", + "max": 0, + "min": 0, + "name": "disk_used", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1102580855", + "max": 0, + "min": 0, + "name": "disk_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1936777896", + "max": null, + "min": null, + "name": "network_rx_bytes", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3038767919", + "max": null, + "min": null, + "name": "network_tx_bytes", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3452919696", + "max": null, + "min": null, + "name": "network_rx_speed", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number195057175", + "max": null, + "min": null, + "name": "network_tx_speed", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_583547467", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "dockers", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1069831160", + "max": 0, + "min": 0, + "name": "docker_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3847340049", + "max": 0, + "min": 0, + "name": "hostname", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text587191692", + "max": 0, + "min": 0, + "name": "ip_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063420466", + "max": 0, + "min": 0, + "name": "os_template", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1563400775", + "max": 0, + "min": 0, + "name": "uptime", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number933879452", + "max": null, + "min": null, + "name": "ram_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number4038915663", + "max": null, + "min": null, + "name": "ram_used", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1461425251", + "max": null, + "min": null, + "name": "cpu_cores", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2062996636", + "max": null, + "min": null, + "name": "cpu_usage", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2583550350", + "max": null, + "min": null, + "name": "disk_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1976676007", + "max": null, + "min": null, + "name": "disk_used", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date3415181003", + "max": "", + "min": "", + "name": "last_checked", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2782324286", + "max": 0, + "min": 0, + "name": "timestamp", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2189087560", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "incidents", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2314121105", + "max": 0, + "min": 0, + "name": "assigned_users", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3210298530", + "max": 0, + "min": 0, + "name": "resolution_time", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text723782963", + "max": 0, + "min": 0, + "name": "affected_systems", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1210058505", + "max": 0, + "min": 0, + "name": "root_cause", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text178040949", + "max": 0, + "min": 0, + "name": "resolution_steps", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2767280054", + "max": 0, + "min": 0, + "name": "lessons_learned", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4053573849", + "max": 0, + "min": 0, + "name": "operational_status_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select2722347397", + "maxSelect": 1, + "name": "impact_status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "investigating", + "found_root_cause", + "identified", + "monitoring", + "resolved" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1655102503", + "maxSelect": 1, + "name": "priority", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + { + "hidden": false, + "id": "select3288973319", + "maxSelect": 1, + "name": "impact", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "minor", + "major", + "critical", + "none" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1738231158", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "maintenance", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date1345189255", + "max": "", + "min": "", + "name": "start_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date1096160257", + "max": "", + "min": "", + "name": "end_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3922189066", + "max": 0, + "min": 0, + "name": "affected", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3725765462", + "max": 0, + "min": 0, + "name": "created_by", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1348078739", + "max": 0, + "min": 0, + "name": "notify_subscribers", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1655102503", + "maxSelect": 1, + "name": "priority", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "scheduled", + "completed", + "cancelled", + "in_progress" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4053573849", + "max": 0, + "min": 0, + "name": "operational_status_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1542800728", + "maxSelect": 1, + "name": "field", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "minor", + "major", + "critical", + "none" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3490420651", + "max": 0, + "min": 0, + "name": "assigned_users", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3662317132", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "operational_page", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1811784642", + "maxSelect": 1, + "name": "is_public", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "true", + "false" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2560465762", + "max": 0, + "min": 0, + "name": "slug", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2541086472", + "max": 0, + "min": 0, + "name": "theme", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text156371623", + "max": 0, + "min": 0, + "name": "logo_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2466564623", + "max": 0, + "min": 0, + "name": "custom_domain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3581369753", + "max": 0, + "min": 0, + "name": "custom_css", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1430013884", + "max": 0, + "min": 0, + "name": "page_style", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "operational", + "degraded", + "partial_outage", + "major_outage", + "maintenance" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1168766540", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "ping_data", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1341404999", + "max": 0, + "min": 0, + "name": "packet_loss", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text926446584", + "max": 0, + "min": 0, + "name": "latency", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2750318623", + "max": 0, + "min": 0, + "name": "max_rtt", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2645286934", + "max": 0, + "min": 0, + "name": "min_rtt", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text108668494", + "max": 0, + "min": 0, + "name": "packets_sent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3848691274", + "max": 0, + "min": 0, + "name": "packets_recv", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2665772979", + "max": 0, + "min": 0, + "name": "avg_rtt", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text836103155", + "max": 0, + "min": 0, + "name": "rtts", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_171698555", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "regional_service", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1404634243", + "max": 0, + "min": 0, + "name": "agent_ip_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text704082790", + "max": 0, + "min": 0, + "name": "connection", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1597481275", + "max": 0, + "min": 0, + "name": "token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1998570700", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "server_metrics", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text933879452", + "max": 0, + "min": 0, + "name": "ram_total", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4038915663", + "max": 0, + "min": 0, + "name": "ram_used", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3302315167", + "max": 0, + "min": 0, + "name": "ram_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1461425251", + "max": 0, + "min": 0, + "name": "cpu_cores", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2062996636", + "max": 0, + "min": 0, + "name": "cpu_usage", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1525183142", + "max": 0, + "min": 0, + "name": "cpu_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2583550350", + "max": 0, + "min": 0, + "name": "disk_total", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1976676007", + "max": 0, + "min": 0, + "name": "disk_used", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1102580855", + "max": 0, + "min": 0, + "name": "disk_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1936777896", + "max": null, + "min": null, + "name": "network_rx_bytes", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3038767919", + "max": null, + "min": null, + "name": "network_tx_bytes", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3452919696", + "max": null, + "min": null, + "name": "network_rx_speed", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number195057175", + "max": null, + "min": null, + "name": "network_tx_speed", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text635908666", + "max": 0, + "min": 0, + "name": "agent_status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1907735298", + "max": 0, + "min": 0, + "name": "max_retries", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3065852031", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text772177811", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1965843930", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "server_notification_templates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2233324553", + "max": 0, + "min": 0, + "name": "ram_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3462525847", + "max": 0, + "min": 0, + "name": "cpu_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3499937189", + "max": 0, + "min": 0, + "name": "disk_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3897838783", + "max": 0, + "min": 0, + "name": "network_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2761878292", + "max": 0, + "min": 0, + "name": "up_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3025025370", + "max": 0, + "min": 0, + "name": "down_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2718833336", + "max": 0, + "min": 0, + "name": "paused_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3489289583", + "max": 0, + "min": 0, + "name": "cpu_temp_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2170408196", + "max": 0, + "min": 0, + "name": "disk_io_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4125531906", + "max": 0, + "min": 0, + "name": "placeholder", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1005957295", + "max": 0, + "min": 0, + "name": "warning_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text992877620", + "max": 0, + "min": 0, + "name": "restore_ram_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2334264704", + "max": 0, + "min": 0, + "name": "restore_cpu_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text808080362", + "max": 0, + "min": 0, + "name": "restore_disk_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2722458077", + "max": 0, + "min": 0, + "name": "restore_network_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text823542732", + "max": 0, + "min": 0, + "name": "restore_disk_io_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1526027363", + "max": 0, + "min": 0, + "name": "restore_cpu_temp_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1464856393", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "server_threshold_templates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2233324553", + "max": 0, + "min": 0, + "name": "ram_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3462525847", + "max": 0, + "min": 0, + "name": "cpu_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3499937189", + "max": 0, + "min": 0, + "name": "disk_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3897838783", + "max": 0, + "min": 0, + "name": "network_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3837604842", + "max": 0, + "min": 0, + "name": "disk_io_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2623591511", + "max": 0, + "min": 0, + "name": "cpu_temp_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3738798621", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "servers", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3847340049", + "max": 0, + "min": 0, + "name": "hostname", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text587191692", + "max": 0, + "min": 0, + "name": "ip_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063420466", + "max": 0, + "min": 0, + "name": "os_type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "up", + "down", + "warning", + "paused" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1563400775", + "max": 0, + "min": 0, + "name": "uptime", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number933879452", + "max": null, + "min": null, + "name": "ram_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number4038915663", + "max": null, + "min": null, + "name": "ram_used", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1461425251", + "max": null, + "min": null, + "name": "cpu_cores", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2062996636", + "max": null, + "min": null, + "name": "cpu_usage", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2583550350", + "max": null, + "min": null, + "name": "disk_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1976676007", + "max": null, + "min": null, + "name": "disk_used", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date3415181003", + "max": "", + "min": "", + "name": "last_checked", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4290167602", + "max": 0, + "min": 0, + "name": "server_token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2782324286", + "max": 0, + "min": 0, + "name": "timestamp", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text704082790", + "max": 0, + "min": 0, + "name": "connection", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text635908666", + "max": 0, + "min": 0, + "name": "agent_status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3661988513", + "max": 0, + "min": 0, + "name": "system_info", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1936777896", + "max": 0, + "min": 0, + "name": "network_rx_bytes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3038767919", + "max": 0, + "min": 0, + "name": "network_tx_bytes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3452919696", + "max": 0, + "min": 0, + "name": "network_rx_speed", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text195057175", + "max": 0, + "min": 0, + "name": "network_tx_speed", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number4097339557", + "max": null, + "min": null, + "name": "check_interval", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "select4065722285", + "maxSelect": 1, + "name": "docker", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "true", + "false" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1907735298", + "max": 0, + "min": 0, + "name": "max_retries", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1080317331", + "max": 0, + "min": 0, + "name": "threshold_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1553704459", + "max": 0, + "min": 0, + "name": "webhook_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool3215432513", + "name": "notification_status", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_763794515", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "service_group", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1572233440", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "service_notification_templates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2761878292", + "max": 0, + "min": 0, + "name": "up_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3025025370", + "max": 0, + "min": 0, + "name": "down_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3477829101", + "max": 0, + "min": 0, + "name": "maintenance_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text293022370", + "max": 0, + "min": 0, + "name": "incident_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3490715516", + "max": 0, + "min": 0, + "name": "resolved_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1005957295", + "max": 0, + "min": 0, + "name": "warning_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4125531906", + "max": 0, + "min": 0, + "name": "placeholder", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_863811952", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "services", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3475444733", + "max": 0, + "min": 0, + "name": "host", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1563400775", + "max": null, + "min": null, + "name": "uptime", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date3415181003", + "max": "", + "min": "", + "name": "last_checked", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number1133600204", + "max": null, + "min": null, + "name": "port", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2812878347", + "max": 0, + "min": 0, + "name": "domain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number3142844404", + "max": null, + "min": null, + "name": "heartbeat_interval", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1907735298", + "max": null, + "min": null, + "name": "max_retries", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1117643717", + "maxSelect": 1, + "name": "service_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "http", + "tcp", + "ping", + "dns" + ] + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "up", + "down", + "warning", + "paused" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select3434705756", + "maxSelect": 1, + "name": "alerts", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "muted", + "unmuted" + ] + }, + { + "hidden": false, + "id": "bool3215432513", + "name": "notification_status", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1745692878", + "max": 0, + "min": 0, + "name": "regional_status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text318214333", + "max": 0, + "min": 0, + "name": "status_codes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2502838439", + "max": 0, + "min": 0, + "name": "keyword", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1841317061", + "max": 0, + "min": 0, + "name": "group_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1553704459", + "max": 0, + "min": 0, + "name": "webhook_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3575570325", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "services_metrics", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2422262506", + "max": 0, + "min": 0, + "name": "service_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3475444733", + "max": 0, + "min": 0, + "name": "host", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1563400775", + "max": null, + "min": null, + "name": "uptime", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date3415181003", + "max": "", + "min": "", + "name": "last_checked", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number1133600204", + "max": null, + "min": null, + "name": "port", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2812878347", + "max": 0, + "min": 0, + "name": "domain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number3142844404", + "max": null, + "min": null, + "name": "heartbeat_interval", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1907735298", + "max": null, + "min": null, + "name": "max_retries", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1117643717", + "maxSelect": 1, + "name": "service_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "http", + "tcp", + "ping", + "dns" + ] + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "up", + "down", + "warning", + "paused" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select4152017003", + "maxSelect": 1, + "name": "alerts", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "muted", + "unmuted" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text318214333", + "max": 0, + "min": 0, + "name": "status_codes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1519612219", + "max": 0, + "min": 0, + "name": "keyword", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "region_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1404634243", + "max": 0, + "min": 0, + "name": "agent_ip_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1836745630", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "ssl_certificates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2812878347", + "max": 0, + "min": 0, + "name": "domain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2910474005", + "max": 0, + "min": 0, + "name": "issuer_o", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2857577546", + "max": "", + "min": "", + "name": "last_notified", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number2281365985", + "max": null, + "min": null, + "name": "warning_threshold", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number219021052", + "max": null, + "min": null, + "name": "expiry_threshold", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3085370608", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2077745078", + "max": "", + "min": "", + "name": "valid_from", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number227839714", + "max": null, + "min": null, + "name": "serial_number", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2790380089", + "max": 0, + "min": 0, + "name": "issued_to", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date3449712486", + "max": "", + "min": "", + "name": "valid_till", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number1533826704", + "max": null, + "min": null, + "name": "validity_days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number4007784154", + "max": null, + "min": null, + "name": "days_left", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number437829758", + "max": null, + "min": null, + "name": "valid_days_to_expire", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3452125334", + "max": 0, + "min": 0, + "name": "resolved_ip", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2712104570", + "max": 0, + "min": 0, + "name": "issuer_cn", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1754636037", + "max": 0, + "min": 0, + "name": "cert_alg", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1208359189", + "max": 0, + "min": 0, + "name": "cert_sans", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4097339557", + "max": 0, + "min": 0, + "name": "check_interval", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2784829323", + "max": "", + "min": "", + "name": "check_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1553704459", + "max": 0, + "min": 0, + "name": "webhook_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1753185970", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "ssl_history", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2812878347", + "max": 0, + "min": 0, + "name": "cert_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1881225410", + "max": 0, + "min": 0, + "name": "days_before_expiry", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3062427817", + "max": 0, + "min": 0, + "name": "days_before_warning", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select3839288252", + "maxSelect": 1, + "name": "warning_status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "sent", + "pending", + "failed" + ] + }, + { + "hidden": false, + "id": "select2670434693", + "maxSelect": 1, + "name": "expiry_status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "sent", + "pending", + "failed" + ] + }, + { + "hidden": false, + "id": "select3824245652", + "maxSelect": 1, + "name": "critical_status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "sent", + "pending", + "failed" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_351174173", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "ssl_notification_templates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2761878292", + "max": 0, + "min": 0, + "name": "expired", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3025025370", + "max": 0, + "min": 0, + "name": "exiring_soon", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1078893766", + "max": 0, + "min": 0, + "name": "warning", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "placeholder", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1636150439", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "status_page_components", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4053573849", + "max": 0, + "min": 0, + "name": "operational_status_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number4101605491", + "max": null, + "min": null, + "name": "display_order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2331916230", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "tcp_data", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1341404999", + "max": 0, + "min": 0, + "name": "connection", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text926446584", + "max": 0, + "min": 0, + "name": "latency", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2770808759", + "max": 0, + "min": 0, + "name": "port", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2273667377", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_67962705", + "listRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"", + "createRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "name": "uptime_data", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1280981898", + "max": 0, + "min": 0, + "name": "packets", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text926446584", + "max": 0, + "min": 0, + "name": "latency", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text318214333", + "max": 0, + "min": 0, + "name": "status_codes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1519612219", + "max": 0, + "min": 0, + "name": "keyword", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2552627541", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + } +] diff --git a/application/public/upload/notification/discord.png b/application/public/upload/notification/discord.png new file mode 100644 index 0000000..cb1b9bd Binary files /dev/null and b/application/public/upload/notification/discord.png differ diff --git a/application/public/upload/notification/email.png b/application/public/upload/notification/email.png new file mode 100644 index 0000000..7b81629 Binary files /dev/null and b/application/public/upload/notification/email.png differ diff --git a/application/public/upload/notification/google.png b/application/public/upload/notification/google.png new file mode 100644 index 0000000..d4587ab Binary files /dev/null and b/application/public/upload/notification/google.png differ diff --git a/application/public/upload/notification/gotify.png b/application/public/upload/notification/gotify.png new file mode 100644 index 0000000..105fc6c Binary files /dev/null and b/application/public/upload/notification/gotify.png differ diff --git a/application/public/upload/notification/matrix.png b/application/public/upload/notification/matrix.png new file mode 100644 index 0000000..4321cb8 Binary files /dev/null and b/application/public/upload/notification/matrix.png differ diff --git a/application/public/upload/notification/notifiarr.png b/application/public/upload/notification/notifiarr.png new file mode 100644 index 0000000..ae4d12e Binary files /dev/null and b/application/public/upload/notification/notifiarr.png differ diff --git a/application/public/upload/notification/ntfy.png b/application/public/upload/notification/ntfy.png new file mode 100644 index 0000000..ad57c98 Binary files /dev/null and b/application/public/upload/notification/ntfy.png differ diff --git a/application/public/upload/notification/pushover.png b/application/public/upload/notification/pushover.png new file mode 100644 index 0000000..8e7b04d Binary files /dev/null and b/application/public/upload/notification/pushover.png differ diff --git a/application/public/upload/notification/signal.png b/application/public/upload/notification/signal.png new file mode 100644 index 0000000..ce4e34c Binary files /dev/null and b/application/public/upload/notification/signal.png differ diff --git a/application/public/upload/notification/slack.png b/application/public/upload/notification/slack.png new file mode 100644 index 0000000..61f9b2e Binary files /dev/null and b/application/public/upload/notification/slack.png differ diff --git a/application/public/upload/notification/telegram.png b/application/public/upload/notification/telegram.png new file mode 100644 index 0000000..083d088 Binary files /dev/null and b/application/public/upload/notification/telegram.png differ diff --git a/application/public/upload/notification/webhook.png b/application/public/upload/notification/webhook.png new file mode 100644 index 0000000..b69edea Binary files /dev/null and b/application/public/upload/notification/webhook.png differ diff --git a/application/public/upload/os/alpine.png b/application/public/upload/os/alpine.png new file mode 100644 index 0000000..35884ac Binary files /dev/null and b/application/public/upload/os/alpine.png differ diff --git a/application/public/upload/os/arch.png b/application/public/upload/os/arch.png new file mode 100644 index 0000000..8a69099 Binary files /dev/null and b/application/public/upload/os/arch.png differ diff --git a/application/public/upload/os/centos.png b/application/public/upload/os/centos.png new file mode 100644 index 0000000..a946ebc Binary files /dev/null and b/application/public/upload/os/centos.png differ diff --git a/application/public/upload/os/debian.png b/application/public/upload/os/debian.png new file mode 100644 index 0000000..9b025dc Binary files /dev/null and b/application/public/upload/os/debian.png differ diff --git a/application/public/upload/os/linux.png b/application/public/upload/os/linux.png new file mode 100644 index 0000000..8a69099 Binary files /dev/null and b/application/public/upload/os/linux.png differ diff --git a/application/public/upload/os/rhel.png b/application/public/upload/os/rhel.png new file mode 100644 index 0000000..5fd5e8b Binary files /dev/null and b/application/public/upload/os/rhel.png differ diff --git a/application/public/upload/os/ubuntu.png b/application/public/upload/os/ubuntu.png new file mode 100644 index 0000000..fe086e2 Binary files /dev/null and b/application/public/upload/os/ubuntu.png differ diff --git a/application/public/upload/os/windows.png b/application/public/upload/os/windows.png new file mode 100644 index 0000000..cefd4ae Binary files /dev/null and b/application/public/upload/os/windows.png differ diff --git a/application/public/upload/profile/profile1.svg b/application/public/upload/profile/profile1.svg new file mode 100644 index 0000000..aa0b9f7 --- /dev/null +++ b/application/public/upload/profile/profile1.svg @@ -0,0 +1 @@ +Fun Emoji SetDavis Uchehttps://www.figma.com/community/file/968125295144990435https://creativecommons.org/licenses/by/4.0/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/) \ No newline at end of file diff --git a/application/public/upload/profile/profile2.svg b/application/public/upload/profile/profile2.svg new file mode 100644 index 0000000..f514232 --- /dev/null +++ b/application/public/upload/profile/profile2.svg @@ -0,0 +1 @@ +Fun Emoji SetDavis Uchehttps://www.figma.com/community/file/968125295144990435https://creativecommons.org/licenses/by/4.0/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/) \ No newline at end of file diff --git a/application/public/upload/profile/profile3.svg b/application/public/upload/profile/profile3.svg new file mode 100644 index 0000000..37ebc61 --- /dev/null +++ b/application/public/upload/profile/profile3.svg @@ -0,0 +1 @@ +Fun Emoji SetDavis Uchehttps://www.figma.com/community/file/968125295144990435https://creativecommons.org/licenses/by/4.0/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/) \ No newline at end of file diff --git a/application/public/upload/profile/profile4.svg b/application/public/upload/profile/profile4.svg new file mode 100644 index 0000000..4a475a2 --- /dev/null +++ b/application/public/upload/profile/profile4.svg @@ -0,0 +1 @@ +Fun Emoji SetDavis Uchehttps://www.figma.com/community/file/968125295144990435https://creativecommons.org/licenses/by/4.0/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/) \ No newline at end of file diff --git a/application/public/upload/profile/profile5.svg b/application/public/upload/profile/profile5.svg new file mode 100644 index 0000000..5a257c8 --- /dev/null +++ b/application/public/upload/profile/profile5.svg @@ -0,0 +1 @@ +Fun Emoji SetDavis Uchehttps://www.figma.com/community/file/968125295144990435https://creativecommons.org/licenses/by/4.0/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/) \ No newline at end of file diff --git a/application/public/upload/profile/profile6.svg b/application/public/upload/profile/profile6.svg new file mode 100644 index 0000000..c372ee0 --- /dev/null +++ b/application/public/upload/profile/profile6.svg @@ -0,0 +1 @@ +Fun Emoji SetDavis Uchehttps://www.figma.com/community/file/968125295144990435https://creativecommons.org/licenses/by/4.0/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/) \ No newline at end of file diff --git a/application/public/upload/profile/profile7.svg b/application/public/upload/profile/profile7.svg new file mode 100644 index 0000000..4315075 --- /dev/null +++ b/application/public/upload/profile/profile7.svg @@ -0,0 +1 @@ +Fun Emoji SetDavis Uchehttps://www.figma.com/community/file/968125295144990435https://creativecommons.org/licenses/by/4.0/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/) \ No newline at end of file diff --git a/application/public/upload/profile/profile8.svg b/application/public/upload/profile/profile8.svg new file mode 100644 index 0000000..a1a8e5e --- /dev/null +++ b/application/public/upload/profile/profile8.svg @@ -0,0 +1 @@ +Fun Emoji SetDavis Uchehttps://www.figma.com/community/file/968125295144990435https://creativecommons.org/licenses/by/4.0/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/) \ No newline at end of file diff --git a/application/src/App.css b/application/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/application/src/App.css @@ -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; +} diff --git a/application/src/App.tsx b/application/src/App.tsx new file mode 100644 index 0000000..e7f1e0b --- /dev/null +++ b/application/src/App.tsx @@ -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 ( + + + + + + + + {/* Public routes */} + } /> + + {/* Protected routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + ); +} + +export default App; \ No newline at end of file diff --git a/application/src/api/index.ts b/application/src/api/index.ts new file mode 100644 index 0000000..1fa86b7 --- /dev/null +++ b/application/src/api/index.ts @@ -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; \ No newline at end of file diff --git a/application/src/api/realtime/index.ts b/application/src/api/realtime/index.ts new file mode 100644 index 0000000..e114a68 --- /dev/null +++ b/application/src/api/realtime/index.ts @@ -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" + } + }; + } +} diff --git a/application/src/api/settings/actions/getSettings.ts b/application/src/api/settings/actions/getSettings.ts new file mode 100644 index 0000000..bf83662 --- /dev/null +++ b/application/src/api/settings/actions/getSettings.ts @@ -0,0 +1,26 @@ + +import { getAuthHeaders, getBaseUrl } from '../utils'; +import { SettingsApiResponse } from '../types'; + +export const getSettings = async (): Promise => { + 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' }, + }; + } +}; \ No newline at end of file diff --git a/application/src/api/settings/actions/sendTestEmail.ts b/application/src/api/settings/actions/sendTestEmail.ts new file mode 100644 index 0000000..10200df --- /dev/null +++ b/application/src/api/settings/actions/sendTestEmail.ts @@ -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 = ` + + +
+

Test Email

+

This is a test email from your monitoring system.

+

If you received this email, your SMTP configuration is working correctly.

+
+

+ Sent from CheckCle Monitoring System
+ Template: ${template}
+ ${data.collection ? `Collection: ${data.collection}` : ''} +

+
+ + + `; + + switch (template) { + case 'verification': + subject = 'Email Verification Test - CheckCle'; + htmlBody = ` + + +
+

Email Verification Test

+

This is a test of the email verification template.

+

If you received this email, your SMTP configuration is working correctly.

+
+

Template: Verification Email

+

Collection: ${data.collection || '_superusers'}

+
+
+

Sent from CheckCle Monitoring System

+
+ + + `; + break; + case 'password-reset': + subject = 'Password Reset Test - CheckCle'; + htmlBody = ` + + +
+

Password Reset Test

+

This is a test of the password reset template.

+

If you received this email, your SMTP configuration is working correctly.

+
+

Template: Password Reset Email

+

Collection: ${data.collection || '_superusers'}

+
+
+

Sent from CheckCle Monitoring System

+
+ + + `; + break; + case 'email-change': + subject = 'Email Change Confirmation Test - CheckCle'; + htmlBody = ` + + +
+

Email Change Confirmation Test

+

This is a test of the email change confirmation template.

+

If you received this email, your SMTP configuration is working correctly.

+
+

Template: Email Change Confirmation

+
+
+

Sent from CheckCle Monitoring System

+
+ + + `; + break; + } + + return { subject, htmlBody }; +}; + +export const sendTestEmail = async (data: any): Promise => { + 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.' + }, + }; + } +}; \ No newline at end of file diff --git a/application/src/api/settings/actions/testEmail.ts b/application/src/api/settings/actions/testEmail.ts new file mode 100644 index 0000000..0557a86 --- /dev/null +++ b/application/src/api/settings/actions/testEmail.ts @@ -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 = ` + + +
+

Test Email

+

This is a test email from your monitoring system.

+

If you received this email, your SMTP configuration is working correctly.

+
+

+ Sent from CheckCle Monitoring System
+ Template: ${template}
+ ${data.collection ? `Collection: ${data.collection}` : ''} +

+
+ + + `; + + switch (template) { + case 'verification': + subject = 'Email Verification Test - CheckCle'; + htmlBody = ` + + +
+

Email Verification Test

+

This is a test of the email verification template.

+

If you received this email, your SMTP configuration is working correctly.

+
+

Template: Verification Email

+

Collection: ${data.collection || '_superusers'}

+
+
+

Sent from CheckCle Monitoring System

+
+ + + `; + break; + case 'password-reset': + subject = 'Password Reset Test - CheckCle'; + htmlBody = ` + + +
+

Password Reset Test

+

This is a test of the password reset template.

+

If you received this email, your SMTP configuration is working correctly.

+
+

Template: Password Reset Email

+

Collection: ${data.collection || '_superusers'}

+
+
+

Sent from CheckCle Monitoring System

+
+ + + `; + break; + case 'email-change': + subject = 'Email Change Confirmation Test - CheckCle'; + htmlBody = ` + + +
+

Email Change Confirmation Test

+

This is a test of the email change confirmation template.

+

If you received this email, your SMTP configuration is working correctly.

+
+

Template: Email Change Confirmation

+
+
+

Sent from CheckCle Monitoring System

+
+ + + `; + break; + } + + return { subject, htmlBody }; +}; + +export const testEmail = async (data: any): Promise => { + 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.' + }, + }; + } +}; \ No newline at end of file diff --git a/application/src/api/settings/actions/testEmailConnection.ts b/application/src/api/settings/actions/testEmailConnection.ts new file mode 100644 index 0000000..41b6876 --- /dev/null +++ b/application/src/api/settings/actions/testEmailConnection.ts @@ -0,0 +1,31 @@ + +import { getAuthHeaders, getBaseUrl } from '../utils'; +import { SettingsApiResponse } from '../types'; + +export const testEmailConnection = async (data: any): Promise => { + 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' }, + }; + } +}; \ No newline at end of file diff --git a/application/src/api/settings/actions/updateSettings.ts b/application/src/api/settings/actions/updateSettings.ts new file mode 100644 index 0000000..0c88abc --- /dev/null +++ b/application/src/api/settings/actions/updateSettings.ts @@ -0,0 +1,38 @@ + +import { getAuthHeaders, getBaseUrl } from '../utils'; +import { SettingsApiResponse } from '../types'; + +export const updateSettings = async (data: any): Promise => { + 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' }, + }; + } +}; \ No newline at end of file diff --git a/application/src/api/settings/index.ts b/application/src/api/settings/index.ts new file mode 100644 index 0000000..50d2b7d --- /dev/null +++ b/application/src/api/settings/index.ts @@ -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; \ No newline at end of file diff --git a/application/src/api/settings/types.ts b/application/src/api/settings/types.ts new file mode 100644 index 0000000..01ef5f1 --- /dev/null +++ b/application/src/api/settings/types.ts @@ -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; +} \ No newline at end of file diff --git a/application/src/api/settings/utils.ts b/application/src/api/settings/utils.ts new file mode 100644 index 0000000..0039a1b --- /dev/null +++ b/application/src/api/settings/utils.ts @@ -0,0 +1,24 @@ + +import { pb, getCurrentEndpoint } from '@/lib/pocketbase'; + +export const getAuthHeaders = (): Record => { + const authToken = pb.authStore.token; + const headers: Record = { + '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); +}; \ No newline at end of file diff --git a/application/src/components/ErrorBoundary.tsx b/application/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..6a8ced8 --- /dev/null +++ b/application/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( +
+
+

Something went wrong

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+ +
+
+ ); + } + + return this.props.children; + } +} \ No newline at end of file diff --git a/application/src/components/auth/ForgotPasswordDialog.tsx b/application/src/components/auth/ForgotPasswordDialog.tsx new file mode 100644 index 0000000..1afad40 --- /dev/null +++ b/application/src/components/auth/ForgotPasswordDialog.tsx @@ -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 ( + + + + + {step === 'request' ? 'Reset Password' : 'Confirm Password Reset'} + + + {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.' + } + + + + {step === 'request' ? ( +
+
+ +
+
+ +
+ setEmail(e.target.value)} + required + className="pl-10" + /> +
+
+ +
+ + +
+
+ ) : ( +
+
+ + setToken(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={6} + /> +
+ +
+ + setPasswordConfirm(e.target.value)} + required + minLength={6} + /> +
+ +
+ + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/application/src/components/auth/ProtectedRoute.tsx b/application/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..22dad62 --- /dev/null +++ b/application/src/components/auth/ProtectedRoute.tsx @@ -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 ; + } + + return <>{children}; +}; \ No newline at end of file diff --git a/application/src/components/dashboard/DashboardContent.tsx b/application/src/components/dashboard/DashboardContent.tsx new file mode 100644 index 0000000..64308b6 --- /dev/null +++ b/application/src/components/dashboard/DashboardContent.tsx @@ -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("all"); + const [searchTerm, setSearchTerm] = useState(""); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(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 ( +
+

Error loading service data.

+ +
+ ); + } + + return ( +
+
+
+

{t('overview')}

+ +
+ + + + + +
+ +
+
+ + +
+ ); +}; diff --git a/application/src/components/dashboard/Header.tsx b/application/src/components/dashboard/Header.tsx new file mode 100644 index 0000000..83c0b67 --- /dev/null +++ b/application/src/components/dashboard/Header.tsx @@ -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(""); + 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 ( +
+ {/* Grid Pattern Overlay */} +
+
+
+
+
+ +
+ {/* Mobile Toggle */} + + + {/* Desktop Toggle */} + + +
+

+ {greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} ๐Ÿ‘‹ โœจ +

+
+
+ +
+ {/* External Links - Hidden on small screens */} +
+ + + + + +
+ + + + + + + + + setLanguage("en")} className={language === "en" ? "bg-accent" : ""}> + {t("english")} + + setLanguage("km")} className={language === "km" ? "bg-accent" : ""}> + {t("khmer")} + + setLanguage("de")} className={language === "de" ? "bg-accent" : ""}> + {t("german")} + + setLanguage("ko")} className={language === "ko" ? "bg-accent" : ""}> + {t("korean")} + + setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}> + {t("japanese")} + + setLanguage("zhcn")} className={language === "zhcn" ? "bg-accent" : ""}> + {t("simplifiedChinese")} + + + + +
+ + + + + {avatarUrl ? : {currentUser?.name?.[0] || 'U'}} + + + +
+ + {avatarUrl ? : {currentUser?.name?.[0] || 'U'}} + +
+ {currentUser?.name || 'User'} + {currentUser?.email} +
+
+ + navigate("/profile")}> + + {t("profile")} + + navigate("/settings")}> + + {t("settings")} + + + + + {t("logout")} + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/application/src/components/dashboard/ServiceFilters.tsx b/application/src/components/dashboard/ServiceFilters.tsx new file mode 100644 index 0000000..613fba4 --- /dev/null +++ b/application/src/components/dashboard/ServiceFilters.tsx @@ -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 ( +
+
+

{t('currentlyMonitoring')}

+ + {servicesCount} + +
+
+ +
+ setSearchTerm(e.target.value)} + /> +
+
+
+ ); +}; diff --git a/application/src/components/dashboard/ServicesTable.tsx b/application/src/components/dashboard/ServicesTable.tsx new file mode 100644 index 0000000..c47526e --- /dev/null +++ b/application/src/components/dashboard/ServicesTable.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/application/src/components/dashboard/Sidebar.tsx b/application/src/components/dashboard/Sidebar.tsx new file mode 100644 index 0000000..4feec70 --- /dev/null +++ b/application/src/components/dashboard/Sidebar.tsx @@ -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 && ( +
+ )} + + {/* Sidebar Container */} + + + ); +}; \ No newline at end of file diff --git a/application/src/components/dashboard/StatusCards.tsx b/application/src/components/dashboard/StatusCards.tsx new file mode 100644 index 0000000..428f8c9 --- /dev/null +++ b/application/src/components/dashboard/StatusCards.tsx @@ -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 ( +
+ {/* Up Services Card */} + + {/* Grid Pattern Overlay */} +
+
+
+ + {t("upServices")} + + + {upServices} +
+ +
+
+
+ + {/* Down Services Card */} + + {/* Grid Pattern Overlay */} +
+
+
+ + {t("downServices")} + + + {downServices} +
+ +
+
+
+ + {/* Paused Services Card */} + + {/* Grid Pattern Overlay */} +
+
+
+ + {t("pausedServices")} + + + {pausedServices} +
+ +
+
+
+ + {/* Warning Services Card */} + + {/* Grid Pattern Overlay */} +
+
+
+ + {t("warningServices")} + + + {warningServices} +
+ +
+
+
+
+ ); +}; diff --git a/application/src/components/dashboard/sidebar/MainNavigation.tsx b/application/src/components/dashboard/sidebar/MainNavigation.tsx new file mode 100644 index 0000000..9b4e103 --- /dev/null +++ b/application/src/components/dashboard/sidebar/MainNavigation.tsx @@ -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 = ({ collapsed }) => { + return ( + + ); +}; \ No newline at end of file diff --git a/application/src/components/dashboard/sidebar/MenuItem.tsx b/application/src/components/dashboard/sidebar/MenuItem.tsx new file mode 100644 index 0000000..2bbf474 --- /dev/null +++ b/application/src/components/dashboard/sidebar/MenuItem.tsx @@ -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 = ({ + 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 ( +
+ + + {!collapsed && ( + + {t(translationKey)} + + )} + + {/* Small Indicator for active state */} + {isActive && !collapsed && ( +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/application/src/components/dashboard/sidebar/SettingsPanel.tsx b/application/src/components/dashboard/sidebar/SettingsPanel.tsx new file mode 100644 index 0000000..2e311c3 --- /dev/null +++ b/application/src/components/dashboard/sidebar/SettingsPanel.tsx @@ -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 = ({ collapsed }) => { + const { theme } = useTheme(); + const { t } = useLanguage(); + const location = useLocation(); + const navigate = useNavigate(); + const [activeSettingsItem, setActiveSettingsItem] = useState("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 ( +
+
handleMenuItemClick('/settings', e)} + className="cursor-pointer" + > + +
+
+ ); + } + + return ( +
+ + +
+ {t("settingPanel")} +
+
+ + +
+
+ + +
+ +
+ {settingsMenuItems.map((item) => ( +
{ + handleMenuItemClick(`/settings?panel=${item.id}`, e); + handleSettingsItemClick(item.id); + }} + > + + {t(item.translationKey)} +
+ ))} +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/application/src/components/dashboard/sidebar/SidebarHeader.tsx b/application/src/components/dashboard/sidebar/SidebarHeader.tsx new file mode 100644 index 0000000..0ebbe2b --- /dev/null +++ b/application/src/components/dashboard/sidebar/SidebarHeader.tsx @@ -0,0 +1,24 @@ + +import React from "react"; +import { useTheme } from "@/contexts/ThemeContext"; + +interface SidebarHeaderProps { + collapsed: boolean; +} + +export const SidebarHeader: React.FC = ({ collapsed }) => { + const { theme } = useTheme(); + + return ( +
+
+ CheckCle +
+ {!collapsed &&

CheckCle App

} +
+ ); +}; \ No newline at end of file diff --git a/application/src/components/dashboard/sidebar/index.ts b/application/src/components/dashboard/sidebar/index.ts new file mode 100644 index 0000000..ec3edb4 --- /dev/null +++ b/application/src/components/dashboard/sidebar/index.ts @@ -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'; \ No newline at end of file diff --git a/application/src/components/dashboard/sidebar/navigationData.ts b/application/src/components/dashboard/sidebar/navigationData.ts new file mode 100644 index 0000000..3fd768a --- /dev/null +++ b/application/src/components/dashboard/sidebar/navigationData.ts @@ -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' + } +]; \ No newline at end of file diff --git a/application/src/components/docker/DockerContainersTable.tsx b/application/src/components/docker/DockerContainersTable.tsx new file mode 100644 index 0000000..45e4c5d --- /dev/null +++ b/application/src/components/docker/DockerContainersTable.tsx @@ -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(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 ( + <> + + +
+
+ {t('dockerContainers', 'docker')} + +
+
+
+ +
+
+
+ + + + {filteredContainers.length === 0 ? ( + + ) : ( + filteredContainers.map((container) => ( + + )) + )} + +
+
+
+
+
+
+ + + + ); +}; \ No newline at end of file diff --git a/application/src/components/docker/DockerMetricsDialog.tsx b/application/src/components/docker/DockerMetricsDialog.tsx new file mode 100644 index 0000000..970e6f6 --- /dev/null +++ b/application/src/components/docker/DockerMetricsDialog.tsx @@ -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("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; + }) => ( +
+
+ + {title} +
+
+
+ Used: + {used} +
+
+ Free: + {free} +
+
+ Total: + {total} +
+
+ Usage: + {percentage.toFixed(1)}% +
+
+
+ ); + + if (!container) return null; + + return ( + + + + +
+
+ +
+ {t('containerMetricsTitle', 'docker', { name: container.name })} +
+
+ +
+
+

+ {t('dockerId', 'docker')}: {container.docker_id} โ€ข {container.hostname} +

+
+ + {isLoading ? ( +
+ + {t('loadingMetrics', 'docker')} +
+ ) : error ? ( +
+

{t('errorLoadingMetrics', 'docker')}: {String((error as any)?.message ?? '')}

+
+ ) : chartData.length === 0 ? ( +
+

{t('noMetricsAvailable', 'docker')}

+
+ ) : ( + <> + {/* Current Metrics Summary */} + {latestMetric && ( +
+ + + + +
+ )} + + + + + + {t('cpu', 'docker')} + + + + {t('memoryTitle', 'docker')} + + + + {t('diskTitle', 'docker')} + + + + {t('network', 'docker')} + + + + +
+ + + {t('cpuUsagePct', 'docker')} + + + + + + + + } + cursor={{ stroke: getGridColor() }} + /> + + + + + + + + + {t('cpuUsageVsAvailable', 'docker')} + + + + + + + + } + cursor={{ stroke: getGridColor() }} + /> + + + + + + +
+
+ + +
+ + + {t('ramUsagePct', 'docker')} + + + + + + + + } + cursor={{ stroke: getGridColor() }} + /> + + + + + + + + + {t('memoryUsageBytes', 'docker')} + + + + + + + formatBytes(value)} + /> + } + cursor={{ stroke: getGridColor() }} + formatter={(value, name) => [ + name === t('usedMemory', 'docker') ? formatBytes(Number(value)) : + name === t('totalMemory', 'docker') ? formatBytes(Number(value)) : value, + name + ]} + /> + + + + + + +
+
+ + +
+ + + {t('diskUsagePct', 'docker')} + + + + + + + + } + cursor={{ stroke: getGridColor() }} + /> + + + + + + + + + {t('diskUsageBytes', 'docker')} + + + + + + + formatBytes(value)} + /> + } + cursor={{ stroke: getGridColor() }} + formatter={(value, name) => [ + name === t('usedDisk', 'docker') ? formatBytes(Number(value)) : + name === t('totalDisk', 'docker') ? formatBytes(Number(value)) : value, + name + ]} + /> + + + + + + +
+
+ + +
+ + + {t('networkTraffic', 'docker')} + + + + + + + + } + cursor={{ stroke: getGridColor() }} + /> + + + + + + + + + + {t('networkSpeedKbs', 'docker')} + + + + + + + + } + cursor={{ stroke: getGridColor() }} + /> + + + + + + +
+
+
+ + )} +
+
+ ); +}; \ No newline at end of file diff --git a/application/src/components/docker/DockerStatsCards.tsx b/application/src/components/docker/DockerStatsCards.tsx new file mode 100644 index 0000000..4453fe6 --- /dev/null +++ b/application/src/components/docker/DockerStatsCards.tsx @@ -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 ( +
+ {cards.map((card) => { + const IconComponent = card.icon; + return ( + + {/* Grid Pattern Overlay */} +
+
+
+ + + + {card.title} + +
+ +
+
+ +
+
+ {card.value} +
+ + {t('containersLabel', 'docker')} + +
+
+ + ); + })} +
+ ); +}; \ No newline at end of file diff --git a/application/src/components/docker/DockerStatusBadge.tsx b/application/src/components/docker/DockerStatusBadge.tsx new file mode 100644 index 0000000..648d452 --- /dev/null +++ b/application/src/components/docker/DockerStatusBadge.tsx @@ -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 ( + + {config.label} + + ); +}; \ No newline at end of file diff --git a/application/src/components/docker/table/DockerEmptyState.tsx b/application/src/components/docker/table/DockerEmptyState.tsx new file mode 100644 index 0000000..311f8f5 --- /dev/null +++ b/application/src/components/docker/table/DockerEmptyState.tsx @@ -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 ( + + +
+
+ {searchTerm ? t('noContainersFound', 'docker') : t('noContainersRunning', 'docker')} +
+
+ {searchTerm ? t('tryAdjustSearch', 'docker') : t('startSomeContainers', 'docker')} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/application/src/components/docker/table/DockerRowActions.tsx b/application/src/components/docker/table/DockerRowActions.tsx new file mode 100644 index 0000000..997d742 --- /dev/null +++ b/application/src/components/docker/table/DockerRowActions.tsx @@ -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 ( + + + + + + onViewMetrics(container)} + className="cursor-pointer hover:bg-muted" + > + + {t('viewMetrics', 'docker')} + + onContainerAction('view-detail', container.id, container.name)} + className="cursor-pointer hover:bg-muted" + > + + {t('viewDetails', 'docker')} + + + + ); +}; \ No newline at end of file diff --git a/application/src/components/docker/table/DockerTableHeader.tsx b/application/src/components/docker/table/DockerTableHeader.tsx new file mode 100644 index 0000000..1e0355c --- /dev/null +++ b/application/src/components/docker/table/DockerTableHeader.tsx @@ -0,0 +1,21 @@ + +import { TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { useLanguage } from "@/contexts/LanguageContext"; + +export const DockerTableHeader = () => { + const { t } = useLanguage(); + return ( + + + {t('container', 'docker')} + {t('status', 'docker')} + {t('cpuUsage', 'docker')} + {t('memory', 'docker')} + {t('disk', 'docker')} + {t('uptime', 'docker')} + {t('lastChecked', 'docker')} + {t('actions', 'docker')} + + + ); +}; \ No newline at end of file diff --git a/application/src/components/docker/table/DockerTableRow.tsx b/application/src/components/docker/table/DockerTableRow.tsx new file mode 100644 index 0000000..119037a --- /dev/null +++ b/application/src/components/docker/table/DockerTableRow.tsx @@ -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 ( + onRowClick(container)} + > + +
+
{container.name}
+
+
{container.docker_id}
+
+
+
+ + + + +
+
+ + + {cpuPercentage}% + +
+
+
+ +
+
+ + + {formatPercentage(container.ram_used, container.ram_total)} + +
+
+ {dockerService.formatBytes(container.ram_used)} / {dockerService.formatBytes(container.ram_total)} +
+
+
+ +
+
+ + + {formatPercentage(container.disk_used, container.disk_total)} + +
+
+ {dockerService.formatBytes(container.disk_used)} / {dockerService.formatBytes(container.disk_total)} +
+
+
+ + + {dockerService.formatUptime(container.uptime)} + + + + + {new Date(container.last_checked).toLocaleString()} + + + e.stopPropagation()}> + + +
+ ); +}; \ No newline at end of file diff --git a/application/src/components/docker/table/DockerTableSearch.tsx b/application/src/components/docker/table/DockerTableSearch.tsx new file mode 100644 index 0000000..f9b0cba --- /dev/null +++ b/application/src/components/docker/table/DockerTableSearch.tsx @@ -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 ( +
+
+ + onSearchChange(e.target.value)} + className="pl-10 sm:w-64 bg-background border-border" + /> +
+ +
+ ); +}; \ No newline at end of file diff --git a/application/src/components/docker/table/index.ts b/application/src/components/docker/table/index.ts new file mode 100644 index 0000000..559a629 --- /dev/null +++ b/application/src/components/docker/table/index.ts @@ -0,0 +1,6 @@ + +export { DockerTableSearch } from './DockerTableSearch'; +export { DockerTableHeader } from './DockerTableHeader'; +export { DockerTableRow } from './DockerTableRow'; +export { DockerRowActions } from './DockerRowActions'; +export { DockerEmptyState } from './DockerEmptyState'; \ No newline at end of file diff --git a/application/src/components/operational-page/ComponentsSelector.tsx b/application/src/components/operational-page/ComponentsSelector.tsx new file mode 100644 index 0000000..25e3eb1 --- /dev/null +++ b/application/src/components/operational-page/ComponentsSelector.tsx @@ -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[]; + onComponentsChange: (components: Partial[]) => 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 = { + ...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 ( + + + + + {t('statusPageComponents')} + + + {t('addMonitoringComponentsDesc')} + + + + {selectedComponents.length > 0 && ( +
+ +
+ {selectedComponents.map((component, index) => ( +
+
+
{component.name}
+ {component.description && ( +
{component.description}
+ )} +
+ {component.service_id && ( + + {t('service')}: {services.find(s => s.id === component.service_id)?.name || component.service_id} + + )} + {component.server_id && ( + + {t('server')}: {component.server_id} + + )} +
+
+ +
+ ))} +
+
+ )} + + {!showAddForm ? ( + + ) : ( +
+
+
+ + setNewComponent({ ...newComponent, name: e.target.value })} + /> +
+
+ + setNewComponent({ ...newComponent, display_order: parseInt(e.target.value) || 1 })} + /> +
+
+ +
+ + + + +

+ Thanks,
+ PocketBase team +

+
+
+ + + `, + "Lorem ipsum \r\nDolor sit amet \r\n[Verify](a/b/c) \r\n[Verify2.1 Verify2.2](a/b/c) \r\n\r\n- ul.test1 \r\n- ul.test2 \r\n- ul.test3 \r\n- ol.test1 \r\n- ol.test2 \r\n- ol.test3 \r\nThanks,\r\nPocketBase team", + }, + } + + for i, s := range scenarios { + result, err := html2Text(s.html) + if err != nil { + t.Errorf("(%d) Unexpected error %v", i, err) + } + + if result != s.expected { + t.Errorf("(%d) Expected \n(%q)\n%v,\n\ngot:\n\n(%q)\n%v", i, s.expected, s.expected, result, result) + } + } +} diff --git a/server/core_backend-server/tools/mailer/mailer.go b/server/core_backend-server/tools/mailer/mailer.go new file mode 100644 index 0000000..c87e2e8 --- /dev/null +++ b/server/core_backend-server/tools/mailer/mailer.go @@ -0,0 +1,72 @@ +package mailer + +import ( + "bytes" + "io" + "net/mail" + + "github.com/gabriel-vasile/mimetype" + "github.com/pocketbase/pocketbase/tools/hook" +) + +// Message defines a generic email message struct. +type Message struct { + From mail.Address `json:"from"` + To []mail.Address `json:"to"` + Bcc []mail.Address `json:"bcc"` + Cc []mail.Address `json:"cc"` + Subject string `json:"subject"` + HTML string `json:"html"` + Text string `json:"text"` + Headers map[string]string `json:"headers"` + Attachments map[string]io.Reader `json:"attachments"` + InlineAttachments map[string]io.Reader `json:"inlineAttachments"` +} + +// Mailer defines a base mail client interface. +type Mailer interface { + // Send sends an email with the provided Message. + Send(message *Message) error +} + +// SendInterceptor is optional interface for registering mail send hooks. +type SendInterceptor interface { + OnSend() *hook.Hook[*SendEvent] +} + +type SendEvent struct { + hook.Event + Message *Message +} + +// addressesToStrings converts the provided address to a list of serialized RFC 5322 strings. +// +// To export only the email part of mail.Address, you can set withName to false. +func addressesToStrings(addresses []mail.Address, withName bool) []string { + result := make([]string, len(addresses)) + + for i, addr := range addresses { + if withName && addr.Name != "" { + result[i] = addr.String() + } else { + // keep only the email part to avoid wrapping in angle-brackets + result[i] = addr.Address + } + } + + return result +} + +// detectReaderMimeType reads the first couple bytes of the reader to detect its MIME type. +// +// Returns a new combined reader from the partial read + the remaining of the original reader. +func detectReaderMimeType(r io.Reader) (io.Reader, string, error) { + readCopy := new(bytes.Buffer) + + mime, err := mimetype.DetectReader(io.TeeReader(r, readCopy)) + if err != nil { + return nil, "", err + } + + return io.MultiReader(readCopy, r), mime.String(), nil +} diff --git a/server/core_backend-server/tools/mailer/mailer_test.go b/server/core_backend-server/tools/mailer/mailer_test.go new file mode 100644 index 0000000..571f9ea --- /dev/null +++ b/server/core_backend-server/tools/mailer/mailer_test.go @@ -0,0 +1,77 @@ +package mailer + +import ( + "fmt" + "io" + "net/mail" + "strings" + "testing" +) + +func TestAddressesToStrings(t *testing.T) { + t.Parallel() + + scenarios := []struct { + withName bool + addresses []mail.Address + expected []string + }{ + { + true, + []mail.Address{{Name: "John Doe", Address: "test1@example.com"}, {Name: "Jane Doe", Address: "test2@example.com"}}, + []string{`"John Doe" `, `"Jane Doe" `}, + }, + { + true, + []mail.Address{{Name: "John Doe", Address: "test1@example.com"}, {Address: "test2@example.com"}}, + []string{`"John Doe" `, `test2@example.com`}, + }, + { + false, + []mail.Address{{Name: "John Doe", Address: "test1@example.com"}, {Name: "Jane Doe", Address: "test2@example.com"}}, + []string{`test1@example.com`, `test2@example.com`}, + }, + } + + for _, s := range scenarios { + t.Run(fmt.Sprintf("%v_%v", s.withName, s.addresses), func(t *testing.T) { + result := addressesToStrings(s.addresses, s.withName) + + if len(s.expected) != len(result) { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, result) + } + + for k, v := range s.expected { + if v != result[k] { + t.Fatalf("Expected %d address %q, got %q", k, v, result[k]) + } + } + }) + } +} + +func TestDetectReaderMimeType(t *testing.T) { + t.Parallel() + + str := "#!/bin/node\n" + strings.Repeat("a", 10000) // ensure that it is large enough to remain after the signature sniffing + + r, mime, err := detectReaderMimeType(strings.NewReader(str)) + if err != nil { + t.Fatal(err) + } + + expectedMime := "text/javascript" + if mime != expectedMime { + t.Fatalf("Expected mime %q, got %q", expectedMime, mime) + } + + raw, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) + + if rawStr != str { + t.Fatalf("Expected content\n%s\ngot\n%s", str, rawStr) + } +} diff --git a/server/core_backend-server/tools/mailer/sendmail.go b/server/core_backend-server/tools/mailer/sendmail.go new file mode 100644 index 0000000..b1dbaec --- /dev/null +++ b/server/core_backend-server/tools/mailer/sendmail.go @@ -0,0 +1,99 @@ +package mailer + +import ( + "bytes" + "errors" + "mime" + "net/http" + "os/exec" + "strings" + + "github.com/pocketbase/pocketbase/tools/hook" +) + +var _ Mailer = (*Sendmail)(nil) + +// Sendmail implements [mailer.Mailer] interface and defines a mail +// client that sends emails via the "sendmail" *nix command. +// +// This client is usually recommended only for development and testing. +type Sendmail struct { + onSend *hook.Hook[*SendEvent] +} + +// OnSend implements [mailer.SendInterceptor] interface. +func (c *Sendmail) OnSend() *hook.Hook[*SendEvent] { + if c.onSend == nil { + c.onSend = &hook.Hook[*SendEvent]{} + } + return c.onSend +} + +// Send implements [mailer.Mailer] interface. +func (c *Sendmail) Send(m *Message) error { + if c.onSend != nil { + return c.onSend.Trigger(&SendEvent{Message: m}, func(e *SendEvent) error { + return c.send(e.Message) + }) + } + + return c.send(m) +} + +func (c *Sendmail) send(m *Message) error { + toAddresses := addressesToStrings(m.To, false) + + headers := make(http.Header) + headers.Set("Subject", mime.QEncoding.Encode("utf-8", m.Subject)) + headers.Set("From", m.From.String()) + headers.Set("Content-Type", "text/html; charset=UTF-8") + headers.Set("To", strings.Join(toAddresses, ",")) + + cmdPath, err := findSendmailPath() + if err != nil { + return err + } + + var buffer bytes.Buffer + + // write + // --- + if err := headers.Write(&buffer); err != nil { + return err + } + if _, err := buffer.Write([]byte("\r\n")); err != nil { + return err + } + if m.HTML != "" { + if _, err := buffer.Write([]byte(m.HTML)); err != nil { + return err + } + } else { + if _, err := buffer.Write([]byte(m.Text)); err != nil { + return err + } + } + // --- + + sendmail := exec.Command(cmdPath, strings.Join(toAddresses, ",")) + sendmail.Stdin = &buffer + + return sendmail.Run() +} + +func findSendmailPath() (string, error) { + options := []string{ + "/usr/sbin/sendmail", + "/usr/bin/sendmail", + "sendmail", + } + + for _, option := range options { + path, err := exec.LookPath(option) + if err == nil { + return path, err + } + } + + return "", errors.New("failed to locate a sendmail executable path") +} diff --git a/server/core_backend-server/tools/mailer/smtp.go b/server/core_backend-server/tools/mailer/smtp.go new file mode 100644 index 0000000..3f1f745 --- /dev/null +++ b/server/core_backend-server/tools/mailer/smtp.go @@ -0,0 +1,211 @@ +package mailer + +import ( + "errors" + "fmt" + "net/smtp" + "strings" + + "github.com/domodwyer/mailyak/v3" + "github.com/pocketbase/pocketbase/tools/hook" + "github.com/pocketbase/pocketbase/tools/security" +) + +var _ Mailer = (*SMTPClient)(nil) + +const ( + SMTPAuthPlain = "PLAIN" + SMTPAuthLogin = "LOGIN" +) + +// SMTPClient defines a SMTP mail client structure that implements +// `mailer.Mailer` interface. +type SMTPClient struct { + onSend *hook.Hook[*SendEvent] + + TLS bool + Port int + Host string + Username string + Password string + + // SMTP auth method to use + // (if not explicitly set, defaults to "PLAIN") + AuthMethod string + + // LocalName is optional domain name used for the EHLO/HELO exchange + // (if not explicitly set, defaults to "localhost"). + // + // This is required only by some SMTP servers, such as Gmail SMTP-relay. + LocalName string +} + +// OnSend implements [mailer.SendInterceptor] interface. +func (c *SMTPClient) OnSend() *hook.Hook[*SendEvent] { + if c.onSend == nil { + c.onSend = &hook.Hook[*SendEvent]{} + } + return c.onSend +} + +// Send implements [mailer.Mailer] interface. +func (c *SMTPClient) Send(m *Message) error { + if c.onSend != nil { + return c.onSend.Trigger(&SendEvent{Message: m}, func(e *SendEvent) error { + return c.send(e.Message) + }) + } + + return c.send(m) +} + +func (c *SMTPClient) send(m *Message) error { + var smtpAuth smtp.Auth + if c.Username != "" || c.Password != "" { + switch c.AuthMethod { + case SMTPAuthLogin: + smtpAuth = &smtpLoginAuth{c.Username, c.Password} + default: + smtpAuth = smtp.PlainAuth("", c.Username, c.Password, c.Host) + } + } + + // create mail instance + var yak *mailyak.MailYak + if c.TLS { + var tlsErr error + yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth, nil) + if tlsErr != nil { + return tlsErr + } + } else { + yak = mailyak.New(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth) + } + + if c.LocalName != "" { + yak.LocalName(c.LocalName) + } + + if m.From.Name != "" { + yak.FromName(m.From.Name) + } + yak.From(m.From.Address) + yak.Subject(m.Subject) + yak.HTML().Set(m.HTML) + + if m.Text == "" { + // try to generate a plain text version of the HTML + if plain, err := html2Text(m.HTML); err == nil { + yak.Plain().Set(plain) + } + } else { + yak.Plain().Set(m.Text) + } + + if len(m.To) > 0 { + yak.To(addressesToStrings(m.To, true)...) + } + + if len(m.Bcc) > 0 { + yak.Bcc(addressesToStrings(m.Bcc, true)...) + } + + if len(m.Cc) > 0 { + yak.Cc(addressesToStrings(m.Cc, true)...) + } + + // add regular attachements (if any) + for name, data := range m.Attachments { + r, mime, err := detectReaderMimeType(data) + if err != nil { + return err + } + yak.AttachWithMimeType(name, r, mime) + } + + // add inline attachments (if any) + for name, data := range m.InlineAttachments { + r, mime, err := detectReaderMimeType(data) + if err != nil { + return err + } + yak.AttachInlineWithMimeType(name, r, mime) + } + + // add custom headers (if any) + var hasMessageId bool + for k, v := range m.Headers { + if strings.EqualFold(k, "Message-ID") { + hasMessageId = true + } + yak.AddHeader(k, v) + } + if !hasMessageId { + // add a default message id if missing + fromParts := strings.Split(m.From.Address, "@") + if len(fromParts) == 2 { + yak.AddHeader("Message-ID", fmt.Sprintf("<%s@%s>", + security.PseudorandomString(15), + fromParts[1], + )) + } + } + + return yak.Send() +} + +// ------------------------------------------------------------------- +// AUTH LOGIN +// ------------------------------------------------------------------- + +var _ smtp.Auth = (*smtpLoginAuth)(nil) + +// smtpLoginAuth defines an AUTH that implements the LOGIN authentication mechanism. +// +// AUTH LOGIN is obsolete[1] but some mail services like outlook requires it [2]. +// +// NB! +// It will only send the credentials if the connection is using TLS or is connected to localhost. +// Otherwise authentication will fail with an error, without sending the credentials. +// +// [1]: https://github.com/golang/go/issues/40817 +// [2]: https://support.microsoft.com/en-us/office/outlook-com-no-longer-supports-auth-plain-authentication-07f7d5e9-1697-465f-84d2-4513d4ff0145?ui=en-us&rs=en-us&ad=us +type smtpLoginAuth struct { + username, password string +} + +// Start initializes an authentication with the server. +// +// It is part of the [smtp.Auth] interface. +func (a *smtpLoginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + // Must have TLS, or else localhost server. + // Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo. + // In particular, it doesn't matter if the server advertises LOGIN auth. + // That might just be the attacker saying + // "it's ok, you can trust me with your password." + if !server.TLS && !isLocalhost(server.Name) { + return "", nil, errors.New("unencrypted connection") + } + + return "LOGIN", nil, nil +} + +// Next "continues" the auth process by feeding the server with the requested data. +// +// It is part of the [smtp.Auth] interface. +func (a *smtpLoginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch strings.ToLower(string(fromServer)) { + case "username:": + return []byte(a.username), nil + case "password:": + return []byte(a.password), nil + } + } + + return nil, nil +} + +func isLocalhost(name string) bool { + return name == "localhost" || name == "127.0.0.1" || name == "::1" +} diff --git a/server/core_backend-server/tools/mailer/smtp_test.go b/server/core_backend-server/tools/mailer/smtp_test.go new file mode 100644 index 0000000..49797d5 --- /dev/null +++ b/server/core_backend-server/tools/mailer/smtp_test.go @@ -0,0 +1,166 @@ +package mailer + +import ( + "net/smtp" + "testing" +) + +func TestLoginAuthStart(t *testing.T) { + auth := smtpLoginAuth{username: "test", password: "123456"} + + scenarios := []struct { + name string + serverInfo *smtp.ServerInfo + expectError bool + }{ + { + "localhost without tls", + &smtp.ServerInfo{TLS: false, Name: "localhost"}, + false, + }, + { + "localhost with tls", + &smtp.ServerInfo{TLS: true, Name: "localhost"}, + false, + }, + { + "127.0.0.1 without tls", + &smtp.ServerInfo{TLS: false, Name: "127.0.0.1"}, + false, + }, + { + "127.0.0.1 with tls", + &smtp.ServerInfo{TLS: false, Name: "127.0.0.1"}, + false, + }, + { + "::1 without tls", + &smtp.ServerInfo{TLS: false, Name: "::1"}, + false, + }, + { + "::1 with tls", + &smtp.ServerInfo{TLS: false, Name: "::1"}, + false, + }, + { + "non-localhost without tls", + &smtp.ServerInfo{TLS: false, Name: "example.com"}, + true, + }, + { + "non-localhost with tls", + &smtp.ServerInfo{TLS: true, Name: "example.com"}, + false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + method, resp, err := auth.Start(s.serverInfo) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) + } + + if hasErr { + return + } + + if len(resp) != 0 { + t.Fatalf("Expected empty data response, got %v", resp) + } + + if method != "LOGIN" { + t.Fatalf("Expected LOGIN, got %v", method) + } + }) + } +} + +func TestLoginAuthNext(t *testing.T) { + auth := smtpLoginAuth{username: "test", password: "123456"} + + { + // example|false + r1, err := auth.Next([]byte("example:"), false) + if err != nil { + t.Fatalf("[example|false] Unexpected error %v", err) + } + if len(r1) != 0 { + t.Fatalf("[example|false] Expected empty part, got %v", r1) + } + + // example|true + r2, err := auth.Next([]byte("example:"), true) + if err != nil { + t.Fatalf("[example|true] Unexpected error %v", err) + } + if len(r2) != 0 { + t.Fatalf("[example|true] Expected empty part, got %v", r2) + } + } + + // --------------------------------------------------------------- + + { + // username:|false + r1, err := auth.Next([]byte("username:"), false) + if err != nil { + t.Fatalf("[username|false] Unexpected error %v", err) + } + if len(r1) != 0 { + t.Fatalf("[username|false] Expected empty part, got %v", r1) + } + + // username:|true + r2, err := auth.Next([]byte("username:"), true) + if err != nil { + t.Fatalf("[username|true] Unexpected error %v", err) + } + if str := string(r2); str != auth.username { + t.Fatalf("[username|true] Expected %s, got %s", auth.username, str) + } + + // uSeRnAmE:|true + r3, err := auth.Next([]byte("uSeRnAmE:"), true) + if err != nil { + t.Fatalf("[uSeRnAmE|true] Unexpected error %v", err) + } + if str := string(r3); str != auth.username { + t.Fatalf("[uSeRnAmE|true] Expected %s, got %s", auth.username, str) + } + } + + // --------------------------------------------------------------- + + { + // password:|false + r1, err := auth.Next([]byte("password:"), false) + if err != nil { + t.Fatalf("[password|false] Unexpected error %v", err) + } + if len(r1) != 0 { + t.Fatalf("[password|false] Expected empty part, got %v", r1) + } + + // password:|true + r2, err := auth.Next([]byte("password:"), true) + if err != nil { + t.Fatalf("[password|true] Unexpected error %v", err) + } + if str := string(r2); str != auth.password { + t.Fatalf("[password|true] Expected %s, got %s", auth.password, str) + } + + // pAsSwOrD:|true + r3, err := auth.Next([]byte("pAsSwOrD:"), true) + if err != nil { + t.Fatalf("[pAsSwOrD|true] Unexpected error %v", err) + } + if str := string(r3); str != auth.password { + t.Fatalf("[pAsSwOrD|true] Expected %s, got %s", auth.password, str) + } + } +} diff --git a/server/core_backend-server/tools/osutils/cmd.go b/server/core_backend-server/tools/osutils/cmd.go new file mode 100644 index 0000000..d21f886 --- /dev/null +++ b/server/core_backend-server/tools/osutils/cmd.go @@ -0,0 +1,65 @@ +package osutils + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/go-ozzo/ozzo-validation/v4/is" +) + +// LaunchURL attempts to open the provided url in the user's default browser. +// +// It is platform dependent and it uses: +// - "open" on macOS +// - "rundll32" on Windows +// - "xdg-open" on everything else (Linux, FreeBSD, etc.) +func LaunchURL(url string) error { + if err := is.URL.Validate(url); err != nil { + return err + } + + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "windows": + // not sure if this is the best command but seems to be the most reliable based on the comments in + // https://stackoverflow.com/questions/3739327/launching-a-website-via-the-windows-commandline#answer-49115945 + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + return exec.Command("xdg-open", url).Start() + } +} + +// YesNoPrompt performs a console prompt that asks the user for Yes/No answer. +// +// If the user just press Enter (aka. doesn't type anything) it returns the fallback value. +func YesNoPrompt(message string, fallback bool) bool { + options := "Y/n" + if !fallback { + options = "y/N" + } + + r := bufio.NewReader(os.Stdin) + + var s string + for { + fmt.Fprintf(os.Stderr, "%s (%s) ", message, options) + + s, _ = r.ReadString('\n') + + s = strings.ToLower(strings.TrimSpace(s)) + + switch s { + case "": + return fallback + case "y", "yes": + return true + case "n", "no": + return false + } + } +} diff --git a/server/core_backend-server/tools/osutils/cmd_test.go b/server/core_backend-server/tools/osutils/cmd_test.go new file mode 100644 index 0000000..38beeab --- /dev/null +++ b/server/core_backend-server/tools/osutils/cmd_test.go @@ -0,0 +1,66 @@ +package osutils_test + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/osutils" +) + +func TestYesNoPrompt(t *testing.T) { + scenarios := []struct { + stdin string + fallback bool + expected bool + }{ + {"", false, false}, + {"", true, true}, + + // yes + {"y", false, true}, + {"Y", false, true}, + {"Yes", false, true}, + {"yes", false, true}, + + // no + {"n", true, false}, + {"N", true, false}, + {"No", true, false}, + {"no", true, false}, + + // invalid -> no/yes + {"invalid|no", true, false}, + {"invalid|yes", false, true}, + } + + for _, s := range scenarios { + t.Run(fmt.Sprintf("%s_%v", s.stdin, s.fallback), func(t *testing.T) { + stdinread, stdinwrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + parts := strings.Split(s.stdin, "|") + for _, p := range parts { + if _, err := stdinwrite.WriteString(p + "\n"); err != nil { + t.Fatalf("Failed to write test stdin part %q: %v", p, err) + } + } + + if err = stdinwrite.Close(); err != nil { + t.Fatal(err) + } + + defer func(oldStdin *os.File) { os.Stdin = oldStdin }(os.Stdin) + os.Stdin = stdinread + + result := osutils.YesNoPrompt("test", s.fallback) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} diff --git a/server/core_backend-server/tools/osutils/dir.go b/server/core_backend-server/tools/osutils/dir.go new file mode 100644 index 0000000..2d2c819 --- /dev/null +++ b/server/core_backend-server/tools/osutils/dir.go @@ -0,0 +1,79 @@ +package osutils + +import ( + "errors" + "os" + "path/filepath" + + "github.com/pocketbase/pocketbase/tools/list" +) + +// MoveDirContent moves the src dir content, that is not listed in the exclude list, +// to dest dir (it will be created if missing). +// +// The rootExclude argument is used to specify a list of src root entries to exclude. +// +// Note that this method doesn't delete the old src dir. +// +// It is an alternative to os.Rename() for the cases where we can't +// rename/delete the src dir (see https://github.com/pocketbase/pocketbase/issues/2519). +func MoveDirContent(src string, dest string, rootExclude ...string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + // make sure that the dest dir exist + manuallyCreatedDestDir := false + if _, err := os.Stat(dest); err != nil { + if err := os.Mkdir(dest, os.ModePerm); err != nil { + return err + } + manuallyCreatedDestDir = true + } + + moved := map[string]string{} + + tryRollback := func() []error { + errs := []error{} + + for old, new := range moved { + if err := os.Rename(new, old); err != nil { + errs = append(errs, err) + } + } + + // try to delete manually the created dest dir if all moved files were restored + if manuallyCreatedDestDir && len(errs) == 0 { + if err := os.Remove(dest); err != nil { + errs = append(errs, err) + } + } + + return errs + } + + for _, entry := range entries { + basename := entry.Name() + + if list.ExistInSlice(basename, rootExclude) { + continue + } + + old := filepath.Join(src, basename) + new := filepath.Join(dest, basename) + + if err := os.Rename(old, new); err != nil { + if errs := tryRollback(); len(errs) > 0 { + errs = append(errs, err) + err = errors.Join(errs...) + } + + return err + } + + moved[old] = new + } + + return nil +} diff --git a/server/core_backend-server/tools/osutils/dir_test.go b/server/core_backend-server/tools/osutils/dir_test.go new file mode 100644 index 0000000..f690e99 --- /dev/null +++ b/server/core_backend-server/tools/osutils/dir_test.go @@ -0,0 +1,142 @@ +package osutils_test + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/osutils" + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestMoveDirContent(t *testing.T) { + testDir := createTestDir(t) + defer os.RemoveAll(testDir) + + exclude := []string{ + "missing", + "test2", + "b", + } + + // missing dest path + // --- + dir1 := filepath.Join(filepath.Dir(testDir), "a", "b", "c", "d", "_pb_move_dir_content_test_"+security.PseudorandomString(4)) + defer os.RemoveAll(dir1) + + if err := osutils.MoveDirContent(testDir, dir1, exclude...); err == nil { + t.Fatal("Expected path error, got nil") + } + + // existing parent dir + // --- + dir2 := filepath.Join(filepath.Dir(testDir), "_pb_move_dir_content_test_"+security.PseudorandomString(4)) + defer os.RemoveAll(dir2) + + if err := osutils.MoveDirContent(testDir, dir2, exclude...); err != nil { + t.Fatalf("Expected dir2 to be created, got error: %v", err) + } + + // find all files + files := []string{} + filepath.WalkDir(dir2, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + files = append(files, path) + + return nil + }) + + expectedFiles := []string{ + filepath.Join(dir2, "test1"), + filepath.Join(dir2, "a", "a1"), + filepath.Join(dir2, "a", "a2"), + } + + if len(files) != len(expectedFiles) { + t.Fatalf("Expected %d files, got %d: \n%v", len(expectedFiles), len(files), files) + } + + for _, expected := range expectedFiles { + if !list.ExistInSlice(expected, files) { + t.Fatalf("Missing expected file %q in \n%v", expected, files) + } + } +} + +// ------------------------------------------------------------------- + +// note: make sure to call os.RemoveAll(dir) after you are done +// working with the created test dir. +func createTestDir(t *testing.T) string { + dir, err := os.MkdirTemp(os.TempDir(), "test_dir") + if err != nil { + t.Fatal(err) + } + + // create sub directories + if err := os.MkdirAll(filepath.Join(dir, "a"), os.ModePerm); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "b"), os.ModePerm); err != nil { + t.Fatal(err) + } + + { + f, err := os.OpenFile(filepath.Join(dir, "test1"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + f.Close() + } + + { + f, err := os.OpenFile(filepath.Join(dir, "test2"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + f.Close() + } + + { + f, err := os.OpenFile(filepath.Join(dir, "a/a1"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + f.Close() + } + + { + f, err := os.OpenFile(filepath.Join(dir, "a/a2"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + f.Close() + } + + { + f, err := os.OpenFile(filepath.Join(dir, "b/b2"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + f.Close() + } + + { + f, err := os.OpenFile(filepath.Join(dir, "b/b2"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + f.Close() + } + + return dir +} diff --git a/server/core_backend-server/tools/osutils/run.go b/server/core_backend-server/tools/osutils/run.go new file mode 100644 index 0000000..4253cb8 --- /dev/null +++ b/server/core_backend-server/tools/osutils/run.go @@ -0,0 +1,32 @@ +package osutils + +import ( + "os" + "strings" +) + +var runDirs = []string{os.TempDir(), cacheDir()} + +// IsProbablyGoRun loosely checks if the current program was started with "go run". +func IsProbablyGoRun() bool { + for _, dir := range runDirs { + if dir != "" && strings.HasPrefix(os.Args[0], dir) { + return true + } + } + + return false +} + +func cacheDir() string { + dir := os.Getenv("GOCACHE") + if dir == "off" { + return "" + } + + if dir == "" { + dir, _ = os.UserCacheDir() + } + + return dir +} diff --git a/server/core_backend-server/tools/osutils/run_test.go b/server/core_backend-server/tools/osutils/run_test.go new file mode 100644 index 0000000..c726556 --- /dev/null +++ b/server/core_backend-server/tools/osutils/run_test.go @@ -0,0 +1,45 @@ +package osutils + +import ( + "os" + "strconv" + "testing" +) + +func TestIsProbablyGoRun(t *testing.T) { + scenarios := []struct { + arg0 string + runDirs []string + expected bool + }{ + {"", nil, false}, + {"/a/b", nil, false}, + {"/a/b", []string{""}, false}, + {"/a/b", []string{"/b/"}, false}, + {"/a/b", []string{"/a/"}, true}, + {"/a/b", []string{"", "/b/", "/a/"}, true}, + } + + originalArgs := os.Args + defer func() { + os.Args = originalArgs + }() + + originalRunDirs := runDirs + defer func() { + runDirs = originalRunDirs + }() + + for i, s := range scenarios { + t.Run(strconv.Itoa(i)+"_"+s.arg0, func(t *testing.T) { + os.Args = []string{s.arg0} + runDirs = s.runDirs + + result := IsProbablyGoRun() + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} diff --git a/server/core_backend-server/tools/picker/excerpt_modifier.go b/server/core_backend-server/tools/picker/excerpt_modifier.go new file mode 100644 index 0000000..a60c842 --- /dev/null +++ b/server/core_backend-server/tools/picker/excerpt_modifier.go @@ -0,0 +1,150 @@ +package picker + +import ( + "errors" + "regexp" + "strings" + + "github.com/pocketbase/pocketbase/tools/list" + "github.com/spf13/cast" + "golang.org/x/net/html" +) + +func init() { + Modifiers["excerpt"] = func(args ...string) (Modifier, error) { + return newExcerptModifier(args...) + } +} + +var whitespaceRegex = regexp.MustCompile(`\s+`) + +var excludeTags = []string{ + "head", "style", "script", "iframe", "embed", "applet", "object", + "svg", "img", "picture", "dialog", "template", "button", "form", + "textarea", "input", "select", "option", +} + +var inlineTags = []string{ + "a", "abbr", "acronym", "b", "bdo", "big", "br", "button", + "cite", "code", "em", "i", "label", "q", "small", "span", + "strong", "strike", "sub", "sup", "time", +} + +var _ Modifier = (*excerptModifier)(nil) + +type excerptModifier struct { + max int // approximate max excerpt length + withEllipsis bool // if enabled will add ellipsis when the plain text length > max +} + +// newExcerptModifier validates the specified raw string arguments and +// initializes a new excerptModifier. +// +// This method is usually invoked in initModifer(). +func newExcerptModifier(args ...string) (*excerptModifier, error) { + totalArgs := len(args) + + if totalArgs == 0 { + return nil, errors.New("max argument is required - expected (max, withEllipsis?)") + } + + if totalArgs > 2 { + return nil, errors.New("too many arguments - expected (max, withEllipsis?)") + } + + max := cast.ToInt(args[0]) + if max == 0 { + return nil, errors.New("max argument must be > 0") + } + + var withEllipsis bool + if totalArgs > 1 { + withEllipsis = cast.ToBool(args[1]) + } + + return &excerptModifier{max, withEllipsis}, nil +} + +// Modify implements the [Modifier.Modify] interface method. +// +// It returns a plain text excerpt/short-description from a formatted +// html string (non-string values are kept untouched). +func (m *excerptModifier) Modify(value any) (any, error) { + strValue, ok := value.(string) + if !ok { + // not a string -> return as it is without applying the modifier + // (we don't throw an error because the modifier could be applied for a missing expand field) + return value, nil + } + + var builder strings.Builder + + doc, err := html.Parse(strings.NewReader(strValue)) + if err != nil { + return "", err + } + + var hasPrevSpace bool + + // for all node types and more details check + // https://pkg.go.dev/golang.org/x/net/html#Parse + var stripTags func(*html.Node) + stripTags = func(n *html.Node) { + switch n.Type { + case html.TextNode: + // collapse multiple spaces into one + txt := whitespaceRegex.ReplaceAllString(n.Data, " ") + + if hasPrevSpace { + txt = strings.TrimLeft(txt, " ") + } + + if txt != "" { + hasPrevSpace = strings.HasSuffix(txt, " ") + + builder.WriteString(txt) + } + } + + // excerpt max has been reached => no need to further iterate + // (+2 for the extra whitespace suffix/prefix that will be trimmed later) + if builder.Len() > m.max+2 { + return + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type != html.ElementNode || !list.ExistInSlice(c.Data, excludeTags) { + isBlock := c.Type == html.ElementNode && !list.ExistInSlice(c.Data, inlineTags) + + if isBlock && !hasPrevSpace { + builder.WriteString(" ") + hasPrevSpace = true + } + + stripTags(c) + + if isBlock && !hasPrevSpace { + builder.WriteString(" ") + hasPrevSpace = true + } + } + } + } + stripTags(doc) + + result := strings.TrimSpace(builder.String()) + + if len(result) > m.max { + // note: casted to []rune to properly account for multi-byte chars + runes := []rune(result) + if len(runes) > m.max { + result = string(runes[:m.max]) + result = strings.TrimSpace(result) + if m.withEllipsis { + result += "..." + } + } + } + + return result, nil +} diff --git a/server/core_backend-server/tools/picker/excerpt_modifier_test.go b/server/core_backend-server/tools/picker/excerpt_modifier_test.go new file mode 100644 index 0000000..7e5fe70 --- /dev/null +++ b/server/core_backend-server/tools/picker/excerpt_modifier_test.go @@ -0,0 +1,170 @@ +package picker + +import ( + "fmt" + "testing" + + "github.com/spf13/cast" +) + +func TestNewExcerptModifier(t *testing.T) { + scenarios := []struct { + name string + args []string + expectError bool + }{ + { + "no arguments", + nil, + true, + }, + { + "too many arguments", + []string{"12", "false", "something"}, + true, + }, + { + "non-numeric max argument", + []string{"something"}, // should fallback to 0 which is not allowed + true, + }, + { + "numeric max argument", + []string{"12"}, + false, + }, + { + "non-bool withEllipsis argument", + []string{"12", "something"}, // should fallback to false which is allowed + false, + }, + { + "truthy withEllipsis argument", + []string{"12", "t"}, + false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + m, err := newExcerptModifier(s.args...) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + if m != nil { + t.Fatalf("Expected nil modifier, got %v", m) + } + + return + } + + var argMax int + if len(s.args) > 0 { + argMax = cast.ToInt(s.args[0]) + } + + var argWithEllipsis bool + if len(s.args) > 1 { + argWithEllipsis = cast.ToBool(s.args[1]) + } + + if m.max != argMax { + t.Fatalf("Expected max %d, got %d", argMax, m.max) + } + + if m.withEllipsis != argWithEllipsis { + t.Fatalf("Expected withEllipsis %v, got %v", argWithEllipsis, m.withEllipsis) + } + }) + } +} + +func TestExcerptModifierModify(t *testing.T) { + html := `

Hello

t est12 + 3456
word 7 89!? a b c#

title

` + + plainText := "Hello t est12 3456 word 7 89!? a b c# title" + + scenarios := []struct { + name string + args []string + value string + expected string + }{ + // without ellipsis + { + "only max < len(plainText)", + []string{"2"}, + html, + plainText[:2], + }, + { + "only max = len(plainText)", + []string{fmt.Sprint(len(plainText))}, + html, + plainText, + }, + { + "only max > len(plainText)", + []string{fmt.Sprint(len(plainText) + 5)}, + html, + plainText, + }, + + // with ellipsis + { + "with ellipsis and max < len(plainText)", + []string{"2", "t"}, + html, + plainText[:2] + "...", + }, + { + "with ellipsis and max = len(plainText)", + []string{fmt.Sprint(len(plainText)), "t"}, + html, + plainText, + }, + { + "with ellipsis and max > len(plainText)", + []string{fmt.Sprint(len(plainText) + 5), "t"}, + html, + plainText, + }, + + // multibyte chars + { + "mutibyte chars <= max", + []string{"4", "t"}, + "ะฐะฑ\nะฒ ", + "ะฐะฑ ะฒ", + }, + { + "mutibyte chars > max", + []string{"3", "t"}, + "ะฐะฑ\nะฒ ", + "ะฐะฑ...", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + m, err := newExcerptModifier(s.args...) + if err != nil { + t.Fatal(err) + } + + raw, err := m.Modify(s.value) + if err != nil { + t.Fatal(err) + } + + if v := cast.ToString(raw); v != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, v) + } + }) + } +} diff --git a/server/core_backend-server/tools/picker/modifiers.go b/server/core_backend-server/tools/picker/modifiers.go new file mode 100644 index 0000000..6572e0c --- /dev/null +++ b/server/core_backend-server/tools/picker/modifiers.go @@ -0,0 +1,41 @@ +package picker + +import ( + "fmt" + + "github.com/pocketbase/pocketbase/tools/tokenizer" +) + +var Modifiers = map[string]ModifierFactoryFunc{} + +type ModifierFactoryFunc func(args ...string) (Modifier, error) + +type Modifier interface { + // Modify executes the modifier and returns a new modified value. + Modify(value any) (any, error) +} + +func initModifer(rawModifier string) (Modifier, error) { + t := tokenizer.NewFromString(rawModifier) + t.Separators('(', ')', ',', ' ') + t.IgnoreParenthesis(true) + + parts, err := t.ScanAll() + if err != nil { + return nil, err + } + + if len(parts) == 0 { + return nil, fmt.Errorf("invalid or empty modifier expression %q", rawModifier) + } + + name := parts[0] + args := parts[1:] + + factory, ok := Modifiers[name] + if !ok { + return nil, fmt.Errorf("missing or invalid modifier %q", name) + } + + return factory(args...) +} diff --git a/server/core_backend-server/tools/picker/pick.go b/server/core_backend-server/tools/picker/pick.go new file mode 100644 index 0000000..2cd23aa --- /dev/null +++ b/server/core_backend-server/tools/picker/pick.go @@ -0,0 +1,184 @@ +package picker + +import ( + "encoding/json" + "strings" + + "github.com/pocketbase/pocketbase/tools/search" + "github.com/pocketbase/pocketbase/tools/tokenizer" +) + +// Pick converts data into a []any, map[string]any, etc. (using json marshal->unmarshal) +// containing only the fields from the parsed rawFields expression. +// +// rawFields is a comma separated string of the fields to include. +// Nested fields should be listed with dot-notation. +// Fields value modifiers are also supported using the `:modifier(args)` format (see Modifiers). +// +// Example: +// +// data := map[string]any{"a": 1, "b": 2, "c": map[string]any{"c1": 11, "c2": 22}} +// Pick(data, "a,c.c1") // map[string]any{"a": 1, "c": map[string]any{"c1": 11}} +func Pick(data any, rawFields string) (any, error) { + parsedFields, err := parseFields(rawFields) + if err != nil { + return nil, err + } + + // marshalize the provided data to ensure that the related json.Marshaler + // implementations are invoked, and then convert it back to a plain + // json value that we can further operate on. + // + // @todo research other approaches to avoid the double serialization + // --- + encoded, err := json.Marshal(data) + if err != nil { + return nil, err + } + + var decoded any + if err := json.Unmarshal(encoded, &decoded); err != nil { + return nil, err + } + // --- + + // special cases to preserve the same fields format when used with single item or search results data. + var isSearchResult bool + switch data.(type) { + case search.Result, *search.Result: + isSearchResult = true + } + + if isSearchResult { + if decodedMap, ok := decoded.(map[string]any); ok { + pickParsedFields(decodedMap["items"], parsedFields) + } + } else { + pickParsedFields(decoded, parsedFields) + } + + return decoded, nil +} + +func parseFields(rawFields string) (map[string]Modifier, error) { + t := tokenizer.NewFromString(rawFields) + + fields, err := t.ScanAll() + if err != nil { + return nil, err + } + + result := make(map[string]Modifier, len(fields)) + + for _, f := range fields { + parts := strings.SplitN(strings.TrimSpace(f), ":", 2) + + if len(parts) > 1 { + m, err := initModifer(parts[1]) + if err != nil { + return nil, err + } + result[parts[0]] = m + } else { + result[parts[0]] = nil + } + } + + return result, nil +} + +func pickParsedFields(data any, fields map[string]Modifier) error { + switch v := data.(type) { + case map[string]any: + pickMapFields(v, fields) + case []map[string]any: + for _, item := range v { + if err := pickMapFields(item, fields); err != nil { + return err + } + } + case []any: + if len(v) == 0 { + return nil // nothing to pick + } + + if _, ok := v[0].(map[string]any); !ok { + return nil // for now ignore non-map values + } + + for _, item := range v { + if err := pickMapFields(item.(map[string]any), fields); err != nil { + return nil + } + } + } + + return nil +} + +func pickMapFields(data map[string]any, fields map[string]Modifier) error { + if len(fields) == 0 { + return nil // nothing to pick + } + + if m, ok := fields["*"]; ok { + // append all missing root level data keys + for k := range data { + var exists bool + + for f := range fields { + if strings.HasPrefix(f+".", k+".") { + exists = true + break + } + } + + if !exists { + fields[k] = m + } + } + } + +DataLoop: + for k := range data { + matchingFields := make(map[string]Modifier, len(fields)) + for f, m := range fields { + if strings.HasPrefix(f+".", k+".") { + matchingFields[f] = m + continue + } + } + + if len(matchingFields) == 0 { + delete(data, k) + continue DataLoop + } + + // remove the current key from the matching fields path + for f, m := range matchingFields { + remains := strings.TrimSuffix(strings.TrimPrefix(f+".", k+"."), ".") + + // final key + if remains == "" { + if m != nil { + var err error + data[k], err = m.Modify(data[k]) + if err != nil { + return err + } + } + continue DataLoop + } + + // cleanup the old field key and continue with the rest of the field path + delete(matchingFields, f) + matchingFields[remains] = m + } + + if err := pickParsedFields(data[k], matchingFields); err != nil { + return err + } + } + + return nil +} diff --git a/server/core_backend-server/tools/picker/pick_test.go b/server/core_backend-server/tools/picker/pick_test.go new file mode 100644 index 0000000..95add58 --- /dev/null +++ b/server/core_backend-server/tools/picker/pick_test.go @@ -0,0 +1,276 @@ +package picker_test + +import ( + "encoding/json" + "testing" + + "github.com/pocketbase/pocketbase/tools/picker" + "github.com/pocketbase/pocketbase/tools/search" +) + +func TestPickFields(t *testing.T) { + scenarios := []struct { + name string + data any + fields string + expectError bool + result string + }{ + { + "empty fields", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "", + false, + `{"a":1,"b":2,"c":"test"}`, + }, + { + "missing fields", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "missing", + false, + `{}`, + }, + { + "non map data", + "test", + "a,b,test", + false, + `"test"`, + }, + { + "non slice of map data", + []any{"a", "b", "test"}, + "a,test", + false, + `["a","b","test"]`, + }, + { + "map with no matching field", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "missing", // test individual fields trim + false, + `{}`, + }, + { + "map with existing and missing fields", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "a, c ,missing", // test individual fields trim + false, + `{"a":1,"c":"test"}`, + }, + { + "slice of maps with existing and missing fields", + []any{ + map[string]any{"a": 11, "b": 11, "c": "test1"}, + map[string]any{"a": 22, "b": 22, "c": "test2"}, + }, + "a, c ,missing", // test individual fields trim + false, + `[{"a":11,"c":"test1"},{"a":22,"c":"test2"}]`, + }, + { + "nested fields with mixed map and any slices", + map[string]any{ + "a": 1, + "b": 2, + "c": "test", + "anySlice": []any{ + map[string]any{ + "A": []int{1, 2, 3}, + "B": []any{"1", "2", 3}, + "C": "test", + "D": map[string]any{ + "DA": 1, + "DB": 2, + }, + }, + map[string]any{ + "A": "test", + }, + }, + "mapSlice": []map[string]any{ + { + "A": []int{1, 2, 3}, + "B": []any{"1", "2", 3}, + "C": "test", + "D": []any{ + map[string]any{"DA": 1}, + }, + }, + { + "B": []any{"1", "2", 3}, + "D": []any{ + map[string]any{"DA": 2}, + map[string]any{"DA": 3}, + map[string]any{"DB": 4}, // will result to empty since there is no DA + }, + }, + }, + "fullMap": []map[string]any{ + { + "A": []int{1, 2, 3}, + "B": []any{"1", "2", 3}, + "C": "test", + }, + { + "B": []any{"1", "2", 3}, + "D": []any{ + map[string]any{"DA": 2}, + map[string]any{"DA": 3}, // will result to empty since there is no DA + }, + }, + }, + }, + "a, c, anySlice.A, mapSlice.C, mapSlice.D.DA, anySlice.D,fullMap", + false, + `{"a":1,"anySlice":[{"A":[1,2,3],"D":{"DA":1,"DB":2}},{"A":"test"}],"c":"test","fullMap":[{"A":[1,2,3],"B":["1","2",3],"C":"test"},{"B":["1","2",3],"D":[{"DA":2},{"DA":3}]}],"mapSlice":[{"C":"test","D":[{"DA":1}]},{"D":[{"DA":2},{"DA":3},{}]}]}`, + }, + { + "SearchResult", + search.Result{ + Page: 1, + PerPage: 10, + TotalItems: 20, + TotalPages: 30, + Items: []any{ + map[string]any{"a": 11, "b": 11, "c": "test1"}, + map[string]any{"a": 22, "b": 22, "c": "test2"}, + }, + }, + "a,c,missing", + false, + `{"items":[{"a":11,"c":"test1"},{"a":22,"c":"test2"}],"page":1,"perPage":10,"totalItems":20,"totalPages":30}`, + }, + { + "*SearchResult", + &search.Result{ + Page: 1, + PerPage: 10, + TotalItems: 20, + TotalPages: 30, + Items: []any{ + map[string]any{"a": 11, "b": 11, "c": "test1"}, + map[string]any{"a": 22, "b": 22, "c": "test2"}, + }, + }, + "a,c", + false, + `{"items":[{"a":11,"c":"test1"},{"a":22,"c":"test2"}],"page":1,"perPage":10,"totalItems":20,"totalPages":30}`, + }, + { + "root wildcard", + &search.Result{ + Page: 1, + PerPage: 10, + TotalItems: 20, + TotalPages: 30, + Items: []any{ + map[string]any{"a": 11, "b": 11, "c": "test1"}, + map[string]any{"a": 22, "b": 22, "c": "test2"}, + }, + }, + "*", + false, + `{"items":[{"a":11,"b":11,"c":"test1"},{"a":22,"b":22,"c":"test2"}],"page":1,"perPage":10,"totalItems":20,"totalPages":30}`, + }, + { + "root wildcard with nested exception", + map[string]any{ + "id": "123", + "title": "lorem", + "rel": map[string]any{ + "id": "456", + "title": "rel_title", + }, + }, + "*,rel.id", + false, + `{"id":"123","rel":{"id":"456"},"title":"lorem"}`, + }, + { + "sub wildcard", + map[string]any{ + "id": "123", + "title": "lorem", + "rel": map[string]any{ + "id": "456", + "title": "rel_title", + "sub": map[string]any{ + "id": "789", + "title": "sub_title", + }, + }, + }, + "id,rel.*", + false, + `{"id":"123","rel":{"id":"456","sub":{"id":"789","title":"sub_title"},"title":"rel_title"}}`, + }, + { + "sub wildcard with nested exception", + map[string]any{ + "id": "123", + "title": "lorem", + "rel": map[string]any{ + "id": "456", + "title": "rel_title", + "sub": map[string]any{ + "id": "789", + "title": "sub_title", + }, + }, + }, + "id,rel.*,rel.sub.id", + false, + `{"id":"123","rel":{"id":"456","sub":{"id":"789"},"title":"rel_title"}}`, + }, + { + "invalid excerpt modifier", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "*:excerpt", + true, + `{"a":1,"b":2,"c":"test"}`, + }, + { + "valid excerpt modifier", + map[string]any{ + "id": "123", + "title": "lorem", + "rel": map[string]any{ + "id": "456", + "title": "

rel_title

", + "sub": map[string]any{ + "id": "789", + "title": "sub_title", + }, + }, + }, + "*:excerpt(2),rel.title:excerpt(3, true)", + false, + `{"id":"12","rel":{"title":"rel..."},"title":"lo"}`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, err := picker.Pick(s.data, s.fields) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + serialized, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + + if v := string(serialized); v != s.result { + t.Fatalf("Expected body\n%s \ngot \n%s", s.result, v) + } + }) + } +} diff --git a/server/core_backend-server/tools/router/error.go b/server/core_backend-server/tools/router/error.go new file mode 100644 index 0000000..2c437cd --- /dev/null +++ b/server/core_backend-server/tools/router/error.go @@ -0,0 +1,231 @@ +package router + +import ( + "database/sql" + "errors" + "io/fs" + "net/http" + "strings" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/tools/inflector" +) + +// SafeErrorItem defines a common error interface for a printable public safe error. +type SafeErrorItem interface { + // Code represents a fixed unique identifier of the error (usually used as translation key). + Code() string + + // Error is the default English human readable error message that will be returned. + Error() string +} + +// SafeErrorParamsResolver defines an optional interface for specifying dynamic error parameters. +type SafeErrorParamsResolver interface { + // Params defines a map with dynamic parameters to return as part of the public safe error view. + Params() map[string]any +} + +// SafeErrorResolver defines an error interface for resolving the public safe error fields. +type SafeErrorResolver interface { + // Resolve allows modifying and returning a new public safe error data map. + Resolve(errData map[string]any) any +} + +// ApiError defines the struct for a basic api error response. +type ApiError struct { + rawData any + + Data map[string]any `json:"data"` + Message string `json:"message"` + Status int `json:"status"` +} + +// Error makes it compatible with the `error` interface. +func (e *ApiError) Error() string { + return e.Message +} + +// RawData returns the unformatted error data (could be an internal error, text, etc.) +func (e *ApiError) RawData() any { + return e.rawData +} + +// Is reports whether the current ApiError wraps the target. +func (e *ApiError) Is(target error) bool { + err, ok := e.rawData.(error) + if ok { + return errors.Is(err, target) + } + + apiErr, ok := target.(*ApiError) + + return ok && e == apiErr +} + +// NewNotFoundError creates and returns 404 ApiError. +func NewNotFoundError(message string, rawErrData any) *ApiError { + if message == "" { + message = "The requested resource wasn't found." + } + + return NewApiError(http.StatusNotFound, message, rawErrData) +} + +// NewBadRequestError creates and returns 400 ApiError. +func NewBadRequestError(message string, rawErrData any) *ApiError { + if message == "" { + message = "Something went wrong while processing your request." + } + + return NewApiError(http.StatusBadRequest, message, rawErrData) +} + +// NewForbiddenError creates and returns 403 ApiError. +func NewForbiddenError(message string, rawErrData any) *ApiError { + if message == "" { + message = "You are not allowed to perform this request." + } + + return NewApiError(http.StatusForbidden, message, rawErrData) +} + +// NewUnauthorizedError creates and returns 401 ApiError. +func NewUnauthorizedError(message string, rawErrData any) *ApiError { + if message == "" { + message = "Missing or invalid authentication." + } + + return NewApiError(http.StatusUnauthorized, message, rawErrData) +} + +// NewInternalServerError creates and returns 500 ApiError. +func NewInternalServerError(message string, rawErrData any) *ApiError { + if message == "" { + message = "Something went wrong while processing your request." + } + + return NewApiError(http.StatusInternalServerError, message, rawErrData) +} + +func NewTooManyRequestsError(message string, rawErrData any) *ApiError { + if message == "" { + message = "Too Many Requests." + } + + return NewApiError(http.StatusTooManyRequests, message, rawErrData) +} + +// NewApiError creates and returns new normalized ApiError instance. +func NewApiError(status int, message string, rawErrData any) *ApiError { + if message == "" { + message = http.StatusText(status) + } + + return &ApiError{ + rawData: rawErrData, + Data: safeErrorsData(rawErrData), + Status: status, + Message: strings.TrimSpace(inflector.Sentenize(message)), + } +} + +// ToApiError wraps err into ApiError instance (if not already). +func ToApiError(err error) *ApiError { + var apiErr *ApiError + + if !errors.As(err, &apiErr) { + // no ApiError found -> assign a generic one + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, fs.ErrNotExist) { + apiErr = NewNotFoundError("", err) + } else { + apiErr = NewBadRequestError("", err) + } + } + + return apiErr +} + +// ------------------------------------------------------------------- + +func safeErrorsData(data any) map[string]any { + switch v := data.(type) { + case validation.Errors: + return resolveSafeErrorsData(v) + case error: + validationErrors := validation.Errors{} + if errors.As(v, &validationErrors) { + return resolveSafeErrorsData(validationErrors) + } + return map[string]any{} // not nil to ensure that is json serialized as object + case map[string]validation.Error: + return resolveSafeErrorsData(v) + case map[string]SafeErrorItem: + return resolveSafeErrorsData(v) + case map[string]error: + return resolveSafeErrorsData(v) + case map[string]string: + return resolveSafeErrorsData(v) + case map[string]any: + return resolveSafeErrorsData(v) + default: + return map[string]any{} // not nil to ensure that is json serialized as object + } +} + +func resolveSafeErrorsData[T any](data map[string]T) map[string]any { + result := map[string]any{} + + for name, err := range data { + if isNestedError(err) { + result[name] = safeErrorsData(err) + } else { + result[name] = resolveSafeErrorItem(err) + } + } + + return result +} + +func isNestedError(err any) bool { + switch err.(type) { + case validation.Errors, + map[string]validation.Error, + map[string]SafeErrorItem, + map[string]error, + map[string]string, + map[string]any: + return true + } + + return false +} + +// resolveSafeErrorItem extracts from each validation error its +// public safe error code and message. +func resolveSafeErrorItem(err any) any { + data := map[string]any{} + + if obj, ok := err.(SafeErrorItem); ok { + // extract the specific error code and message + data["code"] = obj.Code() + data["message"] = inflector.Sentenize(obj.Error()) + } else { + // fallback to the default public safe values + data["code"] = "validation_invalid_value" + data["message"] = "Invalid value." + } + + if s, ok := err.(SafeErrorParamsResolver); ok { + params := s.Params() + if len(params) > 0 { + data["params"] = params + } + } + + if s, ok := err.(SafeErrorResolver); ok { + return s.Resolve(data) + } + + return data +} diff --git a/server/core_backend-server/tools/router/error_test.go b/server/core_backend-server/tools/router/error_test.go new file mode 100644 index 0000000..8ced893 --- /dev/null +++ b/server/core_backend-server/tools/router/error_test.go @@ -0,0 +1,358 @@ +package router_test + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io/fs" + "strconv" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/tools/router" +) + +func TestNewApiErrorWithRawData(t *testing.T) { + t.Parallel() + + e := router.NewApiError( + 300, + "message_test", + "rawData_test", + ) + + result, _ := json.Marshal(e) + expected := `{"data":{},"message":"Message_test.","status":300}` + + if string(result) != expected { + t.Errorf("Expected\n%v\ngot\n%v", expected, string(result)) + } + + if e.Error() != "Message_test." { + t.Errorf("Expected %q, got %q", "Message_test.", e.Error()) + } + + if e.RawData() != "rawData_test" { + t.Errorf("Expected rawData\n%v\ngot\n%v", "rawData_test", e.RawData()) + } +} + +func TestNewApiErrorWithValidationData(t *testing.T) { + t.Parallel() + + e := router.NewApiError( + 300, + "message_test", + map[string]any{ + "err1": errors.New("test error"), // should be normalized + "err2": validation.ErrRequired, + "err3": validation.Errors{ + "err3.1": errors.New("test error"), // should be normalized + "err3.2": validation.ErrRequired, + "err3.3": validation.Errors{ + "err3.3.1": validation.ErrRequired, + }, + }, + "err4": &mockSafeErrorItem{}, + "err5": map[string]error{ + "err5.1": validation.ErrRequired, + }, + }, + ) + + result, _ := json.Marshal(e) + expected := `{"data":{"err1":{"code":"validation_invalid_value","message":"Invalid value."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"err3.1":{"code":"validation_invalid_value","message":"Invalid value."},"err3.2":{"code":"validation_required","message":"Cannot be blank."},"err3.3":{"err3.3.1":{"code":"validation_required","message":"Cannot be blank."}}},"err4":{"code":"mock_code","message":"Mock_error.","mock_resolve":123},"err5":{"err5.1":{"code":"validation_required","message":"Cannot be blank."}}},"message":"Message_test.","status":300}` + + if string(result) != expected { + t.Errorf("Expected \n%v, \ngot \n%v", expected, string(result)) + } + + if e.Error() != "Message_test." { + t.Errorf("Expected %q, got %q", "Message_test.", e.Error()) + } + + if e.RawData() == nil { + t.Error("Expected non-nil rawData") + } +} + +func TestNewNotFoundError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"The requested resource wasn't found.","status":404}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":404}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":404}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewNotFoundError(s.message, s.data) + result, _ := json.Marshal(e) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewBadRequestError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"Something went wrong while processing your request.","status":400}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":400}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":400}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewBadRequestError(s.message, s.data) + result, _ := json.Marshal(e) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewForbiddenError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"You are not allowed to perform this request.","status":403}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":403}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":403}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewForbiddenError(s.message, s.data) + result, _ := json.Marshal(e) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewUnauthorizedError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"Missing or invalid authentication.","status":401}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":401}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":401}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewUnauthorizedError(s.message, s.data) + result, _ := json.Marshal(e) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewInternalServerError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"Something went wrong while processing your request.","status":500}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":500}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":500}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewInternalServerError(s.message, s.data) + result, _ := json.Marshal(e) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewTooManyRequestsError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"Too Many Requests.","status":429}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":429}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message").SetParams(map[string]any{"test": 123})}, `{"data":{"err1":{"code":"test_code","message":"Test_message.","params":{"test":123}}},"message":"Demo.","status":429}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewTooManyRequestsError(s.message, s.data) + result, _ := json.Marshal(e) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestApiErrorIs(t *testing.T) { + t.Parallel() + + err0 := router.NewInternalServerError("", nil) + err1 := router.NewInternalServerError("", nil) + err2 := errors.New("test") + err3 := fmt.Errorf("wrapped: %w", err0) + + scenarios := []struct { + name string + err error + target error + expected bool + }{ + { + "nil error", + err0, + nil, + false, + }, + { + "non ApiError", + err0, + err1, + false, + }, + { + "different ApiError", + err0, + err2, + false, + }, + { + "same ApiError", + err0, + err0, + true, + }, + { + "wrapped ApiError", + err3, + err0, + true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + is := errors.Is(s.err, s.target) + + if is != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, is) + } + }) + } +} + +func TestToApiError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + name string + err error + expected string + }{ + { + "regular error", + errors.New("test"), + `{"data":{},"message":"Something went wrong while processing your request.","status":400}`, + }, + { + "fs.ErrNotExist", + fs.ErrNotExist, + `{"data":{},"message":"The requested resource wasn't found.","status":404}`, + }, + { + "sql.ErrNoRows", + sql.ErrNoRows, + `{"data":{},"message":"The requested resource wasn't found.","status":404}`, + }, + { + "ApiError", + router.NewForbiddenError("test", nil), + `{"data":{},"message":"Test.","status":403}`, + }, + { + "wrapped ApiError", + fmt.Errorf("wrapped: %w", router.NewForbiddenError("test", nil)), + `{"data":{},"message":"Test.","status":403}`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + raw, err := json.Marshal(router.ToApiError(s.err)) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) + + if rawStr != s.expected { + t.Fatalf("Expected error\n%vgot\n%v", s.expected, rawStr) + } + }) + } +} + +// ------------------------------------------------------------------- + +var ( + _ router.SafeErrorItem = (*mockSafeErrorItem)(nil) + _ router.SafeErrorResolver = (*mockSafeErrorItem)(nil) +) + +type mockSafeErrorItem struct { +} + +func (m *mockSafeErrorItem) Code() string { + return "mock_code" +} + +func (m *mockSafeErrorItem) Error() string { + return "mock_error" +} + +func (m *mockSafeErrorItem) Resolve(errData map[string]any) any { + errData["mock_resolve"] = 123 + return errData +} diff --git a/server/core_backend-server/tools/router/event.go b/server/core_backend-server/tools/router/event.go new file mode 100644 index 0000000..a822c59 --- /dev/null +++ b/server/core_backend-server/tools/router/event.go @@ -0,0 +1,398 @@ +package router + +import ( + "encoding/json" + "encoding/xml" + "errors" + "io" + "io/fs" + "net" + "net/http" + "net/netip" + "path/filepath" + "strings" + + "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/hook" + "github.com/pocketbase/pocketbase/tools/picker" + "github.com/pocketbase/pocketbase/tools/store" +) + +var ErrUnsupportedContentType = NewBadRequestError("Unsupported Content-Type", nil) +var ErrInvalidRedirectStatusCode = NewInternalServerError("Invalid redirect status code", nil) +var ErrFileNotFound = NewNotFoundError("File not found", nil) + +const IndexPage = "index.html" + +// Event specifies based Route handler event that is usually intended +// to be embedded as part of a custom event struct. +// +// NB! It is expected that the Response and Request fields are always set. +type Event struct { + Response http.ResponseWriter + Request *http.Request + + hook.Event + + data store.Store[string, any] +} + +// RWUnwrapper specifies that an http.ResponseWriter could be "unwrapped" +// (usually used with [http.ResponseController]). +type RWUnwrapper interface { + Unwrap() http.ResponseWriter +} + +// Written reports whether the current response has already been written. +// +// This method always returns false if e.ResponseWritter doesn't implement the WriteTracker interface +// (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one). +func (e *Event) Written() bool { + written, _ := getWritten(e.Response) + return written +} + +// Status reports the status code of the current response. +// +// This method always returns 0 if e.Response doesn't implement the StatusTracker interface +// (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one). +func (e *Event) Status() int { + status, _ := getStatus(e.Response) + return status +} + +// Flush flushes buffered data to the current response. +// +// Returns [http.ErrNotSupported] if e.Response doesn't implement the [http.Flusher] interface +// (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one). +func (e *Event) Flush() error { + return http.NewResponseController(e.Response).Flush() +} + +// IsTLS reports whether the connection on which the request was received is TLS. +func (e *Event) IsTLS() bool { + return e.Request.TLS != nil +} + +// SetCookie is an alias for [http.SetCookie]. +// +// SetCookie adds a Set-Cookie header to the current response's headers. +// The provided cookie must have a valid Name. +// Invalid cookies may be silently dropped. +func (e *Event) SetCookie(cookie *http.Cookie) { + http.SetCookie(e.Response, cookie) +} + +// RemoteIP returns the IP address of the client that sent the request. +// +// IPv6 addresses are returned expanded. +// For example, "2001:db8::1" becomes "2001:0db8:0000:0000:0000:0000:0000:0001". +// +// Note that if you are behind reverse proxy(ies), this method returns +// the IP of the last connecting proxy. +func (e *Event) RemoteIP() string { + ip, _, _ := net.SplitHostPort(e.Request.RemoteAddr) + parsed, _ := netip.ParseAddr(ip) + return parsed.StringExpanded() +} + +// FindUploadedFiles extracts all form files of "key" from a http request +// and returns a slice with filesystem.File instances (if any). +func (e *Event) FindUploadedFiles(key string) ([]*filesystem.File, error) { + if e.Request.MultipartForm == nil { + err := e.Request.ParseMultipartForm(DefaultMaxMemory) + if err != nil { + return nil, err + } + } + + if e.Request.MultipartForm == nil || e.Request.MultipartForm.File == nil || len(e.Request.MultipartForm.File[key]) == 0 { + return nil, http.ErrMissingFile + } + + result := make([]*filesystem.File, 0, len(e.Request.MultipartForm.File[key])) + + for _, fh := range e.Request.MultipartForm.File[key] { + file, err := filesystem.NewFileFromMultipart(fh) + if err != nil { + return nil, err + } + + result = append(result, file) + } + + return result, nil +} + +// Store +// ------------------------------------------------------------------- + +// Get retrieves single value from the current event data store. +func (e *Event) Get(key string) any { + return e.data.Get(key) +} + +// GetAll returns a copy of the current event data store. +func (e *Event) GetAll() map[string]any { + return e.data.GetAll() +} + +// Set saves single value into the current event data store. +func (e *Event) Set(key string, value any) { + e.data.Set(key, value) +} + +// SetAll saves all items from m into the current event data store. +func (e *Event) SetAll(m map[string]any) { + for k, v := range m { + e.Set(k, v) + } +} + +// Response writers +// ------------------------------------------------------------------- + +const headerContentType = "Content-Type" + +func (e *Event) setResponseHeaderIfEmpty(key, value string) { + header := e.Response.Header() + if header.Get(key) == "" { + header.Set(key, value) + } +} + +// String writes a plain string response. +func (e *Event) String(status int, data string) error { + e.setResponseHeaderIfEmpty(headerContentType, "text/plain; charset=utf-8") + e.Response.WriteHeader(status) + _, err := e.Response.Write([]byte(data)) + return err +} + +// HTML writes an HTML response. +func (e *Event) HTML(status int, data string) error { + e.setResponseHeaderIfEmpty(headerContentType, "text/html; charset=utf-8") + e.Response.WriteHeader(status) + _, err := e.Response.Write([]byte(data)) + return err +} + +const jsonFieldsParam = "fields" + +// JSON writes a JSON response. +// +// It also provides a generic response data fields picker if the "fields" query parameter is set. +// For example, if you are requesting `?fields=a,b` for `e.JSON(200, map[string]int{ "a":1, "b":2, "c":3 })`, +// it should result in a JSON response like: `{"a":1, "b": 2}`. +func (e *Event) JSON(status int, data any) error { + e.setResponseHeaderIfEmpty(headerContentType, "application/json") + e.Response.WriteHeader(status) + + rawFields := e.Request.URL.Query().Get(jsonFieldsParam) + + // error response or no fields to pick + if rawFields == "" || status < 200 || status > 299 { + return json.NewEncoder(e.Response).Encode(data) + } + + // pick only the requested fields + modified, err := picker.Pick(data, rawFields) + if err != nil { + return err + } + + return json.NewEncoder(e.Response).Encode(modified) +} + +// XML writes an XML response. +// It automatically prepends the generic [xml.Header] string to the response. +func (e *Event) XML(status int, data any) error { + e.setResponseHeaderIfEmpty(headerContentType, "application/xml; charset=utf-8") + e.Response.WriteHeader(status) + if _, err := e.Response.Write([]byte(xml.Header)); err != nil { + return err + } + return xml.NewEncoder(e.Response).Encode(data) +} + +// Stream streams the specified reader into the response. +func (e *Event) Stream(status int, contentType string, reader io.Reader) error { + e.Response.Header().Set(headerContentType, contentType) + e.Response.WriteHeader(status) + _, err := io.Copy(e.Response, reader) + return err +} + +// Blob writes a blob (bytes slice) response. +func (e *Event) Blob(status int, contentType string, b []byte) error { + e.setResponseHeaderIfEmpty(headerContentType, contentType) + e.Response.WriteHeader(status) + _, err := e.Response.Write(b) + return err +} + +// FileFS serves the specified filename from fsys. +// +// It is similar to [echo.FileFS] for consistency with earlier versions. +func (e *Event) FileFS(fsys fs.FS, filename string) error { + f, err := fsys.Open(filename) + if err != nil { + return ErrFileNotFound + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return err + } + + // if it is a directory try to open its index.html file + if fi.IsDir() { + filename = filepath.ToSlash(filepath.Join(filename, IndexPage)) + f, err = fsys.Open(filename) + if err != nil { + return ErrFileNotFound + } + defer f.Close() + + fi, err = f.Stat() + if err != nil { + return err + } + } + + ff, ok := f.(io.ReadSeeker) + if !ok { + return errors.New("[FileFS] file does not implement io.ReadSeeker") + } + + http.ServeContent(e.Response, e.Request, fi.Name(), fi.ModTime(), ff) + + return nil +} + +// NoContent writes a response with no body (ex. 204). +func (e *Event) NoContent(status int) error { + e.Response.WriteHeader(status) + return nil +} + +// Redirect writes a redirect response to the specified url. +// The status code must be in between 300 โ€“ 399 range. +func (e *Event) Redirect(status int, url string) error { + if status < 300 || status > 399 { + return ErrInvalidRedirectStatusCode + } + e.Response.Header().Set("Location", url) + e.Response.WriteHeader(status) + return nil +} + +// ApiError helpers +// ------------------------------------------------------------------- + +func (e *Event) Error(status int, message string, errData any) *ApiError { + return NewApiError(status, message, errData) +} + +func (e *Event) BadRequestError(message string, errData any) *ApiError { + return NewBadRequestError(message, errData) +} + +func (e *Event) NotFoundError(message string, errData any) *ApiError { + return NewNotFoundError(message, errData) +} + +func (e *Event) ForbiddenError(message string, errData any) *ApiError { + return NewForbiddenError(message, errData) +} + +func (e *Event) UnauthorizedError(message string, errData any) *ApiError { + return NewUnauthorizedError(message, errData) +} + +func (e *Event) TooManyRequestsError(message string, errData any) *ApiError { + return NewTooManyRequestsError(message, errData) +} + +func (e *Event) InternalServerError(message string, errData any) *ApiError { + return NewInternalServerError(message, errData) +} + +// Binders +// ------------------------------------------------------------------- + +const DefaultMaxMemory = 32 << 20 // 32mb + +// BindBody unmarshal the request body into the provided dst. +// +// dst must be either a struct pointer or map[string]any. +// +// The rules how the body will be scanned depends on the request Content-Type. +// +// Currently the following Content-Types are supported: +// - application/json +// - text/xml, application/xml +// - multipart/form-data, application/x-www-form-urlencoded +// +// Respectively the following struct tags are supported (again, which one will be used depends on the Content-Type): +// - "json" (json body)- uses the builtin Go json package for unmarshaling. +// - "xml" (xml body) - uses the builtin Go xml package for unmarshaling. +// - "form" (form data) - utilizes the custom [router.UnmarshalRequestData] method. +// +// NB! When dst is a struct make sure that it doesn't have public fields +// that shouldn't be bindable and it is advisible such fields to be unexported +// or have a separate struct just for the binding. For example: +// +// data := struct{ +// somethingPrivate string +// +// Title string `json:"title" form:"title"` +// Total int `json:"total" form:"total"` +// } +// err := e.BindBody(&data) +func (e *Event) BindBody(dst any) error { + if e.Request.ContentLength == 0 { + return nil + } + + contentType := e.Request.Header.Get(headerContentType) + + if strings.HasPrefix(contentType, "application/json") { + dec := json.NewDecoder(e.Request.Body) + err := dec.Decode(dst) + if err == nil { + // manually call Reread because single call of json.Decoder.Decode() + // doesn't ensure that the entire body is a valid json string + // and it is not guaranteed that it will reach EOF to trigger the reread reset + // (ex. in case of trailing spaces or invalid trailing parts like: `{"test":1},something`) + if body, ok := e.Request.Body.(Rereader); ok { + body.Reread() + } + } + return err + } + + if strings.HasPrefix(contentType, "multipart/form-data") { + if err := e.Request.ParseMultipartForm(DefaultMaxMemory); err != nil { + return err + } + + return UnmarshalRequestData(e.Request.Form, dst, "", "") + } + + if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + if err := e.Request.ParseForm(); err != nil { + return err + } + + return UnmarshalRequestData(e.Request.Form, dst, "", "") + } + + if strings.HasPrefix(contentType, "text/xml") || + strings.HasPrefix(contentType, "application/xml") { + return xml.NewDecoder(e.Request.Body).Decode(dst) + } + + return ErrUnsupportedContentType +} diff --git a/server/core_backend-server/tools/router/event_test.go b/server/core_backend-server/tools/router/event_test.go new file mode 100644 index 0000000..9d7dc99 --- /dev/null +++ b/server/core_backend-server/tools/router/event_test.go @@ -0,0 +1,959 @@ +package router_test + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/tools/router" +) + +type unwrapTester struct { + http.ResponseWriter +} + +func (ut unwrapTester) Unwrap() http.ResponseWriter { + return ut.ResponseWriter +} + +func TestEventWritten(t *testing.T) { + t.Parallel() + + res1 := httptest.NewRecorder() + + res2 := httptest.NewRecorder() + res2.Write([]byte("test")) + + res3 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}} + + res4 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}} + res4.Write([]byte("test")) + + scenarios := []struct { + name string + response http.ResponseWriter + expected bool + }{ + { + name: "non-written non-WriteTracker", + response: res1, + expected: false, + }, + { + name: "written non-WriteTracker", + response: res2, + expected: false, + }, + { + name: "non-written WriteTracker", + response: res3, + expected: false, + }, + { + name: "written WriteTracker", + response: res4, + expected: true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + event := router.Event{ + Response: s.response, + } + + result := event.Written() + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestEventStatus(t *testing.T) { + t.Parallel() + + res1 := httptest.NewRecorder() + + res2 := httptest.NewRecorder() + res2.WriteHeader(123) + + res3 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}} + + res4 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}} + res4.WriteHeader(123) + + scenarios := []struct { + name string + response http.ResponseWriter + expected int + }{ + { + name: "non-written non-StatusTracker", + response: res1, + expected: 0, + }, + { + name: "written non-StatusTracker", + response: res2, + expected: 0, + }, + { + name: "non-written StatusTracker", + response: res3, + expected: 0, + }, + { + name: "written StatusTracker", + response: res4, + expected: 123, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + event := router.Event{ + Response: s.response, + } + + result := event.Status() + + if result != s.expected { + t.Fatalf("Expected %d, got %d", s.expected, result) + } + }) + } +} + +func TestEventIsTLS(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + + event := router.Event{Request: req} + + // without TLS + if event.IsTLS() { + t.Fatalf("Expected IsTLS false") + } + + // dummy TLS state + req.TLS = new(tls.ConnectionState) + + // with TLS + if !event.IsTLS() { + t.Fatalf("Expected IsTLS true") + } +} + +func TestEventSetCookie(t *testing.T) { + t.Parallel() + + event := router.Event{ + Response: httptest.NewRecorder(), + } + + cookie := event.Response.Header().Get("set-cookie") + if cookie != "" { + t.Fatalf("Expected empty cookie string, got %q", cookie) + } + + event.SetCookie(&http.Cookie{Name: "test", Value: "a"}) + + expected := "test=a" + + cookie = event.Response.Header().Get("set-cookie") + if cookie != expected { + t.Fatalf("Expected cookie %q, got %q", expected, cookie) + } +} + +func TestEventRemoteIP(t *testing.T) { + t.Parallel() + + scenarios := []struct { + remoteAddr string + expected string + }{ + {"", "invalid IP"}, + {"1.2.3.4", "invalid IP"}, + {"1.2.3.4:8198", "1.2.3.4"}, + {"[0000:0000:0000:0000:0000:0000:0000:0002]:80", "0000:0000:0000:0000:0000:0000:0000:0002"}, + {"[::2]:80", "0000:0000:0000:0000:0000:0000:0000:0002"}, // should always return the expanded version + } + + for _, s := range scenarios { + t.Run(s.remoteAddr, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + req.RemoteAddr = s.remoteAddr + + event := router.Event{Request: req} + + ip := event.RemoteIP() + + if ip != s.expected { + t.Fatalf("Expected IP %q, got %q", s.expected, ip) + } + }) + } +} + +func TestFindUploadedFiles(t *testing.T) { + scenarios := []struct { + filename string + expectedPattern string + }{ + {"ab.png", `^ab\w{10}_\w{10}\.png$`}, + {"test", `^test_\w{10}\.txt$`}, + {"a b c d!@$.j!@$pg", `^a_b_c_d_\w{10}\.jpg$`}, + {strings.Repeat("a", 150), `^a{100}_\w{10}\.txt$`}, + } + + for _, s := range scenarios { + t.Run(s.filename, func(t *testing.T) { + // create multipart form file body + body := new(bytes.Buffer) + mp := multipart.NewWriter(body) + w, err := mp.CreateFormFile("test", s.filename) + if err != nil { + t.Fatal(err) + } + w.Write([]byte("test")) + mp.Close() + // --- + + req := httptest.NewRequest(http.MethodPost, "/", body) + req.Header.Add("Content-Type", mp.FormDataContentType()) + + event := router.Event{Request: req} + + result, err := event.FindUploadedFiles("test") + if err != nil { + t.Fatal(err) + } + + if len(result) != 1 { + t.Fatalf("Expected 1 file, got %d", len(result)) + } + + if result[0].Size != 4 { + t.Fatalf("Expected the file size to be 4 bytes, got %d", result[0].Size) + } + + pattern, err := regexp.Compile(s.expectedPattern) + if err != nil { + t.Fatalf("Invalid filename pattern %q: %v", s.expectedPattern, err) + } + if !pattern.MatchString(result[0].Name) { + t.Fatalf("Expected filename to match %s, got filename %s", s.expectedPattern, result[0].Name) + } + }) + } +} + +func TestFindUploadedFilesMissing(t *testing.T) { + body := new(bytes.Buffer) + mp := multipart.NewWriter(body) + mp.Close() + + req := httptest.NewRequest(http.MethodPost, "/", body) + req.Header.Add("Content-Type", mp.FormDataContentType()) + + event := router.Event{Request: req} + + result, err := event.FindUploadedFiles("test") + if err == nil { + t.Error("Expected error, got nil") + } + + if result != nil { + t.Errorf("Expected result to be nil, got %v", result) + } +} + +func TestEventSetGet(t *testing.T) { + event := router.Event{} + + // get before any set (ensures that doesn't panic) + if v := event.Get("test"); v != nil { + t.Fatalf("Expected nil value, got %v", v) + } + + event.Set("a", 123) + event.Set("b", 456) + + scenarios := []struct { + key string + expected any + }{ + {"", nil}, + {"missing", nil}, + {"a", 123}, + {"b", 456}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) { + result := event.Get(s.key) + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestEventSetAllGetAll(t *testing.T) { + data := map[string]any{ + "a": 123, + "b": 456, + } + rawData, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + event := router.Event{} + event.SetAll(data) + + // modify the data to ensure that the map was shallow coppied + data["c"] = 789 + + result := event.GetAll() + rawResult, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + + if len(rawResult) == 0 || !bytes.Equal(rawData, rawResult) { + t.Fatalf("Expected\n%v\ngot\n%v", rawData, rawResult) + } +} + +func TestEventString(t *testing.T) { + scenarios := []testResponseWriteScenario[string]{ + { + name: "no explicit content-type", + status: 123, + headers: nil, + body: "test", + expectedStatus: 123, + expectedHeaders: map[string]string{"content-type": "text/plain; charset=utf-8"}, + expectedBody: "test", + }, + { + name: "with explicit content-type", + status: 123, + headers: map[string]string{"content-type": "text/test"}, + body: "test", + expectedStatus: 123, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.String(s.status, s.body) + }) + } +} + +func TestEventHTML(t *testing.T) { + scenarios := []testResponseWriteScenario[string]{ + { + name: "no explicit content-type", + status: 123, + headers: nil, + body: "test", + expectedStatus: 123, + expectedHeaders: map[string]string{"content-type": "text/html; charset=utf-8"}, + expectedBody: "test", + }, + { + name: "with explicit content-type", + status: 123, + headers: map[string]string{"content-type": "text/test"}, + body: "test", + expectedStatus: 123, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.HTML(s.status, s.body) + }) + } +} + +func TestEventJSON(t *testing.T) { + body := map[string]any{"a": 123, "b": 456, "c": "test"} + expectedPickedBody := `{"a":123,"c":"test"}` + "\n" + expectedFullBody := `{"a":123,"b":456,"c":"test"}` + "\n" + + scenarios := []testResponseWriteScenario[any]{ + { + name: "no explicit content-type", + status: 200, + headers: nil, + body: body, + expectedStatus: 200, + expectedHeaders: map[string]string{"content-type": "application/json"}, + expectedBody: expectedPickedBody, + }, + { + name: "with explicit content-type (200)", + status: 200, + headers: map[string]string{"content-type": "application/test"}, + body: body, + expectedStatus: 200, + expectedHeaders: map[string]string{"content-type": "application/test"}, + expectedBody: expectedPickedBody, + }, + { + name: "with explicit content-type (400)", // no fields picker + status: 400, + headers: map[string]string{"content-type": "application/test"}, + body: body, + expectedStatus: 400, + expectedHeaders: map[string]string{"content-type": "application/test"}, + expectedBody: expectedFullBody, + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + e.Request.URL.RawQuery = "fields=a,c" // ensures that the picker is invoked + return e.JSON(s.status, s.body) + }) + } +} + +func TestEventXML(t *testing.T) { + scenarios := []testResponseWriteScenario[string]{ + { + name: "no explicit content-type", + status: 234, + headers: nil, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "application/xml; charset=utf-8"}, + expectedBody: xml.Header + "test", + }, + { + name: "with explicit content-type", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: xml.Header + "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.XML(s.status, s.body) + }) + } +} + +func TestEventStream(t *testing.T) { + scenarios := []testResponseWriteScenario[string]{ + { + name: "stream", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.Stream(s.status, s.headers["content-type"], strings.NewReader(s.body)) + }) + } +} + +func TestEventBlob(t *testing.T) { + scenarios := []testResponseWriteScenario[[]byte]{ + { + name: "blob", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: []byte("test"), + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.Blob(s.status, s.headers["content-type"], s.body) + }) + } +} + +func TestEventNoContent(t *testing.T) { + s := testResponseWriteScenario[any]{ + name: "no content", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: nil, + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "", + } + + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.NoContent(s.status) + }) +} + +func TestEventFlush(t *testing.T) { + rec := httptest.NewRecorder() + + event := &router.Event{ + Response: unwrapTester{&router.ResponseWriter{ResponseWriter: rec}}, + } + event.Response.Write([]byte("test")) + event.Flush() + + if !rec.Flushed { + t.Fatal("Expected response to be flushed") + } +} + +func TestEventRedirect(t *testing.T) { + scenarios := []testResponseWriteScenario[any]{ + { + name: "non-30x status", + status: 200, + expectedStatus: 200, + expectedError: router.ErrInvalidRedirectStatusCode, + }, + { + name: "30x status", + status: 302, + headers: map[string]string{"location": "test"}, // should be overwritten with the argument + expectedStatus: 302, + expectedHeaders: map[string]string{"location": "example"}, + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.Redirect(s.status, "example") + }) + } +} + +func TestEventFileFS(t *testing.T) { + // stub test files + // --- + dir, err := os.MkdirTemp("", "EventFileFS") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + err = os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0644) + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(filepath.Join(dir, "test.txt"), []byte("test"), 0644) + if err != nil { + t.Fatal(err) + } + + // create sub directory with an index.html file inside it + err = os.MkdirAll(filepath.Join(dir, "sub1"), os.ModePerm) + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(filepath.Join(dir, "sub1", "index.html"), []byte("sub1 index"), 0644) + if err != nil { + t.Fatal(err) + } + + err = os.MkdirAll(filepath.Join(dir, "sub2"), os.ModePerm) + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(filepath.Join(dir, "sub2", "test.txt"), []byte("sub2 test"), 0644) + if err != nil { + t.Fatal(err) + } + // --- + + scenarios := []struct { + name string + path string + expected string + }{ + {"missing file", "", ""}, + {"root with no explicit file", "", ""}, + {"root with explicit file", "test.txt", "test"}, + {"sub dir with no explicit file", "sub1", "sub1 index"}, + {"sub dir with no explicit file (no index.html)", "sub2", ""}, + {"sub dir explicit file", "sub2/test.txt", "sub2 test"}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + + event := &router.Event{ + Request: req, + Response: rec, + } + + err = event.FileFS(os.DirFS(dir), s.path) + + hasErr := err != nil + expectErr := s.expected == "" + if hasErr != expectErr { + t.Fatalf("Expected hasErr %v, got %v (%v)", expectErr, hasErr, err) + } + + result := rec.Result() + + raw, err := io.ReadAll(result.Body) + result.Body.Close() + if err != nil { + t.Fatal(err) + } + + if string(raw) != s.expected { + t.Fatalf("Expected body\n%s\ngot\n%s", s.expected, raw) + } + + // ensure that the proper file headers are added + // (aka. http.ServeContent is invoked) + length, _ := strconv.Atoi(result.Header.Get("content-length")) + if length != len(s.expected) { + t.Fatalf("Expected Content-Length %d, got %d", len(s.expected), length) + } + }) + } +} + +func TestEventError(t *testing.T) { + err := new(router.Event).Error(123, "message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":123}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventBadRequestError(t *testing.T) { + err := new(router.Event).BadRequestError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":400}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventNotFoundError(t *testing.T) { + err := new(router.Event).NotFoundError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":404}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventForbiddenError(t *testing.T) { + err := new(router.Event).ForbiddenError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":403}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventUnauthorizedError(t *testing.T) { + err := new(router.Event).UnauthorizedError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":401}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventTooManyRequestsError(t *testing.T) { + err := new(router.Event).TooManyRequestsError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":429}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventInternalServerError(t *testing.T) { + err := new(router.Event).InternalServerError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":500}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventBindBody(t *testing.T) { + type testDstStruct struct { + A int `json:"a" xml:"a" form:"a"` + B int `json:"b" xml:"b" form:"b"` + C string `json:"c" xml:"c" form:"c"` + } + + emptyDst := `{"a":0,"b":0,"c":""}` + + queryDst := `a=123&b=-456&c=test` + + xmlDst := ` + + + 123 + -456 + test + + ` + + jsonDst := `{"a":123,"b":-456,"c":"test"}` + + // multipart + mpBody := &bytes.Buffer{} + mpWriter := multipart.NewWriter(mpBody) + mpWriter.WriteField("@jsonPayload", `{"a":123}`) + mpWriter.WriteField("b", "-456") + mpWriter.WriteField("c", "test") + if err := mpWriter.Close(); err != nil { + t.Fatal(err) + } + + scenarios := []struct { + contentType string + body io.Reader + expectDst string + expectError bool + }{ + { + contentType: "", + body: strings.NewReader(jsonDst), + expectDst: emptyDst, + expectError: true, + }, + { + contentType: "application/rtf", // unsupported + body: strings.NewReader(jsonDst), + expectDst: emptyDst, + expectError: true, + }, + // empty body + { + contentType: "application/json;charset=emptybody", + body: strings.NewReader(""), + expectDst: emptyDst, + }, + // json + { + contentType: "application/json", + body: strings.NewReader(jsonDst), + expectDst: jsonDst, + }, + { + contentType: "application/json;charset=abc", + body: strings.NewReader(jsonDst), + expectDst: jsonDst, + }, + // xml + { + contentType: "text/xml", + body: strings.NewReader(xmlDst), + expectDst: jsonDst, + }, + { + contentType: "text/xml;charset=abc", + body: strings.NewReader(xmlDst), + expectDst: jsonDst, + }, + { + contentType: "application/xml", + body: strings.NewReader(xmlDst), + expectDst: jsonDst, + }, + { + contentType: "application/xml;charset=abc", + body: strings.NewReader(xmlDst), + expectDst: jsonDst, + }, + // x-www-form-urlencoded + { + contentType: "application/x-www-form-urlencoded", + body: strings.NewReader(queryDst), + expectDst: jsonDst, + }, + { + contentType: "application/x-www-form-urlencoded;charset=abc", + body: strings.NewReader(queryDst), + expectDst: jsonDst, + }, + // multipart + { + contentType: mpWriter.FormDataContentType(), + body: mpBody, + expectDst: jsonDst, + }, + } + + for _, s := range scenarios { + t.Run(s.contentType, func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/", s.body) + if err != nil { + t.Fatal(err) + } + req.Header.Add("content-type", s.contentType) + + event := &router.Event{Request: req} + + dst := testDstStruct{} + + err = event.BindBody(&dst) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + dstRaw, err := json.Marshal(dst) + if err != nil { + t.Fatal(err) + } + + if string(dstRaw) != s.expectDst { + t.Fatalf("Expected dst\n%s\ngot\n%s", s.expectDst, dstRaw) + } + }) + } +} + +// ------------------------------------------------------------------- + +type testResponseWriteScenario[T any] struct { + name string + status int + headers map[string]string + body T + expectedStatus int + expectedHeaders map[string]string + expectedBody string + expectedError error +} + +func testEventResponseWrite[T any]( + t *testing.T, + scenario testResponseWriteScenario[T], + writeFunc func(e *router.Event) error, +) { + t.Run(scenario.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + event := &router.Event{ + Request: req, + Response: &router.ResponseWriter{ResponseWriter: rec}, + } + + for k, v := range scenario.headers { + event.Response.Header().Add(k, v) + } + + err = writeFunc(event) + if (scenario.expectedError != nil || err != nil) && !errors.Is(err, scenario.expectedError) { + t.Fatalf("Expected error %v, got %v", scenario.expectedError, err) + } + + result := rec.Result() + + if result.StatusCode != scenario.expectedStatus { + t.Fatalf("Expected status code %d, got %d", scenario.expectedStatus, result.StatusCode) + } + + resultBody, err := io.ReadAll(result.Body) + result.Body.Close() + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + resultBody, err = json.Marshal(string(resultBody)) + if err != nil { + t.Fatal(err) + } + + expectedBody, err := json.Marshal(scenario.expectedBody) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(resultBody, expectedBody) { + t.Fatalf("Expected body\n%s\ngot\n%s", expectedBody, resultBody) + } + + for k, ev := range scenario.expectedHeaders { + if v := result.Header.Get(k); v != ev { + t.Fatalf("Expected %q header to be %q, got %q", k, ev, v) + } + } + }) +} diff --git a/server/core_backend-server/tools/router/group.go b/server/core_backend-server/tools/router/group.go new file mode 100644 index 0000000..b6f7524 --- /dev/null +++ b/server/core_backend-server/tools/router/group.go @@ -0,0 +1,231 @@ +package router + +import ( + "net/http" + "regexp" + "strings" + + "github.com/pocketbase/pocketbase/tools/hook" +) + +// (note: the struct is named RouterGroup instead of Group so that it can +// be embedded in the Router without conflicting with the Group method) + +// RouterGroup represents a collection of routes and other sub groups +// that share common pattern prefix and middlewares. +type RouterGroup[T hook.Resolver] struct { + excludedMiddlewares map[string]struct{} + children []any // Route or RouterGroup + + Prefix string + Middlewares []*hook.Handler[T] +} + +// Group creates and register a new child Group into the current one +// with the specified prefix. +// +// The prefix follows the standard Go net/http ServeMux pattern format ("[HOST]/[PATH]") +// and will be concatenated recursively into the final route path, meaning that +// only the root level group could have HOST as part of the prefix. +// +// Returns the newly created group to allow chaining and registering +// sub-routes and group specific middlewares. +func (group *RouterGroup[T]) Group(prefix string) *RouterGroup[T] { + newGroup := &RouterGroup[T]{} + newGroup.Prefix = prefix + + group.children = append(group.children, newGroup) + + return newGroup +} + +// BindFunc registers one or multiple middleware functions to the current group. +// +// The registered middleware functions are "anonymous" and with default priority, +// aka. executes in the order they were registered. +// +// If you need to specify a named middleware (ex. so that it can be removed) +// or middleware with custom exec prirority, use [RouterGroup.Bind] method. +func (group *RouterGroup[T]) BindFunc(middlewareFuncs ...func(e T) error) *RouterGroup[T] { + for _, m := range middlewareFuncs { + group.Middlewares = append(group.Middlewares, &hook.Handler[T]{Func: m}) + } + + return group +} + +// Bind registers one or multiple middleware handlers to the current group. +func (group *RouterGroup[T]) Bind(middlewares ...*hook.Handler[T]) *RouterGroup[T] { + group.Middlewares = append(group.Middlewares, middlewares...) + + // unmark the newly added middlewares in case they were previously "excluded" + if group.excludedMiddlewares != nil { + for _, m := range middlewares { + if m.Id != "" { + delete(group.excludedMiddlewares, m.Id) + } + } + } + + return group +} + +// Unbind removes one or more middlewares with the specified id(s) +// from the current group and its children (if any). +// +// Anonymous middlewares are not removable, aka. this method does nothing +// if the middleware id is an empty string. +func (group *RouterGroup[T]) Unbind(middlewareIds ...string) *RouterGroup[T] { + for _, middlewareId := range middlewareIds { + if middlewareId == "" { + continue + } + + // remove from the group middlwares + for i := len(group.Middlewares) - 1; i >= 0; i-- { + if group.Middlewares[i].Id == middlewareId { + group.Middlewares = append(group.Middlewares[:i], group.Middlewares[i+1:]...) + } + } + + // remove from the group children + for i := len(group.children) - 1; i >= 0; i-- { + switch v := group.children[i].(type) { + case *RouterGroup[T]: + v.Unbind(middlewareId) + case *Route[T]: + v.Unbind(middlewareId) + } + } + + // add to the exclude list + if group.excludedMiddlewares == nil { + group.excludedMiddlewares = map[string]struct{}{} + } + group.excludedMiddlewares[middlewareId] = struct{}{} + } + + return group +} + +// Route registers a single route into the current group. +// +// Note that the final route path will be the concatenation of all parent groups prefixes + the route path. +// The path follows the standard Go net/http ServeMux format ("[HOST]/[PATH]"), +// meaning that only a top level group route could have HOST as part of the prefix. +// +// Returns the newly created route to allow attaching route-only middlewares. +func (group *RouterGroup[T]) Route(method string, path string, action func(e T) error) *Route[T] { + route := &Route[T]{ + Method: method, + Path: path, + Action: action, + } + + group.children = append(group.children, route) + + return route +} + +// Any is a shorthand for [RouterGroup.AddRoute] with "" as route method (aka. matches any method). +func (group *RouterGroup[T]) Any(path string, action func(e T) error) *Route[T] { + return group.Route("", path, action) +} + +// GET is a shorthand for [RouterGroup.AddRoute] with GET as route method. +func (group *RouterGroup[T]) GET(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodGet, path, action) +} + +// SEARCH is a shorthand for [RouterGroup.AddRoute] with SEARCH as route method. +func (group *RouterGroup[T]) SEARCH(path string, action func(e T) error) *Route[T] { + return group.Route("SEARCH", path, action) +} + +// POST is a shorthand for [RouterGroup.AddRoute] with POST as route method. +func (group *RouterGroup[T]) POST(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodPost, path, action) +} + +// DELETE is a shorthand for [RouterGroup.AddRoute] with DELETE as route method. +func (group *RouterGroup[T]) DELETE(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodDelete, path, action) +} + +// PATCH is a shorthand for [RouterGroup.AddRoute] with PATCH as route method. +func (group *RouterGroup[T]) PATCH(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodPatch, path, action) +} + +// PUT is a shorthand for [RouterGroup.AddRoute] with PUT as route method. +func (group *RouterGroup[T]) PUT(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodPut, path, action) +} + +// HEAD is a shorthand for [RouterGroup.AddRoute] with HEAD as route method. +func (group *RouterGroup[T]) HEAD(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodHead, path, action) +} + +// OPTIONS is a shorthand for [RouterGroup.AddRoute] with OPTIONS as route method. +func (group *RouterGroup[T]) OPTIONS(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodOptions, path, action) +} + +// HasRoute checks whether the specified route pattern (method + path) +// is registered in the current group or its children. +// +// This could be useful to conditionally register and checks for routes +// in order prevent panic on duplicated routes. +// +// Note that routes with anonymous and named wildcard placeholder are treated as equal, +// aka. "GET /abc/" is considered the same as "GET /abc/{something...}". +func (group *RouterGroup[T]) HasRoute(method string, path string) bool { + pattern := path + if method != "" { + pattern = strings.ToUpper(method) + " " + pattern + } + + return group.hasRoute(pattern, nil) +} + +func (group *RouterGroup[T]) hasRoute(pattern string, parents []*RouterGroup[T]) bool { + for _, child := range group.children { + switch v := child.(type) { + case *RouterGroup[T]: + if v.hasRoute(pattern, append(parents, group)) { + return true + } + case *Route[T]: + var result string + + if v.Method != "" { + result += v.Method + " " + } + + // add parent groups prefixes + for _, p := range parents { + result += p.Prefix + } + + // add current group prefix + result += group.Prefix + + // add current route path + result += v.Path + + if result == pattern || // direct match + // compares without the named wildcard, aka. /abc/{test...} is equal to /abc/ + stripWildcard(result) == stripWildcard(pattern) { + return true + } + } + } + return false +} + +var wildcardPlaceholderRegex = regexp.MustCompile(`/{.+\.\.\.}$`) + +func stripWildcard(pattern string) string { + return wildcardPlaceholderRegex.ReplaceAllString(pattern, "/") +} diff --git a/server/core_backend-server/tools/router/group_test.go b/server/core_backend-server/tools/router/group_test.go new file mode 100644 index 0000000..aeafca4 --- /dev/null +++ b/server/core_backend-server/tools/router/group_test.go @@ -0,0 +1,430 @@ +package router + +import ( + "errors" + "fmt" + "net/http" + "slices" + "testing" + + "github.com/pocketbase/pocketbase/tools/hook" +) + +func TestRouterGroupGroup(t *testing.T) { + t.Parallel() + + g0 := RouterGroup[*Event]{} + + g1 := g0.Group("test1") + g2 := g0.Group("test2") + + if total := len(g0.children); total != 2 { + t.Fatalf("Expected %d child groups, got %d", 2, total) + } + + if g1.Prefix != "test1" { + t.Fatalf("Expected g1 with prefix %q, got %q", "test1", g1.Prefix) + } + if g2.Prefix != "test2" { + t.Fatalf("Expected g2 with prefix %q, got %q", "test2", g2.Prefix) + } +} + +func TestRouterGroupBindFunc(t *testing.T) { + t.Parallel() + + g := RouterGroup[*Event]{} + + calls := "" + + // append one function + g.BindFunc(func(e *Event) error { + calls += "a" + return nil + }) + + // append multiple functions + g.BindFunc( + func(e *Event) error { + calls += "b" + return nil + }, + func(e *Event) error { + calls += "c" + return nil + }, + ) + + if total := len(g.Middlewares); total != 3 { + t.Fatalf("Expected %d middlewares, got %v", 3, total) + } + + for _, h := range g.Middlewares { + _ = h.Func(nil) + } + + if calls != "abc" { + t.Fatalf("Expected calls sequence %q, got %q", "abc", calls) + } +} + +func TestRouterGroupBind(t *testing.T) { + t.Parallel() + + g := RouterGroup[*Event]{ + // mock excluded middlewares to check whether the entry will be deleted + excludedMiddlewares: map[string]struct{}{"test2": {}}, + } + + calls := "" + + // append one handler + g.Bind(&hook.Handler[*Event]{ + Func: func(e *Event) error { + calls += "a" + return nil + }, + }) + + // append multiple handlers + g.Bind( + &hook.Handler[*Event]{ + Id: "test1", + Func: func(e *Event) error { + calls += "b" + return nil + }, + }, + &hook.Handler[*Event]{ + Id: "test2", + Func: func(e *Event) error { + calls += "c" + return nil + }, + }, + ) + + if total := len(g.Middlewares); total != 3 { + t.Fatalf("Expected %d middlewares, got %v", 3, total) + } + + for _, h := range g.Middlewares { + _ = h.Func(nil) + } + + if calls != "abc" { + t.Fatalf("Expected calls %q, got %q", "abc", calls) + } + + // ensures that the previously excluded middleware was removed + if len(g.excludedMiddlewares) != 0 { + t.Fatalf("Expected test2 to be removed from the excludedMiddlewares list, got %v", g.excludedMiddlewares) + } +} + +func TestRouterGroupUnbind(t *testing.T) { + t.Parallel() + + g := RouterGroup[*Event]{} + + calls := "" + + // anonymous middlewares + g.Bind(&hook.Handler[*Event]{ + Func: func(e *Event) error { + calls += "a" + return nil // unused value + }, + }) + + // middlewares with id + g.Bind(&hook.Handler[*Event]{ + Id: "test1", + Func: func(e *Event) error { + calls += "b" + return nil // unused value + }, + }) + g.Bind(&hook.Handler[*Event]{ + Id: "test2", + Func: func(e *Event) error { + calls += "c" + return nil // unused value + }, + }) + g.Bind(&hook.Handler[*Event]{ + Id: "test3", + Func: func(e *Event) error { + calls += "d" + return nil // unused value + }, + }) + + // remove + g.Unbind("") // should be no-op + g.Unbind("test1", "test3") + + if total := len(g.Middlewares); total != 2 { + t.Fatalf("Expected %d middlewares, got %v", 2, total) + } + + for _, h := range g.Middlewares { + if err := h.Func(nil); err != nil { + continue + } + } + + if calls != "ac" { + t.Fatalf("Expected calls %q, got %q", "ac", calls) + } + + // ensure that the ids were added in the exclude list + excluded := []string{"test1", "test3"} + if len(g.excludedMiddlewares) != len(excluded) { + t.Fatalf("Expected excludes %v, got %v", excluded, g.excludedMiddlewares) + } + for id := range g.excludedMiddlewares { + if !slices.Contains(excluded, id) { + t.Fatalf("Expected %q to be marked as excluded", id) + } + } +} + +func TestRouterGroupRoute(t *testing.T) { + t.Parallel() + + group := RouterGroup[*Event]{} + + sub := group.Group("sub") + + var called bool + route := group.Route(http.MethodPost, "/test", func(e *Event) error { + called = true + return nil + }) + + // ensure that the route was registered only to the main one + // --- + if len(sub.children) != 0 { + t.Fatalf("Expected no sub children, got %d", len(sub.children)) + } + + if len(group.children) != 2 { + t.Fatalf("Expected %d group children, got %d", 2, len(group.children)) + } + // --- + + // check the registered route + // --- + if route != group.children[1] { + t.Fatalf("Expected group children %v, got %v", route, group.children[1]) + } + + if route.Method != http.MethodPost { + t.Fatalf("Expected route method %q, got %q", http.MethodPost, route.Method) + } + + if route.Path != "/test" { + t.Fatalf("Expected route path %q, got %q", "/test", route.Path) + } + + route.Action(nil) + if !called { + t.Fatal("Expected route action to be called") + } +} + +func TestRouterGroupRouteAliases(t *testing.T) { + t.Parallel() + + group := RouterGroup[*Event]{} + + testErr := errors.New("test") + + testAction := func(e *Event) error { + return testErr + } + + scenarios := []struct { + route *Route[*Event] + expectMethod string + expectPath string + }{ + { + group.Any("/test", testAction), + "", + "/test", + }, + { + group.GET("/test", testAction), + http.MethodGet, + "/test", + }, + { + group.SEARCH("/test", testAction), + "SEARCH", + "/test", + }, + { + group.POST("/test", testAction), + http.MethodPost, + "/test", + }, + { + group.DELETE("/test", testAction), + http.MethodDelete, + "/test", + }, + { + group.PATCH("/test", testAction), + http.MethodPatch, + "/test", + }, + { + group.PUT("/test", testAction), + http.MethodPut, + "/test", + }, + { + group.HEAD("/test", testAction), + http.MethodHead, + "/test", + }, + { + group.OPTIONS("/test", testAction), + http.MethodOptions, + "/test", + }, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s_%s", i, s.expectMethod, s.expectPath), func(t *testing.T) { + if s.route.Method != s.expectMethod { + t.Fatalf("Expected method %q, got %q", s.expectMethod, s.route.Method) + } + + if s.route.Path != s.expectPath { + t.Fatalf("Expected path %q, got %q", s.expectPath, s.route.Path) + } + + if err := s.route.Action(nil); !errors.Is(err, testErr) { + t.Fatal("Expected test action") + } + }) + } +} + +func TestRouterGroupHasRoute(t *testing.T) { + t.Parallel() + + group := RouterGroup[*Event]{} + + group.Any("/any", nil) + + group.GET("/base", nil) + group.DELETE("/base", nil) + + sub := group.Group("/sub1") + sub.GET("/a", nil) + sub.POST("/a", nil) + + sub2 := sub.Group("/sub2") + sub2.GET("/b", nil) + sub2.GET("/b/{test}", nil) + + // special cases to test the normalizations + group.GET("/c/", nil) // the same as /c/{test...} + group.GET("/d/{test...}", nil) // the same as /d/ + + scenarios := []struct { + method string + path string + expected bool + }{ + { + http.MethodGet, + "", + false, + }, + { + "", + "/any", + true, + }, + { + http.MethodPost, + "/base", + false, + }, + { + http.MethodGet, + "/base", + true, + }, + { + http.MethodDelete, + "/base", + true, + }, + { + http.MethodGet, + "/sub1", + false, + }, + { + http.MethodGet, + "/sub1/a", + true, + }, + { + http.MethodPost, + "/sub1/a", + true, + }, + { + http.MethodDelete, + "/sub1/a", + false, + }, + { + http.MethodGet, + "/sub2/b", + false, + }, + { + http.MethodGet, + "/sub1/sub2/b", + true, + }, + { + http.MethodGet, + "/sub1/sub2/b/{test}", + true, + }, + { + http.MethodGet, + "/sub1/sub2/b/{test2}", + false, + }, + { + http.MethodGet, + "/c/{test...}", + true, + }, + { + http.MethodGet, + "/d/", + true, + }, + } + + for _, s := range scenarios { + t.Run(s.method+"_"+s.path, func(t *testing.T) { + has := group.HasRoute(s.method, s.path) + + if has != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, has) + } + }) + } +} diff --git a/server/core_backend-server/tools/router/rereadable_read_closer.go b/server/core_backend-server/tools/router/rereadable_read_closer.go new file mode 100644 index 0000000..0e5f5d8 --- /dev/null +++ b/server/core_backend-server/tools/router/rereadable_read_closer.go @@ -0,0 +1,60 @@ +package router + +import ( + "bytes" + "io" +) + +var ( + _ io.ReadCloser = (*RereadableReadCloser)(nil) + _ Rereader = (*RereadableReadCloser)(nil) +) + +// Rereader defines an interface for rewindable readers. +type Rereader interface { + Reread() +} + +// RereadableReadCloser defines a wrapper around a io.ReadCloser reader +// allowing to read the original reader multiple times. +type RereadableReadCloser struct { + io.ReadCloser + + copy *bytes.Buffer + active io.Reader +} + +// Read implements the standard io.Reader interface. +// +// It reads up to len(b) bytes into b and at at the same time writes +// the read data into an internal bytes buffer. +// +// On EOF the r is "rewinded" to allow reading from r multiple times. +func (r *RereadableReadCloser) Read(b []byte) (int, error) { + if r.active == nil { + if r.copy == nil { + r.copy = &bytes.Buffer{} + } + r.active = io.TeeReader(r.ReadCloser, r.copy) + } + + n, err := r.active.Read(b) + if err == io.EOF { + r.Reread() + } + + return n, err +} + +// Reread satisfies the [Rereader] interface and resets the r internal state to allow rereads. +// +// note: not named Reset to avoid conflicts with other reader interfaces. +func (r *RereadableReadCloser) Reread() { + if r.copy == nil || r.copy.Len() == 0 { + return // nothing to reset or it has been already reset + } + + oldCopy := r.copy + r.copy = &bytes.Buffer{} + r.active = io.TeeReader(oldCopy, r.copy) +} diff --git a/server/core_backend-server/tools/router/rereadable_read_closer_test.go b/server/core_backend-server/tools/router/rereadable_read_closer_test.go new file mode 100644 index 0000000..2334ee6 --- /dev/null +++ b/server/core_backend-server/tools/router/rereadable_read_closer_test.go @@ -0,0 +1,28 @@ +package router_test + +import ( + "io" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/router" +) + +func TestRereadableReadCloser(t *testing.T) { + content := "test" + + rereadable := &router.RereadableReadCloser{ + ReadCloser: io.NopCloser(strings.NewReader(content)), + } + + // read multiple times + for i := 0; i < 3; i++ { + result, err := io.ReadAll(rereadable) + if err != nil { + t.Fatalf("[read:%d] %v", i, err) + } + if str := string(result); str != content { + t.Fatalf("[read:%d] Expected %q, got %q", i, content, result) + } + } +} diff --git a/server/core_backend-server/tools/router/route.go b/server/core_backend-server/tools/router/route.go new file mode 100644 index 0000000..351096d --- /dev/null +++ b/server/core_backend-server/tools/router/route.go @@ -0,0 +1,73 @@ +package router + +import "github.com/pocketbase/pocketbase/tools/hook" + +type Route[T hook.Resolver] struct { + excludedMiddlewares map[string]struct{} + + Action func(e T) error + Method string + Path string + Middlewares []*hook.Handler[T] +} + +// BindFunc registers one or multiple middleware functions to the current route. +// +// The registered middleware functions are "anonymous" and with default priority, +// aka. executes in the order they were registered. +// +// If you need to specify a named middleware (ex. so that it can be removed) +// or middleware with custom exec prirority, use the [Route.Bind] method. +func (route *Route[T]) BindFunc(middlewareFuncs ...func(e T) error) *Route[T] { + for _, m := range middlewareFuncs { + route.Middlewares = append(route.Middlewares, &hook.Handler[T]{Func: m}) + } + + return route +} + +// Bind registers one or multiple middleware handlers to the current route. +func (route *Route[T]) Bind(middlewares ...*hook.Handler[T]) *Route[T] { + route.Middlewares = append(route.Middlewares, middlewares...) + + // unmark the newly added middlewares in case they were previously "excluded" + if route.excludedMiddlewares != nil { + for _, m := range middlewares { + if m.Id != "" { + delete(route.excludedMiddlewares, m.Id) + } + } + } + + return route +} + +// Unbind removes one or more middlewares with the specified id(s) from the current route. +// +// It also adds the removed middleware ids to an exclude list so that they could be skipped from +// the execution chain in case the middleware is registered in a parent group. +// +// Anonymous middlewares are considered non-removable, aka. this method +// does nothing if the middleware id is an empty string. +func (route *Route[T]) Unbind(middlewareIds ...string) *Route[T] { + for _, middlewareId := range middlewareIds { + if middlewareId == "" { + continue + } + + // remove from the route's middlewares + for i := len(route.Middlewares) - 1; i >= 0; i-- { + if route.Middlewares[i].Id == middlewareId { + route.Middlewares = append(route.Middlewares[:i], route.Middlewares[i+1:]...) + } + } + + // add to the exclude list + if route.excludedMiddlewares == nil { + route.excludedMiddlewares = map[string]struct{}{} + } + route.excludedMiddlewares[middlewareId] = struct{}{} + } + + return route +} diff --git a/server/core_backend-server/tools/router/route_test.go b/server/core_backend-server/tools/router/route_test.go new file mode 100644 index 0000000..ef6e541 --- /dev/null +++ b/server/core_backend-server/tools/router/route_test.go @@ -0,0 +1,168 @@ +package router + +import ( + "slices" + "testing" + + "github.com/pocketbase/pocketbase/tools/hook" +) + +func TestRouteBindFunc(t *testing.T) { + t.Parallel() + + r := Route[*Event]{} + + calls := "" + + // append one function + r.BindFunc(func(e *Event) error { + calls += "a" + return nil + }) + + // append multiple functions + r.BindFunc( + func(e *Event) error { + calls += "b" + return nil + }, + func(e *Event) error { + calls += "c" + return nil + }, + ) + + if total := len(r.Middlewares); total != 3 { + t.Fatalf("Expected %d middlewares, got %v", 3, total) + } + + for _, h := range r.Middlewares { + _ = h.Func(nil) + } + + if calls != "abc" { + t.Fatalf("Expected calls sequence %q, got %q", "abc", calls) + } +} + +func TestRouteBind(t *testing.T) { + t.Parallel() + + r := Route[*Event]{ + // mock excluded middlewares to check whether the entry will be deleted + excludedMiddlewares: map[string]struct{}{"test2": {}}, + } + + calls := "" + + // append one handler + r.Bind(&hook.Handler[*Event]{ + Func: func(e *Event) error { + calls += "a" + return nil + }, + }) + + // append multiple handlers + r.Bind( + &hook.Handler[*Event]{ + Id: "test1", + Func: func(e *Event) error { + calls += "b" + return nil + }, + }, + &hook.Handler[*Event]{ + Id: "test2", + Func: func(e *Event) error { + calls += "c" + return nil + }, + }, + ) + + if total := len(r.Middlewares); total != 3 { + t.Fatalf("Expected %d middlewares, got %v", 3, total) + } + + for _, h := range r.Middlewares { + _ = h.Func(nil) + } + + if calls != "abc" { + t.Fatalf("Expected calls %q, got %q", "abc", calls) + } + + // ensures that the previously excluded middleware was removed + if len(r.excludedMiddlewares) != 0 { + t.Fatalf("Expected test2 to be removed from the excludedMiddlewares list, got %v", r.excludedMiddlewares) + } +} + +func TestRouteUnbind(t *testing.T) { + t.Parallel() + + r := Route[*Event]{} + + calls := "" + + // anonymous middlewares + r.Bind(&hook.Handler[*Event]{ + Func: func(e *Event) error { + calls += "a" + return nil // unused value + }, + }) + + // middlewares with id + r.Bind(&hook.Handler[*Event]{ + Id: "test1", + Func: func(e *Event) error { + calls += "b" + return nil // unused value + }, + }) + r.Bind(&hook.Handler[*Event]{ + Id: "test2", + Func: func(e *Event) error { + calls += "c" + return nil // unused value + }, + }) + r.Bind(&hook.Handler[*Event]{ + Id: "test3", + Func: func(e *Event) error { + calls += "d" + return nil // unused value + }, + }) + + // remove + r.Unbind("") // should be no-op + r.Unbind("test1", "test3") + + if total := len(r.Middlewares); total != 2 { + t.Fatalf("Expected %d middlewares, got %v", 2, total) + } + + for _, h := range r.Middlewares { + if err := h.Func(nil); err != nil { + continue + } + } + + if calls != "ac" { + t.Fatalf("Expected calls %q, got %q", "ac", calls) + } + + // ensure that the id was added in the exclude list + excluded := []string{"test1", "test3"} + if len(r.excludedMiddlewares) != len(excluded) { + t.Fatalf("Expected excludes %v, got %v", excluded, r.excludedMiddlewares) + } + for id := range r.excludedMiddlewares { + if !slices.Contains(excluded, id) { + t.Fatalf("Expected %q to be marked as excluded", id) + } + } +} diff --git a/server/core_backend-server/tools/router/router.go b/server/core_backend-server/tools/router/router.go new file mode 100644 index 0000000..d789ff5 --- /dev/null +++ b/server/core_backend-server/tools/router/router.go @@ -0,0 +1,329 @@ +package router + +import ( + "bufio" + "encoding/json" + "errors" + "io" + "log" + "net" + "net/http" + + "github.com/pocketbase/pocketbase/tools/hook" +) + +type EventCleanupFunc func() + +// EventFactoryFunc defines the function responsible for creating a Route specific event +// based on the provided request handler ServeHTTP data. +// +// Optionally return a clean up function that will be invoked right after the route execution. +type EventFactoryFunc[T hook.Resolver] func(w http.ResponseWriter, r *http.Request) (T, EventCleanupFunc) + +// Router defines a thin wrapper around the standard Go [http.ServeMux] by +// adding support for routing sub-groups, middlewares and other common utils. +// +// Example: +// +// r := NewRouter[*MyEvent](eventFactory) +// +// // middlewares +// r.BindFunc(m1, m2) +// +// // routes +// r.GET("/test", handler1) +// +// // sub-routers/groups +// api := r.Group("/api") +// api.GET("/admins", handler2) +// +// // generate a http.ServeMux instance based on the router configurations +// mux, _ := r.BuildMux() +// +// http.ListenAndServe("localhost:8090", mux) +type Router[T hook.Resolver] struct { + // @todo consider renaming the type to just Group and replace the embed type + // with an alias after Go 1.24 adds support for generic type aliases + *RouterGroup[T] + + eventFactory EventFactoryFunc[T] +} + +// NewRouter creates a new Router instance with the provided event factory function. +func NewRouter[T hook.Resolver](eventFactory EventFactoryFunc[T]) *Router[T] { + return &Router[T]{ + RouterGroup: &RouterGroup[T]{}, + eventFactory: eventFactory, + } +} + +// BuildMux constructs a new mux [http.Handler] instance from the current router configurations. +func (r *Router[T]) BuildMux() (http.Handler, error) { + // Note that some of the default std Go handlers like the [http.NotFoundHandler] + // cannot be currently extended and requires defining a custom "catch-all" route + // so that the group middlewares could be executed. + // + // https://github.com/golang/go/issues/65648 + if !r.HasRoute("", "/") { + r.Route("", "/", func(e T) error { + return NewNotFoundError("", nil) + }) + } + + mux := http.NewServeMux() + + if err := r.loadMux(mux, r.RouterGroup, nil); err != nil { + return nil, err + } + + return mux, nil +} + +func (r *Router[T]) loadMux(mux *http.ServeMux, group *RouterGroup[T], parents []*RouterGroup[T]) error { + for _, child := range group.children { + switch v := child.(type) { + case *RouterGroup[T]: + if err := r.loadMux(mux, v, append(parents, group)); err != nil { + return err + } + case *Route[T]: + routeHook := &hook.Hook[T]{} + + var pattern string + + if v.Method != "" { + pattern = v.Method + " " + } + + // add parent groups middlewares + for _, p := range parents { + pattern += p.Prefix + for _, h := range p.Middlewares { + if _, ok := p.excludedMiddlewares[h.Id]; !ok { + if _, ok = group.excludedMiddlewares[h.Id]; !ok { + if _, ok = v.excludedMiddlewares[h.Id]; !ok { + routeHook.Bind(h) + } + } + } + } + } + + // add current groups middlewares + pattern += group.Prefix + for _, h := range group.Middlewares { + if _, ok := group.excludedMiddlewares[h.Id]; !ok { + if _, ok = v.excludedMiddlewares[h.Id]; !ok { + routeHook.Bind(h) + } + } + } + + // add current route middlewares + pattern += v.Path + for _, h := range v.Middlewares { + if _, ok := v.excludedMiddlewares[h.Id]; !ok { + routeHook.Bind(h) + } + } + + mux.HandleFunc(pattern, func(resp http.ResponseWriter, req *http.Request) { + // wrap the response to add write and status tracking + resp = &ResponseWriter{ResponseWriter: resp} + + // wrap the request body to allow multiple reads + req.Body = &RereadableReadCloser{ReadCloser: req.Body} + + event, cleanupFunc := r.eventFactory(resp, req) + + // trigger the handler hook chain + err := routeHook.Trigger(event, v.Action) + if err != nil { + ErrorHandler(resp, req, err) + } + + if cleanupFunc != nil { + cleanupFunc() + } + }) + default: + return errors.New("invalid Group item type") + } + } + + return nil +} + +func ErrorHandler(resp http.ResponseWriter, req *http.Request, err error) { + if err == nil { + return + } + + if ok, _ := getWritten(resp); ok { + return // a response was already written (aka. already handled) + } + + header := resp.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "application/json") + } + + apiErr := ToApiError(err) + + resp.WriteHeader(apiErr.Status) + + if req.Method != http.MethodHead { + if jsonErr := json.NewEncoder(resp).Encode(apiErr); jsonErr != nil { + log.Println(jsonErr) // truly rare case, log to stderr only for dev purposes + } + } +} + +// ------------------------------------------------------------------- + +type WriteTracker interface { + // Written reports whether a write operation has occurred. + Written() bool +} + +type StatusTracker interface { + // Status reports the written response status code. + Status() int +} + +type flushErrorer interface { + FlushError() error +} + +var ( + _ WriteTracker = (*ResponseWriter)(nil) + _ StatusTracker = (*ResponseWriter)(nil) + _ http.Flusher = (*ResponseWriter)(nil) + _ http.Hijacker = (*ResponseWriter)(nil) + _ http.Pusher = (*ResponseWriter)(nil) + _ io.ReaderFrom = (*ResponseWriter)(nil) + _ flushErrorer = (*ResponseWriter)(nil) +) + +// ResponseWriter wraps a http.ResponseWriter to track its write state. +type ResponseWriter struct { + http.ResponseWriter + + written bool + status int +} + +func (rw *ResponseWriter) WriteHeader(status int) { + if rw.written { + return + } + + rw.written = true + rw.status = status + rw.ResponseWriter.WriteHeader(status) +} + +func (rw *ResponseWriter) Write(b []byte) (int, error) { + if !rw.written { + rw.WriteHeader(http.StatusOK) + } + + return rw.ResponseWriter.Write(b) +} + +// Written implements [WriteTracker] and returns whether the current response body has been already written. +func (rw *ResponseWriter) Written() bool { + return rw.written +} + +// Written implements [StatusTracker] and returns the written status code of the current response. +func (rw *ResponseWriter) Status() int { + return rw.status +} + +// Flush implements [http.Flusher] and allows an HTTP handler to flush buffered data to the client. +// This method is no-op if the wrapped writer doesn't support it. +func (rw *ResponseWriter) Flush() { + _ = rw.FlushError() +} + +// FlushError is similar to [Flush] but returns [http.ErrNotSupported] +// if the wrapped writer doesn't support it. +func (rw *ResponseWriter) FlushError() error { + err := http.NewResponseController(rw.ResponseWriter).Flush() + if err == nil || !errors.Is(err, http.ErrNotSupported) { + rw.written = true + } + return err +} + +// Hijack implements [http.Hijacker] and allows an HTTP handler to take over the current connection. +func (rw *ResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return http.NewResponseController(rw.ResponseWriter).Hijack() +} + +// Pusher implements [http.Pusher] to indicate HTTP/2 server push support. +func (rw *ResponseWriter) Push(target string, opts *http.PushOptions) error { + w := rw.ResponseWriter + for { + switch p := w.(type) { + case http.Pusher: + return p.Push(target, opts) + case RWUnwrapper: + w = p.Unwrap() + default: + return http.ErrNotSupported + } + } +} + +// ReaderFrom implements [io.ReaderFrom] by checking if the underlying writer supports it. +// Otherwise calls [io.Copy]. +func (rw *ResponseWriter) ReadFrom(r io.Reader) (n int64, err error) { + if !rw.written { + rw.WriteHeader(http.StatusOK) + } + + w := rw.ResponseWriter + for { + switch rf := w.(type) { + case io.ReaderFrom: + return rf.ReadFrom(r) + case RWUnwrapper: + w = rf.Unwrap() + default: + return io.Copy(rw.ResponseWriter, r) + } + } +} + +// Unwrap returns the underlying ResponseWritter instance (usually used by [http.ResponseController]). +func (rw *ResponseWriter) Unwrap() http.ResponseWriter { + return rw.ResponseWriter +} + +func getWritten(rw http.ResponseWriter) (bool, error) { + for { + switch w := rw.(type) { + case WriteTracker: + return w.Written(), nil + case RWUnwrapper: + rw = w.Unwrap() + default: + return false, http.ErrNotSupported + } + } +} + +func getStatus(rw http.ResponseWriter) (int, error) { + for { + switch w := rw.(type) { + case StatusTracker: + return w.Status(), nil + case RWUnwrapper: + rw = w.Unwrap() + default: + return 0, http.ErrNotSupported + } + } +} diff --git a/server/core_backend-server/tools/router/router_test.go b/server/core_backend-server/tools/router/router_test.go new file mode 100644 index 0000000..c71cb70 --- /dev/null +++ b/server/core_backend-server/tools/router/router_test.go @@ -0,0 +1,253 @@ +package router_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/pocketbase/pocketbase/tools/hook" + "github.com/pocketbase/pocketbase/tools/router" +) + +func TestRouter(t *testing.T) { + calls := "" + + r := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*router.Event, router.EventCleanupFunc) { + return &router.Event{ + Response: w, + Request: r, + }, + func() { + calls += ":cleanup" + } + }) + + r.BindFunc(func(e *router.Event) error { + calls += "root_m:" + + err := e.Next() + + if err != nil { + calls += "/error" + } + + return err + }) + + r.Any("/any", func(e *router.Event) error { + calls += "/any" + return nil + }) + + r.GET("/a", func(e *router.Event) error { + calls += "/a" + return nil + }) + + g1 := r.Group("/a/b").BindFunc(func(e *router.Event) error { + calls += "a_b_group_m:" + return e.Next() + }) + g1.GET("/1", func(e *router.Event) error { + calls += "/1_get" + return nil + }).BindFunc(func(e *router.Event) error { + calls += "1_get_m:" + return e.Next() + }) + g1.POST("/1", func(e *router.Event) error { + calls += "/1_post" + return nil + }) + g1.GET("/{param}", func(e *router.Event) error { + calls += "/" + e.Request.PathValue("param") + return errors.New("test") // should be normalized to an ApiError + }) + + mux, err := r.BuildMux() + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(mux) + defer ts.Close() + + client := ts.Client() + + scenarios := []struct { + method string + path string + calls string + }{ + {http.MethodGet, "/any", "root_m:/any:cleanup"}, + {http.MethodOptions, "/any", "root_m:/any:cleanup"}, + {http.MethodPatch, "/any", "root_m:/any:cleanup"}, + {http.MethodPut, "/any", "root_m:/any:cleanup"}, + {http.MethodPost, "/any", "root_m:/any:cleanup"}, + {http.MethodDelete, "/any", "root_m:/any:cleanup"}, + // --- + {http.MethodPost, "/a", "root_m:/error:cleanup"}, // missing + {http.MethodGet, "/a", "root_m:/a:cleanup"}, + {http.MethodHead, "/a", "root_m:/a:cleanup"}, // auto registered with the GET + {http.MethodGet, "/a/b/1", "root_m:a_b_group_m:1_get_m:/1_get:cleanup"}, + {http.MethodHead, "/a/b/1", "root_m:a_b_group_m:1_get_m:/1_get:cleanup"}, + {http.MethodPost, "/a/b/1", "root_m:a_b_group_m:/1_post:cleanup"}, + {http.MethodGet, "/a/b/456", "root_m:a_b_group_m:/456/error:cleanup"}, + } + + for _, s := range scenarios { + t.Run(s.method+"_"+s.path, func(t *testing.T) { + calls = "" // reset + + req, err := http.NewRequest(s.method, ts.URL+s.path, nil) + if err != nil { + t.Fatal(err) + } + + _, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + + if calls != s.calls { + t.Fatalf("Expected calls\n%q\ngot\n%q", s.calls, calls) + } + }) + } +} + +func TestRouterUnbind(t *testing.T) { + calls := "" + + r := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*router.Event, router.EventCleanupFunc) { + return &router.Event{ + Response: w, + Request: r, + }, + func() { + calls += ":cleanup" + } + }) + r.Bind(&hook.Handler[*router.Event]{ + Id: "root_1", + Func: func(e *router.Event) error { + calls += "root_1:" + return e.Next() + }, + }) + r.Bind(&hook.Handler[*router.Event]{ + Id: "root_2", + Func: func(e *router.Event) error { + calls += "root_2:" + return e.Next() + }, + }) + r.Bind(&hook.Handler[*router.Event]{ + Id: "root_3", + Func: func(e *router.Event) error { + calls += "root_3:" + return e.Next() + }, + }) + r.GET("/action", func(e *router.Event) error { + calls += "root_action" + return nil + }).Unbind("root_1") + + ga := r.Group("/group_a") + ga.Unbind("root_1") + ga.Bind(&hook.Handler[*router.Event]{ + Id: "group_a_1", + Func: func(e *router.Event) error { + calls += "group_a_1:" + return e.Next() + }, + }) + ga.Bind(&hook.Handler[*router.Event]{ + Id: "group_a_2", + Func: func(e *router.Event) error { + calls += "group_a_2:" + return e.Next() + }, + }) + ga.Bind(&hook.Handler[*router.Event]{ + Id: "group_a_3", + Func: func(e *router.Event) error { + calls += "group_a_3:" + return e.Next() + }, + }) + ga.GET("/action", func(e *router.Event) error { + calls += "group_a_action" + return nil + }).Unbind("root_2", "group_b_1", "group_a_1") + + gb := r.Group("/group_b") + gb.Unbind("root_2") + gb.Bind(&hook.Handler[*router.Event]{ + Id: "group_b_1", + Func: func(e *router.Event) error { + calls += "group_b_1:" + return e.Next() + }, + }) + gb.Bind(&hook.Handler[*router.Event]{ + Id: "group_b_2", + Func: func(e *router.Event) error { + calls += "group_b_2:" + return e.Next() + }, + }) + gb.Bind(&hook.Handler[*router.Event]{ + Id: "group_b_3", + Func: func(e *router.Event) error { + calls += "group_b_3:" + return e.Next() + }, + }) + gb.GET("/action", func(e *router.Event) error { + calls += "group_b_action" + return nil + }).Unbind("group_b_3", "group_a_3", "root_3") + + mux, err := r.BuildMux() + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(mux) + defer ts.Close() + + client := ts.Client() + + scenarios := []struct { + method string + path string + calls string + }{ + {http.MethodGet, "/action", "root_2:root_3:root_action:cleanup"}, + {http.MethodGet, "/group_a/action", "root_3:group_a_2:group_a_3:group_a_action:cleanup"}, + {http.MethodGet, "/group_b/action", "root_1:group_b_1:group_b_2:group_b_action:cleanup"}, + } + + for _, s := range scenarios { + t.Run(s.method+"_"+s.path, func(t *testing.T) { + calls = "" // reset + + req, err := http.NewRequest(s.method, ts.URL+s.path, nil) + if err != nil { + t.Fatal(err) + } + + _, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + + if calls != s.calls { + t.Fatalf("Expected calls\n%q\ngot\n%q", s.calls, calls) + } + }) + } +} diff --git a/server/core_backend-server/tools/router/unmarshal_request_data.go b/server/core_backend-server/tools/router/unmarshal_request_data.go new file mode 100644 index 0000000..099a915 --- /dev/null +++ b/server/core_backend-server/tools/router/unmarshal_request_data.go @@ -0,0 +1,342 @@ +package router + +import ( + "encoding" + "encoding/json" + "errors" + "reflect" + "regexp" + "strconv" +) + +var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() + +// JSONPayloadKey is the key for the special UnmarshalRequestData case +// used for reading serialized json payload without normalization. +const JSONPayloadKey string = "@jsonPayload" + +// UnmarshalRequestData unmarshals url.Values type of data (query, multipart/form-data, etc.) into dst. +// +// dst must be a pointer to a map[string]any or struct. +// +// If dst is a map[string]any, each data value will be inferred and +// converted to its bool, numeric, or string equivalent value +// (refer to inferValue() for the exact rules). +// +// If dst is a struct, the following field types are supported: +// - bool +// - string +// - int, int8, int16, int32, int64 +// - uint, uint8, uint16, uint32, uint64 +// - float32, float64 +// - serialized json string if submitted under the special "@jsonPayload" key +// - encoding.TextUnmarshaler +// - pointer and slice variations of the above primitives (ex. *string, []string, *[]string []*string, etc.) +// - named/anonymous struct fields +// Dot-notation is used to target nested fields, ex. "nestedStructField.title". +// - embedded struct fields +// The embedded struct fields are treated by default as if they were defined in their parent struct. +// If the embedded struct has a tag matching structTagKey then to set its fields the data keys must be prefixed with that tag +// similar to the regular nested struct fields. +// +// structTagKey and structPrefix are used only when dst is a struct. +// +// structTagKey represents the tag to use to match a data entry with a struct field (defaults to "form"). +// If the struct field doesn't have the structTagKey tag, then the exported struct field name will be used as it is. +// +// structPrefix could be provided if all of the data keys are prefixed with a common string +// and you want the struct field to match only the value without the structPrefix +// (ex. for "user.name", "user.email" data keys and structPrefix "user", it will match "name" and "email" struct fields). +// +// Note that while the method was inspired by binders from echo, gorrila/schema, ozzo-routing +// and other similar common routing packages, it is not intended to be a drop-in replacement. +// +// @todo Consider adding support for dot-notation keys, in addition to the prefix, (ex. parent.child.title) to express nested object keys. +func UnmarshalRequestData(data map[string][]string, dst any, structTagKey string, structPrefix string) error { + if len(data) == 0 { + return nil // nothing to unmarshal + } + + dstValue := reflect.ValueOf(dst) + if dstValue.Kind() != reflect.Pointer { + return errors.New("dst must be a pointer") + } + + dstValue = dereference(dstValue) + + dstType := dstValue.Type() + + switch dstType.Kind() { + case reflect.Map: // map[string]any + if dstType.Elem().Kind() != reflect.Interface { + return errors.New("dst map value type must be any/interface{}") + } + + for k, v := range data { + if k == JSONPayloadKey { + continue // unmarshaled separately + } + + total := len(v) + + if total == 1 { + dstValue.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(inferValue(v[0]))) + } else { + normalized := make([]any, total) + for i, vItem := range v { + normalized[i] = inferValue(vItem) + } + dstValue.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(normalized)) + } + } + case reflect.Struct: + // set a default tag key + if structTagKey == "" { + structTagKey = "form" + } + + err := unmarshalInStructValue(data, dstValue, structTagKey, structPrefix) + if err != nil { + return err + } + default: + return errors.New("dst must be a map[string]any or struct") + } + + // @jsonPayload + // + // Special case to scan serialized json string without + // normalization alongside the other data values + // --------------------------------------------------------------- + jsonPayloadValues := data[JSONPayloadKey] + for _, payload := range jsonPayloadValues { + if err := json.Unmarshal([]byte(payload), dst); err != nil { + return err + } + } + + return nil +} + +// unmarshalInStructValue unmarshals data into the provided struct reflect.Value fields. +func unmarshalInStructValue( + data map[string][]string, + dstStructValue reflect.Value, + structTagKey string, + structPrefix string, +) error { + dstStructType := dstStructValue.Type() + + for i := 0; i < dstStructValue.NumField(); i++ { + fieldType := dstStructType.Field(i) + + tag := fieldType.Tag.Get(structTagKey) + + if tag == "-" || (!fieldType.Anonymous && !fieldType.IsExported()) { + continue // disabled or unexported non-anonymous struct field + } + + fieldValue := dereference(dstStructValue.Field(i)) + + ft := fieldType.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + + isSlice := ft.Kind() == reflect.Slice + if isSlice { + ft = ft.Elem() + } + + name := tag + if name == "" && !fieldType.Anonymous { + name = fieldType.Name + } + if name != "" && structPrefix != "" { + name = structPrefix + "." + name + } + + // (*)encoding.TextUnmarshaler field + // --- + if ft.Implements(textUnmarshalerType) || reflect.PointerTo(ft).Implements(textUnmarshalerType) { + values, ok := data[name] + if !ok || len(values) == 0 || !fieldValue.CanSet() { + continue // no value to load or the field cannot be set + } + + if isSlice { + n := len(values) + slice := reflect.MakeSlice(fieldValue.Type(), n, n) + for i, v := range values { + unmarshaler, ok := dereference(slice.Index(i)).Addr().Interface().(encoding.TextUnmarshaler) + if ok { + if err := unmarshaler.UnmarshalText([]byte(v)); err != nil { + return err + } + } + } + fieldValue.Set(slice) + } else { + unmarshaler, ok := fieldValue.Addr().Interface().(encoding.TextUnmarshaler) + if ok { + if err := unmarshaler.UnmarshalText([]byte(values[0])); err != nil { + return err + } + } + } + continue + } + + // "regular" field + // --- + if ft.Kind() != reflect.Struct { + values, ok := data[name] + if !ok || len(values) == 0 || !fieldValue.CanSet() { + continue // no value to load + } + + if isSlice { + n := len(values) + slice := reflect.MakeSlice(fieldValue.Type(), n, n) + for i, v := range values { + if err := setRegularReflectedValue(dereference(slice.Index(i)), v); err != nil { + return err + } + } + fieldValue.Set(slice) + } else { + if err := setRegularReflectedValue(fieldValue, values[0]); err != nil { + return err + } + } + continue + } + + // structs (embedded or nested) + // --- + // slice of structs + if isSlice { + // populating slice of structs is not supported at the moment + // because the filling rules are ambiguous + continue + } + + if tag != "" { + structPrefix = tag + } else { + structPrefix = name // name is empty for anonymous structs -> no prefix + } + + if err := unmarshalInStructValue(data, fieldValue, structTagKey, structPrefix); err != nil { + return err + } + } + + return nil +} + +// dereference returns the underlying value v points to. +func dereference(v reflect.Value) reflect.Value { + for v.Kind() == reflect.Ptr { + if v.IsNil() { + // initialize with a new value and continue searching + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + return v +} + +// setRegularReflectedValue sets and casts value into rv. +func setRegularReflectedValue(rv reflect.Value, value string) error { + switch rv.Kind() { + case reflect.String: + rv.SetString(value) + case reflect.Bool: + if value == "" { + value = "f" + } + + v, err := strconv.ParseBool(value) + if err != nil { + return err + } + + rv.SetBool(v) + case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: + if value == "" { + value = "0" + } + + v, err := strconv.ParseInt(value, 0, 64) + if err != nil { + return err + } + + rv.SetInt(v) + case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint: + if value == "" { + value = "0" + } + + v, err := strconv.ParseUint(value, 0, 64) + if err != nil { + return err + } + + rv.SetUint(v) + case reflect.Float32, reflect.Float64: + if value == "" { + value = "0" + } + + v, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + + rv.SetFloat(v) + case reflect.Interface: + if rv.CanSet() { + rv.Set(reflect.ValueOf(inferValue(value))) + } + default: + return errors.New("unknown value type " + rv.Kind().String()) + } + + return nil +} + +var inferNumberCharsRegex = regexp.MustCompile(`^[\-\.\d]+$`) + +// In order to support more seamlessly both json and multipart/form-data requests, +// the following normalization rules are applied for plain multipart string values: +// - "true" is converted to the json "true" +// - "false" is converted to the json "false" +// - numeric strings are converted to json number ONLY if the resulted +// minimal number string representation is the same as the provided raw string +// (aka. scientific notations, "Infinity", "0.0", "0001", etc. are kept as string) +// - any other string (empty string too) is left as it is +func inferValue(raw string) any { + switch raw { + case "": + return raw + case "true": + return true + case "false": + return false + default: + // try to convert to number + // + // note: expects the provided raw string to match exactly with the minimal string representation of the parsed float + if (raw[0] == '-' || (raw[0] >= '0' && raw[0] <= '9')) && + inferNumberCharsRegex.Match([]byte(raw)) { + v, err := strconv.ParseFloat(raw, 64) + if err == nil && strconv.FormatFloat(v, 'f', -1, 64) == raw { + return v + } + } + + return raw + } +} diff --git a/server/core_backend-server/tools/router/unmarshal_request_data_test.go b/server/core_backend-server/tools/router/unmarshal_request_data_test.go new file mode 100644 index 0000000..797719a --- /dev/null +++ b/server/core_backend-server/tools/router/unmarshal_request_data_test.go @@ -0,0 +1,483 @@ +package router_test + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/pocketbase/pocketbase/tools/router" +) + +func pointer[T any](val T) *T { + return &val +} + +func TestUnmarshalRequestData(t *testing.T) { + t.Parallel() + + mapData := map[string][]string{ + "number1": {"1"}, + "number2": {"2", "3"}, + "number3": {"2.1", "-3.4"}, + "number4": {"0", "-0", "0.0001"}, + "string0": {""}, + "string1": {"a"}, + "string2": {"b", "c"}, + "string3": { + "0.0", + "-0.0", + "000.1", + "000001", + "-000001", + "1.6E-35", + "-1.6E-35", + "10e100", + "1_000_000", + "1.000.000", + " 123 ", + "0b1", + "0xFF", + "1234A", + "Infinity", + "-Infinity", + "undefined", + "null", + }, + "bool1": {"true"}, + "bool2": {"true", "false"}, + "mixed": {"true", "123", "test"}, + "@jsonPayload": {`{"json_a":null,"json_b":123}`, `{"json_c":[1,2,3]}`}, + } + + structData := map[string][]string{ + "anyTag": {"a", "b"}, + "AnyPtr": {"b"}, + "AnySlice": {"a", "b", "false", ""}, + "anySlicePtrTag": {"d", "true"}, + "AnySliceOfPtr": {"f", "123.456"}, + + "stringTag": {"a", "b"}, + "StringPtr": {"b"}, + "StringSlice": {"a", "b", "c", ""}, + "stringSlicePtrTag": {"d", "e"}, + "StringSliceOfPtr": {"f", "g"}, + + "boolTag": {"true"}, + "BoolPtr": {"true"}, + "BoolSlice": {"true", "false", ""}, + "boolSlicePtrTag": {"false", "false", "true"}, + "BoolSliceOfPtr": {"false", "true", "false"}, + + "int8Tag": {"-1", "2"}, + "Int8Ptr": {"3"}, + "Int8Slice": {"4", "5", ""}, + "int8SlicePtrTag": {"5", "6"}, + "Int8SliceOfPtr": {"7", "8"}, + + "int16Tag": {"-1", "2"}, + "Int16Ptr": {"3"}, + "Int16Slice": {"4", "5", ""}, + "int16SlicePtrTag": {"5", "6"}, + "Int16SliceOfPtr": {"7", "8"}, + + "int32Tag": {"-1", "2"}, + "Int32Ptr": {"3"}, + "Int32Slice": {"4", "5", ""}, + "int32SlicePtrTag": {"5", "6"}, + "Int32SliceOfPtr": {"7", "8"}, + + "int64Tag": {"-1", "2"}, + "Int64Ptr": {"3"}, + "Int64Slice": {"4", "5", ""}, + "int64SlicePtrTag": {"5", "6"}, + "Int64SliceOfPtr": {"7", "8"}, + + "intTag": {"-1", "2"}, + "IntPtr": {"3"}, + "IntSlice": {"4", "5", ""}, + "intSlicePtrTag": {"5", "6"}, + "IntSliceOfPtr": {"7", "8"}, + + "uint8Tag": {"1", "2"}, + "Uint8Ptr": {"3"}, + "Uint8Slice": {"4", "5", ""}, + "uint8SlicePtrTag": {"5", "6"}, + "Uint8SliceOfPtr": {"7", "8"}, + + "uint16Tag": {"1", "2"}, + "Uint16Ptr": {"3"}, + "Uint16Slice": {"4", "5", ""}, + "uint16SlicePtrTag": {"5", "6"}, + "Uint16SliceOfPtr": {"7", "8"}, + + "uint32Tag": {"1", "2"}, + "Uint32Ptr": {"3"}, + "Uint32Slice": {"4", "5", ""}, + "uint32SlicePtrTag": {"5", "6"}, + "Uint32SliceOfPtr": {"7", "8"}, + + "uint64Tag": {"1", "2"}, + "Uint64Ptr": {"3"}, + "Uint64Slice": {"4", "5", ""}, + "uint64SlicePtrTag": {"5", "6"}, + "Uint64SliceOfPtr": {"7", "8"}, + + "uintTag": {"1", "2"}, + "UintPtr": {"3"}, + "UintSlice": {"4", "5", ""}, + "uintSlicePtrTag": {"5", "6"}, + "UintSliceOfPtr": {"7", "8"}, + + "float32Tag": {"-1.2"}, + "Float32Ptr": {"1.5", "2.0"}, + "Float32Slice": {"1", "2.3", "-0.3", ""}, + "float32SlicePtrTag": {"-1.3", "3"}, + "Float32SliceOfPtr": {"0", "1.2"}, + + "float64Tag": {"-1.2"}, + "Float64Ptr": {"1.5", "2.0"}, + "Float64Slice": {"1", "2.3", "-0.3", ""}, + "float64SlicePtrTag": {"-1.3", "3"}, + "Float64SliceOfPtr": {"0", "1.2"}, + + "timeTag": {"2009-11-10T15:00:00Z"}, + "TimePtr": {"2009-11-10T14:00:00Z", "2009-11-10T15:00:00Z"}, + "TimeSlice": {"2009-11-10T14:00:00Z", "2009-11-10T15:00:00Z"}, + "timeSlicePtrTag": {"2009-11-10T15:00:00Z", "2009-11-10T16:00:00Z"}, + "TimeSliceOfPtr": {"2009-11-10T17:00:00Z", "2009-11-10T18:00:00Z"}, + + // @jsonPayload fields + "@jsonPayload": { + `{"payloadA":"test", "shouldBeIgnored": "abc"}`, + `{"payloadB":[1,2,3], "payloadC":true}`, + }, + + // unexported fields or `-` tags + "unexperted": {"test"}, + "SkipExported": {"test"}, + "unexportedStructFieldWithoutTag.Name": {"test"}, + "unexportedStruct.Name": {"test"}, + + // structs + "StructWithoutTag.Name": {"test1"}, + "exportedStruct.Name": {"test2"}, + + // embedded + "embed_name": {"test3"}, + "embed2.embed_name2": {"test4"}, + } + + type embed1 struct { + Name string `form:"embed_name" json:"embed_name"` + } + + type embed2 struct { + Name string `form:"embed_name2" json:"embed_name2"` + } + + //nolint + type TestStruct struct { + Any any `form:"anyTag" query:"anyTag2"` + AnyPtr *any + AnySlice []any + AnySlicePtr *[]any `form:"anySlicePtrTag"` + AnySliceOfPtr []*any + + String string `form:"stringTag" query:"stringTag2"` + StringPtr *string + StringSlice []string + StringSlicePtr *[]string `form:"stringSlicePtrTag"` + StringSliceOfPtr []*string + + Bool bool `form:"boolTag" query:"boolTag2"` + BoolPtr *bool + BoolSlice []bool + BoolSlicePtr *[]bool `form:"boolSlicePtrTag"` + BoolSliceOfPtr []*bool + + Int8 int8 `form:"int8Tag" query:"int8Tag2"` + Int8Ptr *int8 + Int8Slice []int8 + Int8SlicePtr *[]int8 `form:"int8SlicePtrTag"` + Int8SliceOfPtr []*int8 + + Int16 int16 `form:"int16Tag" query:"int16Tag2"` + Int16Ptr *int16 + Int16Slice []int16 + Int16SlicePtr *[]int16 `form:"int16SlicePtrTag"` + Int16SliceOfPtr []*int16 + + Int32 int32 `form:"int32Tag" query:"int32Tag2"` + Int32Ptr *int32 + Int32Slice []int32 + Int32SlicePtr *[]int32 `form:"int32SlicePtrTag"` + Int32SliceOfPtr []*int32 + + Int64 int64 `form:"int64Tag" query:"int64Tag2"` + Int64Ptr *int64 + Int64Slice []int64 + Int64SlicePtr *[]int64 `form:"int64SlicePtrTag"` + Int64SliceOfPtr []*int64 + + Int int `form:"intTag" query:"intTag2"` + IntPtr *int + IntSlice []int + IntSlicePtr *[]int `form:"intSlicePtrTag"` + IntSliceOfPtr []*int + + Uint8 uint8 `form:"uint8Tag" query:"uint8Tag2"` + Uint8Ptr *uint8 + Uint8Slice []uint8 + Uint8SlicePtr *[]uint8 `form:"uint8SlicePtrTag"` + Uint8SliceOfPtr []*uint8 + + Uint16 uint16 `form:"uint16Tag" query:"uint16Tag2"` + Uint16Ptr *uint16 + Uint16Slice []uint16 + Uint16SlicePtr *[]uint16 `form:"uint16SlicePtrTag"` + Uint16SliceOfPtr []*uint16 + + Uint32 uint32 `form:"uint32Tag" query:"uint32Tag2"` + Uint32Ptr *uint32 + Uint32Slice []uint32 + Uint32SlicePtr *[]uint32 `form:"uint32SlicePtrTag"` + Uint32SliceOfPtr []*uint32 + + Uint64 uint64 `form:"uint64Tag" query:"uint64Tag2"` + Uint64Ptr *uint64 + Uint64Slice []uint64 + Uint64SlicePtr *[]uint64 `form:"uint64SlicePtrTag"` + Uint64SliceOfPtr []*uint64 + + Uint uint `form:"uintTag" query:"uintTag2"` + UintPtr *uint + UintSlice []uint + UintSlicePtr *[]uint `form:"uintSlicePtrTag"` + UintSliceOfPtr []*uint + + Float32 float32 `form:"float32Tag" query:"float32Tag2"` + Float32Ptr *float32 + Float32Slice []float32 + Float32SlicePtr *[]float32 `form:"float32SlicePtrTag"` + Float32SliceOfPtr []*float32 + + Float64 float64 `form:"float64Tag" query:"float64Tag2"` + Float64Ptr *float64 + Float64Slice []float64 + Float64SlicePtr *[]float64 `form:"float64SlicePtrTag"` + Float64SliceOfPtr []*float64 + + // encoding.TextUnmarshaler + Time time.Time `form:"timeTag" query:"timeTag2"` + TimePtr *time.Time + TimeSlice []time.Time + TimeSlicePtr *[]time.Time `form:"timeSlicePtrTag"` + TimeSliceOfPtr []*time.Time + + // @jsonPayload fields + JSONPayloadA string `form:"shouldBeIgnored" json:"payloadA"` + JSONPayloadB []int `json:"payloadB"` + JSONPayloadC bool `json:"-"` + + // unexported fields or `-` tags + unexported string + SkipExported string `form:"-"` + unexportedStructFieldWithoutTag struct { + Name string `json:"unexportedStructFieldWithoutTag_name"` + } + unexportedStructFieldWithTag struct { + Name string `json:"unexportedStructFieldWithTag_name"` + } `form:"unexportedStruct"` + + // structs + StructWithoutTag struct { + Name string `json:"StructWithoutTag_name"` + } + StructWithTag struct { + Name string `json:"StructWithTag_name"` + } `form:"exportedStruct"` + + // embedded + embed1 + embed2 `form:"embed2"` + } + + scenarios := []struct { + name string + data map[string][]string + dst any + tag string + prefix string + error bool + result string + }{ + { + name: "nil data", + data: nil, + dst: pointer(map[string]any{}), + error: false, + result: `{}`, + }, + { + name: "non-pointer map[string]any", + data: mapData, + dst: map[string]any{}, + error: true, + }, + { + name: "unsupported *map[string]string", + data: mapData, + dst: pointer(map[string]string{}), + error: true, + }, + { + name: "unsupported *map[string][]string", + data: mapData, + dst: pointer(map[string][]string{}), + error: true, + }, + { + name: "*map[string]any", + data: mapData, + dst: pointer(map[string]any{}), + result: `{"bool1":true,"bool2":[true,false],"json_a":null,"json_b":123,"json_c":[1,2,3],"mixed":[true,123,"test"],"number1":1,"number2":[2,3],"number3":[2.1,-3.4],"number4":[0,-0,0.0001],"string0":"","string1":"a","string2":["b","c"],"string3":["0.0","-0.0","000.1","000001","-000001","1.6E-35","-1.6E-35","10e100","1_000_000","1.000.000"," 123 ","0b1","0xFF","1234A","Infinity","-Infinity","undefined","null"]}`, + }, + { + name: "valid pointer struct (all fields)", + data: structData, + dst: &TestStruct{}, + result: `{"Any":"a","AnyPtr":"b","AnySlice":["a","b",false,""],"AnySlicePtr":["d",true],"AnySliceOfPtr":["f",123.456],"String":"a","StringPtr":"b","StringSlice":["a","b","c",""],"StringSlicePtr":["d","e"],"StringSliceOfPtr":["f","g"],"Bool":true,"BoolPtr":true,"BoolSlice":[true,false,false],"BoolSlicePtr":[false,false,true],"BoolSliceOfPtr":[false,true,false],"Int8":-1,"Int8Ptr":3,"Int8Slice":[4,5,0],"Int8SlicePtr":[5,6],"Int8SliceOfPtr":[7,8],"Int16":-1,"Int16Ptr":3,"Int16Slice":[4,5,0],"Int16SlicePtr":[5,6],"Int16SliceOfPtr":[7,8],"Int32":-1,"Int32Ptr":3,"Int32Slice":[4,5,0],"Int32SlicePtr":[5,6],"Int32SliceOfPtr":[7,8],"Int64":-1,"Int64Ptr":3,"Int64Slice":[4,5,0],"Int64SlicePtr":[5,6],"Int64SliceOfPtr":[7,8],"Int":-1,"IntPtr":3,"IntSlice":[4,5,0],"IntSlicePtr":[5,6],"IntSliceOfPtr":[7,8],"Uint8":1,"Uint8Ptr":3,"Uint8Slice":"BAUA","Uint8SlicePtr":"BQY=","Uint8SliceOfPtr":[7,8],"Uint16":1,"Uint16Ptr":3,"Uint16Slice":[4,5,0],"Uint16SlicePtr":[5,6],"Uint16SliceOfPtr":[7,8],"Uint32":1,"Uint32Ptr":3,"Uint32Slice":[4,5,0],"Uint32SlicePtr":[5,6],"Uint32SliceOfPtr":[7,8],"Uint64":1,"Uint64Ptr":3,"Uint64Slice":[4,5,0],"Uint64SlicePtr":[5,6],"Uint64SliceOfPtr":[7,8],"Uint":1,"UintPtr":3,"UintSlice":[4,5,0],"UintSlicePtr":[5,6],"UintSliceOfPtr":[7,8],"Float32":-1.2,"Float32Ptr":1.5,"Float32Slice":[1,2.3,-0.3,0],"Float32SlicePtr":[-1.3,3],"Float32SliceOfPtr":[0,1.2],"Float64":-1.2,"Float64Ptr":1.5,"Float64Slice":[1,2.3,-0.3,0],"Float64SlicePtr":[-1.3,3],"Float64SliceOfPtr":[0,1.2],"Time":"2009-11-10T15:00:00Z","TimePtr":"2009-11-10T14:00:00Z","TimeSlice":["2009-11-10T14:00:00Z","2009-11-10T15:00:00Z"],"TimeSlicePtr":["2009-11-10T15:00:00Z","2009-11-10T16:00:00Z"],"TimeSliceOfPtr":["2009-11-10T17:00:00Z","2009-11-10T18:00:00Z"],"payloadA":"test","payloadB":[1,2,3],"SkipExported":"","StructWithoutTag":{"StructWithoutTag_name":"test1"},"StructWithTag":{"StructWithTag_name":"test2"},"embed_name":"test3","embed_name2":"test4"}`, + }, + { + name: "non-pointer struct", + data: structData, + dst: TestStruct{}, + error: true, + }, + { + name: "invalid struct uint value", + data: map[string][]string{"uintTag": {"-1"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "invalid struct int value", + data: map[string][]string{"intTag": {"abc"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "invalid struct bool value", + data: map[string][]string{"boolTag": {"abc"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "invalid struct float value", + data: map[string][]string{"float64Tag": {"abc"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "invalid struct TextUnmarshaler value", + data: map[string][]string{"timeTag": {"123"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "custom tagKey", + data: map[string][]string{ + "tag1": {"a"}, + "tag2": {"b"}, + "tag3": {"c"}, + "Item": {"d"}, + }, + dst: &struct { + Item string `form:"tag1" query:"tag2" json:"tag2"` + }{}, + tag: "query", + result: `{"tag2":"b"}`, + }, + { + name: "custom prefix", + data: map[string][]string{ + "test.A": {"1"}, + "A": {"2"}, + "test.alias": {"3"}, + }, + dst: &struct { + A string + B string `form:"alias"` + }{}, + prefix: "test", + result: `{"A":"1","B":"3"}`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + err := router.UnmarshalRequestData(s.data, s.dst, s.tag, s.prefix) + + hasErr := err != nil + if hasErr != s.error { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.error, hasErr, err) + } + + if hasErr { + return + } + + raw, err := json.Marshal(s.dst) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(raw, []byte(s.result)) { + t.Fatalf("Expected dst \n%s\ngot\n%s", s.result, raw) + } + }) + } +} + +// note: extra unexported checks in addition to the above test as there +// is no easy way to print nested structs with all their fields. +func TestUnmarshalRequestDataUnexportedFields(t *testing.T) { + t.Parallel() + + //nolint:all + type TestStruct struct { + Exported string + + unexported string + // to ensure that the reflection doesn't take tags with higher priority than the exported state + unexportedWithTag string `form:"unexportedWithTag" json:"unexportedWithTag"` + } + + dst := &TestStruct{} + + err := router.UnmarshalRequestData(map[string][]string{ + "Exported": {"test"}, // just for reference + + "Unexported": {"test"}, + "unexported": {"test"}, + "UnexportedWithTag": {"test"}, + "unexportedWithTag": {"test"}, + }, dst, "", "") + + if err != nil { + t.Fatal(err) + } + + if dst.Exported != "test" { + t.Fatalf("Expected the Exported field to be %q, got %q", "test", dst.Exported) + } + + if dst.unexported != "" { + t.Fatalf("Expected the unexported field to remain empty, got %q", dst.unexported) + } + + if dst.unexportedWithTag != "" { + t.Fatalf("Expected the unexportedWithTag field to remain empty, got %q", dst.unexportedWithTag) + } +} diff --git a/server/core_backend-server/tools/routine/routine.go b/server/core_backend-server/tools/routine/routine.go new file mode 100644 index 0000000..afddd30 --- /dev/null +++ b/server/core_backend-server/tools/routine/routine.go @@ -0,0 +1,32 @@ +package routine + +import ( + "log" + "runtime/debug" + "sync" +) + +// FireAndForget executes f() in a new go routine and auto recovers if panic. +// +// **Note:** Use this only if you are not interested in the result of f() +// and don't want to block the parent go routine. +func FireAndForget(f func(), wg ...*sync.WaitGroup) { + if len(wg) > 0 && wg[0] != nil { + wg[0].Add(1) + } + + go func() { + if len(wg) > 0 && wg[0] != nil { + defer wg[0].Done() + } + + defer func() { + if err := recover(); err != nil { + log.Printf("RECOVERED FROM PANIC (safe to ignore): %v", err) + log.Println(string(debug.Stack())) + } + }() + + f() + }() +} diff --git a/server/core_backend-server/tools/routine/routine_test.go b/server/core_backend-server/tools/routine/routine_test.go new file mode 100644 index 0000000..dcb6ace --- /dev/null +++ b/server/core_backend-server/tools/routine/routine_test.go @@ -0,0 +1,27 @@ +package routine_test + +import ( + "sync" + "testing" + + "github.com/pocketbase/pocketbase/tools/routine" +) + +func TestFireAndForget(t *testing.T) { + called := false + + fn := func() { + called = true + panic("test") + } + + wg := &sync.WaitGroup{} + + routine.FireAndForget(fn, wg) + + wg.Wait() + + if !called { + t.Error("Expected fn to be called.") + } +} diff --git a/server/core_backend-server/tools/search/filter.go b/server/core_backend-server/tools/search/filter.go new file mode 100644 index 0000000..1e397e5 --- /dev/null +++ b/server/core_backend-server/tools/search/filter.go @@ -0,0 +1,1042 @@ +package search + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "strconv" + "strings" + + "github.com/ganigeorgiev/fexpr" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/pocketbase/pocketbase/tools/store" + "github.com/spf13/cast" +) + +// FilterData is a filter expression string following the `fexpr` package grammar. +// +// The filter string can also contain dbx placeholder parameters (eg. "title = {:name}"), +// that will be safely replaced and properly quoted inplace with the placeholderReplacements values. +// +// Example: +// +// var filter FilterData = "id = null || (name = 'test' && status = true) || (total >= {:min} && total <= {:max})" +// resolver := search.NewSimpleFieldResolver("id", "name", "status") +// expr, err := filter.BuildExpr(resolver, dbx.Params{"min": 100, "max": 200}) +type FilterData string + +func isPostgres() bool { + dbType := strings.ToLower(os.Getenv("DB_TYPE")) + return dbType == "postgres" || dbType == "postgresql" +} + +// parsedFilterData holds a cache with previously parsed filter data expressions +// (initialized with some preallocated empty data map) +var parsedFilterData = store.New(make(map[string][]fexpr.ExprGroup, 50)) + +// BuildExpr parses the current filter data and returns a new db WHERE expression. +// +// The filter string can also contain dbx placeholder parameters (eg. "title = {:name}"), +// that will be safely replaced and properly quoted inplace with the placeholderReplacements values. +// +// The parsed expressions are limited up to DefaultFilterExprLimit. +// Use [FilterData.BuildExprWithLimit] if you want to set a custom limit. +func (f FilterData) BuildExpr( + fieldResolver FieldResolver, + placeholderReplacements ...dbx.Params, +) (dbx.Expression, error) { + return f.BuildExprWithLimit(fieldResolver, DefaultFilterExprLimit, placeholderReplacements...) +} + +// BuildExpr parses the current filter data and returns a new db WHERE expression. +// +// The filter string can also contain dbx placeholder parameters (eg. "title = {:name}"), +// that will be safely replaced and properly quoted inplace with the placeholderReplacements values. +func (f FilterData) BuildExprWithLimit( + fieldResolver FieldResolver, + maxExpressions int, + placeholderReplacements ...dbx.Params, +) (dbx.Expression, error) { + raw := string(f) + + // replace the placeholder params in the raw string filter + for _, p := range placeholderReplacements { + for key, value := range p { + var replacement string + switch v := value.(type) { + case nil: + replacement = "null" + case bool, float64, float32, int, int64, int32, int16, int8, uint, uint64, uint32, uint16, uint8: + replacement = cast.ToString(v) + default: + replacement = cast.ToString(v) + + // try to json serialize as fallback + if replacement == "" { + raw, _ := json.Marshal(v) + replacement = string(raw) + } + + replacement = strconv.Quote(replacement) + } + raw = strings.ReplaceAll(raw, "{:"+key+"}", replacement) + } + } + + cacheKey := raw + "/" + strconv.Itoa(maxExpressions) + + if data, ok := parsedFilterData.GetOk(cacheKey); ok { + return buildParsedFilterExpr(data, fieldResolver, &maxExpressions) + } + + data, err := fexpr.Parse(raw) + if err != nil { + // depending on the users demand we may allow empty expressions + // (aka. expressions consisting only of whitespaces or comments) + // but for now disallow them as it seems unnecessary + // if errors.Is(err, fexpr.ErrEmpty) { + // return dbx.NewExp("1=1"), nil + // } + + return nil, err + } + + // store in cache + // (the limit size is arbitrary and it is there to prevent the cache growing too big) + parsedFilterData.SetIfLessThanLimit(cacheKey, data, 500) + + return buildParsedFilterExpr(data, fieldResolver, &maxExpressions) +} + +func buildParsedFilterExpr(data []fexpr.ExprGroup, fieldResolver FieldResolver, maxExpressions *int) (dbx.Expression, error) { + if len(data) == 0 { + return nil, fexpr.ErrEmpty + } + + result := &concatExpr{separator: " "} + + for _, group := range data { + var expr dbx.Expression + var exprErr error + + switch item := group.Item.(type) { + case fexpr.Expr: + if *maxExpressions <= 0 { + return nil, ErrFilterExprLimit + } + + *maxExpressions-- + + expr, exprErr = resolveTokenizedExpr(item, fieldResolver) + case fexpr.ExprGroup: + expr, exprErr = buildParsedFilterExpr([]fexpr.ExprGroup{item}, fieldResolver, maxExpressions) + case []fexpr.ExprGroup: + expr, exprErr = buildParsedFilterExpr(item, fieldResolver, maxExpressions) + default: + exprErr = errors.New("unsupported expression item") + } + + if exprErr != nil { + return nil, exprErr + } + + if len(result.parts) > 0 { + var op string + if group.Join == fexpr.JoinOr { + op = "OR" + } else { + op = "AND" + } + result.parts = append(result.parts, &opExpr{op}) + } + + result.parts = append(result.parts, expr) + } + + return result, nil +} + +func resolveTokenizedExpr(expr fexpr.Expr, fieldResolver FieldResolver) (dbx.Expression, error) { + lResult, lErr := resolveToken(expr.Left, fieldResolver) + if lErr != nil || lResult.Identifier == "" { + return nil, fmt.Errorf("invalid left operand %q - %v", expr.Left.Literal, lErr) + } + + rResult, rErr := resolveToken(expr.Right, fieldResolver) + if rErr != nil || rResult.Identifier == "" { + return nil, fmt.Errorf("invalid right operand %q - %v", expr.Right.Literal, rErr) + } + + return buildResolversExpr(lResult, expr.Op, rResult) +} + +func buildResolversExpr( + left *ResolverResult, + op fexpr.SignOp, + right *ResolverResult, +) (dbx.Expression, error) { + var expr dbx.Expression + + switch op { + case fexpr.SignEq, fexpr.SignAnyEq: + expr = resolveEqualExpr(true, left, right) + case fexpr.SignNeq, fexpr.SignAnyNeq: + expr = resolveEqualExpr(false, left, right) + case fexpr.SignLike, fexpr.SignAnyLike: + // the right side is a column and therefor wrap it with "%" for contains like behavior + if len(right.Params) == 0 { + expr = dbx.NewExp(fmt.Sprintf("%s LIKE ('%%' || %s || '%%') ESCAPE '\\'", left.Identifier, right.Identifier), left.Params) + } else { + expr = dbx.NewExp(fmt.Sprintf("%s LIKE %s ESCAPE '\\'", left.Identifier, right.Identifier), mergeParams(left.Params, wrapLikeParams(right.Params))) + } + case fexpr.SignNlike, fexpr.SignAnyNlike: + // the right side is a column and therefor wrap it with "%" for not-contains like behavior + if len(right.Params) == 0 { + expr = dbx.NewExp(fmt.Sprintf("%s NOT LIKE ('%%' || %s || '%%') ESCAPE '\\'", left.Identifier, right.Identifier), left.Params) + } else { + expr = dbx.NewExp(fmt.Sprintf("%s NOT LIKE %s ESCAPE '\\'", left.Identifier, right.Identifier), mergeParams(left.Params, wrapLikeParams(right.Params))) + } + case fexpr.SignLt, fexpr.SignAnyLt: + if isPostgres() { + expr = resolveOrderingExpr("<", left, right) + } else { + expr = dbx.NewExp(fmt.Sprintf("%s < %s", left.Identifier, right.Identifier), mergeParams(left.Params, right.Params)) + } + case fexpr.SignLte, fexpr.SignAnyLte: + if isPostgres() { + expr = resolveOrderingExpr("<=", left, right) + } else { + expr = dbx.NewExp(fmt.Sprintf("%s <= %s", left.Identifier, right.Identifier), mergeParams(left.Params, right.Params)) + } + case fexpr.SignGt, fexpr.SignAnyGt: + if isPostgres() { + expr = resolveOrderingExpr(">", left, right) + } else { + expr = dbx.NewExp(fmt.Sprintf("%s > %s", left.Identifier, right.Identifier), mergeParams(left.Params, right.Params)) + } + case fexpr.SignGte, fexpr.SignAnyGte: + if isPostgres() { + expr = resolveOrderingExpr(">=", left, right) + } else { + expr = dbx.NewExp(fmt.Sprintf("%s >= %s", left.Identifier, right.Identifier), mergeParams(left.Params, right.Params)) + } + } + + if expr == nil { + return nil, fmt.Errorf("unknown expression operator %q", op) + } + + // multi-match expressions + if !isAnyMatchOp(op) { + if left.MultiMatchSubQuery != nil && right.MultiMatchSubQuery != nil { + mm := &manyVsManyExpr{ + left: left, + right: right, + op: op, + } + + expr = dbx.Enclose(dbx.And(expr, mm)) + } else if left.MultiMatchSubQuery != nil { + mm := &manyVsOneExpr{ + noCoalesce: left.NoCoalesce, + subQuery: left.MultiMatchSubQuery, + op: op, + otherOperand: right, + } + + expr = dbx.Enclose(dbx.And(expr, mm)) + } else if right.MultiMatchSubQuery != nil { + mm := &manyVsOneExpr{ + noCoalesce: right.NoCoalesce, + subQuery: right.MultiMatchSubQuery, + op: op, + otherOperand: left, + inverse: true, + } + + expr = dbx.Enclose(dbx.And(expr, mm)) + } + } + + if left.AfterBuild != nil { + expr = left.AfterBuild(expr) + } + + if right.AfterBuild != nil { + expr = right.AfterBuild(expr) + } + + return expr, nil +} + +var normalizedIdentifiers = map[string]string{ + "null": "NULL", +} + +func getNormalizedIdentifiers() map[string]string { + if isPostgres() { + return map[string]string{ + "null": "NULL", + "true": "TRUE", + "false": "FALSE", + } + } + return map[string]string{ + "null": "NULL", + "true": "1", + "false": "0", + } +} + +func resolveToken(token fexpr.Token, fieldResolver FieldResolver) (*ResolverResult, error) { + switch token.Type { + case fexpr.TokenIdentifier: + // check for macros + // --- + if macroFunc, ok := identifierMacros[token.Literal]; ok { + placeholder := "t" + security.PseudorandomString(8) + + macroValue, err := macroFunc() + if err != nil { + return nil, err + } + + return &ResolverResult{ + Identifier: "{:" + placeholder + "}", + Params: dbx.Params{placeholder: macroValue}, + }, nil + } + + // custom resolver + // --- + result, err := fieldResolver.Resolve(token.Literal) + if err != nil || result.Identifier == "" { + for k, v := range getNormalizedIdentifiers() { + if strings.EqualFold(k, token.Literal) { + return &ResolverResult{Identifier: v}, nil + } + } + return nil, err + } + + return result, err + case fexpr.TokenText: + if isPostgres() { + // PostgreSQL only: + // if we know it is a emty string, use the empty string directly. + if token.Literal == "" { + return &ResolverResult{Identifier: `''`}, nil + } + } + + placeholder := "t" + security.PseudorandomString(8) + + return &ResolverResult{ + Identifier: "{:" + placeholder + "}", + Params: dbx.Params{placeholder: token.Literal}, + }, nil + case fexpr.TokenNumber: + if !isPostgres() { + placeholder := "t" + security.PseudorandomString(8) + + return &ResolverResult{ + Identifier: "{:" + placeholder + "}", + Params: dbx.Params{placeholder: cast.ToFloat64(token.Literal)}, + }, nil + } + + // PostgreSQL: + // handle a special case (where 1 = 1) where both left and right identifiers are numeric numbers. + // Eg: To prevent SQL injection, for query "1=1", dbx will generate "select xxx where $1 = $2" (prepared statement) with params [1, 1]. + // because we didn't specify the type for both $1 and $2, so PostgreSQL will treat them as text, and expect all params to be text types. + // And it failed to cast numeric type `1` to text `"1"` and throws an error: + // Error: `failed to encode args[0]: unable to encode 1 into text format for text (OID 25): cannot find encode plan;` + // Related Issue: + // - https://github.com/jackc/pgx/issues/798, + // - https://github.com/jackc/pgx/issues/2307 + // This is not caused by an issue of pgx, but by the strong type validation of PostgreSQL. + // + // To fix it, we have two options: + // Option 1: add a explict type cast: "{:" + placeholder + "}::numeric", + // Option 2: use the number literal directly without a param placeholder. + // We have to convert user input to float64 to remove any harmful characters to avoid SQL injection. + safeNumberStr := strconv.FormatFloat(cast.ToFloat64(token.Literal), 'f', -1, 64) + return &ResolverResult{ + Identifier: safeNumberStr, + Params: dbx.Params{}, + }, nil + case fexpr.TokenFunction: + fn, ok := TokenFunctions[token.Literal] + if !ok { + return nil, fmt.Errorf("unknown function %q", token.Literal) + } + + args, _ := token.Meta.([]fexpr.Token) + return fn(func(argToken fexpr.Token) (*ResolverResult, error) { + return resolveToken(argToken, fieldResolver) + }, args...) + } + + return nil, fmt.Errorf("unsupported token type %q", token.Type) +} + +// Resolves = and != expressions in an attempt to minimize the COALESCE +// usage and to gracefully handle null vs empty string normalizations. +// +// The expression `a = "" OR a is null` tends to perform better than +// `COALESCE(a, "") = ""` since the direct match can be accomplished +// with a seek while the COALESCE will induce a table scan. +func resolveEqualExpr(equal bool, left, right *ResolverResult) dbx.Expression { + isLeftEmpty := isEmptyIdentifier(left) || (len(left.Params) == 1 && hasEmptyParamValue(left)) + isRightEmpty := isEmptyIdentifier(right) || (len(right.Params) == 1 && hasEmptyParamValue(right)) + + /* SQLite: + equalOp := "=" + nullEqualOp := "IS" + */ + equalOp := "=" + nullEqualOp := "IS NOT DISTINCT FROM" + concatOp := "OR" + nullExpr := "IS NULL" + + if !isPostgres() { + nullEqualOp = "IS" + } + + if !equal { + if !isPostgres() { + // always use `IS NOT` instead of `!=` because direct non-equal comparisons + // to nullable column values that are actually NULL yields to NULL instead of TRUE, eg.: + // `'example' != nullableColumn` -> NULL even if nullableColumn row value is NULL + // Note: `select 'non-null-string' != NULL` returns NULL instead of True. + equalOp = "IS NOT" + nullEqualOp = equalOp + } else { + // PostgreSQL: + // In PostgreSQL, `IS NOT` only works for NULL values, but not for empty strings. + // `IS DISTINCT FROM` works like SQLite's `IS NOT`. + equalOp = "IS DISTINCT FROM" + nullEqualOp = equalOp + concatOp = "AND" + nullExpr = "IS NOT NULL" + } + } + + // no coalesce (eg. compare to a json field) + // a IS b + // a IS NOT b + if left.NoCoalesce || right.NoCoalesce { + if !isPostgres() { + return dbx.NewExp( + fmt.Sprintf("%s %s %s", left.Identifier, nullEqualOp, right.Identifier), + mergeParams(left.Params, right.Params), + ) + } + return dbx.NewExp( + typeAwareJoinNoCoalesce(left, nullEqualOp, right), + mergeParams(left.Params, right.Params), + ) + } + + // both operands are empty + if isLeftEmpty && isRightEmpty { + return dbx.NewExp(fmt.Sprintf("'' %s ''", equalOp), mergeParams(left.Params, right.Params)) + } + + // direct compare since at least one of the operands is known to be non-empty + // eg. a = 'example' + if isKnownNonEmptyIdentifier(left) || isKnownNonEmptyIdentifier(right) { + if !isPostgres() { + leftIdentifier := left.Identifier + if isLeftEmpty { + leftIdentifier = "''" + } + rightIdentifier := right.Identifier + if isRightEmpty { + rightIdentifier = "''" + } + return dbx.NewExp( + fmt.Sprintf("%s %s %s", leftIdentifier, equalOp, rightIdentifier), + mergeParams(left.Params, right.Params), + ) + } + return dbx.NewExp( + typeAwareJoinNoCoalesce(left, equalOp, right), + mergeParams(left.Params, right.Params), + ) + } + + // Hint: In PocketBase's world, NULL is treated the same as empty. + // "" = b OR b IS NULL + // "" IS NOT b AND b IS NOT NULL + if isLeftEmpty { + if !isPostgres() { + return dbx.NewExp( + fmt.Sprintf("('' %s %s %s %s %s)", equalOp, right.Identifier, concatOp, right.Identifier, nullExpr), + mergeParams(left.Params, right.Params), + ) + } + return dbx.NewExp( + fmt.Sprintf("('' %s %s %s %s %s)", equalOp, withNonJsonbType(right.Identifier, "text"), concatOp, right.Identifier, nullExpr), + mergeParams(left.Params, right.Params), + ) + } + + // a = "" OR a IS NULL + // a IS NOT "" AND a IS NOT NULL + if isRightEmpty { + if !isPostgres() { + return dbx.NewExp( + fmt.Sprintf("(%s %s '' %s %s %s)", left.Identifier, equalOp, concatOp, left.Identifier, nullExpr), + mergeParams(left.Params, right.Params), + ) + } + return dbx.NewExp( + fmt.Sprintf("(%s %s '' %s %s %s)", withNonJsonbType(left.Identifier, "text"), equalOp, concatOp, left.Identifier, nullExpr), + mergeParams(left.Params, right.Params), + ) + } + + if !isPostgres() { + // fallback to a COALESCE comparison + return dbx.NewExp( + fmt.Sprintf( + "COALESCE(%s, '') %s COALESCE(%s, '')", + left.Identifier, + equalOp, + right.Identifier, + ), + mergeParams(left.Params, right.Params), + ) + } + // PostgreSQL: + return dbx.NewExp( + fmt.Sprintf("%s %s %s", castToJsonb(left), nullEqualOp, castToJsonb(right)), + mergeParams(left.Params, right.Params), + ) +} + +// PostgreSQL only: +func resolveOrderingExpr(op string, l, r *ResolverResult) dbx.Expression { + left := l.Identifier + right := r.Identifier + lType := inferDeterministicType(l) + rType := inferDeterministicType(r) + + // If both sides have different deterministic types, try to convert one side to the other side's type. + // Eg: + // - jsonb('2025') > 2024 => Invalid, Convert to numeric + if lType != "" && rType != "" && lType != rType { + // If either type is numeric, convert to numeric + if lType == "numeric" { + right = withNonJsonbType(right, "numeric") + } else if rType == "numeric" { + left = withNonJsonbType(left, "numeric") + } else { + // Otherwise, convert both sides to text type for comparison. + // + // Possible cases: + // - date vs non-numeric: '2025-05-01'::date > '2025-05-01'::text + // - bool vs non-numeric: true > 'true'::text + // - text vs non-numeric: 'abc'::text > '2025-05-01'::date + // - jsonb vs non-numeric: to_jsonb('abc') > '2025-05-01'::text + // + // We cannot cast date, bool, text, jsonb types to numeric types. (false::numeric throws errors) + // So we simply cast both sides to text type for comparison. + // + // Note: we cannot simply use `to_jsonb()` here to erase the type because + // jsonb does byte-wise comparison instead of semantic comparison. Eg: + // to_jsonb('2026'::text) < to_jsonb(2026) => Valid, returns false + left = withNonJsonbType(left, "text") + right = withNonJsonbType(right, "text") + } + } + + return dbx.NewExp( + fmt.Sprintf("%s %s %s", left, op, right), + mergeParams(l.Params, r.Params), + ) +} + +// PostgreSQL only: +// PostgreSQL lets us write '2024-09-03' and use it as a date, timestamp, text, etc., without explicit casts every time. +// Normally, when we use `SELECT col_text = 'abc'`, the type of 'abc' can be automatically infered to `text`. +// However, when used with `to_jsonb('abc')` function, the type of 'abc' is not determistic, because to_jsonb() can +// handle many different types. So we need to add explicit type hints before using in to_jsonb(). +// +// Currently, it only affects: +// 1. NULL +// 2. String Params in PreparedStatements. +// 3. Numeric Params in PreparedStatements. +// +// Only used with `to_jsonb` +func castToJsonb(identifier *ResolverResult) string { + if isNullIdentifier(identifier) { + return "to_jsonb(NULL::text)" + } + if tp := inferPolymorphicLiteral(identifier); tp != "" { + return fmt.Sprintf("to_jsonb(%s::%s)", identifier.Identifier, tp) + } + return fmt.Sprintf("to_jsonb(%s)", identifier.Identifier) +} + +// There are some json types: +// 1. null -> Undetermine Polymorphic Type, can be any PostgreSQL types +// 2. text -> Undetermine Polymorphic Type, can be Date, TimeStamp, text, etc. +// 3. numbers -> Deterministic type, always numeric, no type cast needed +// 4. bool -> Deterministic type, always boolean, no type cast needed +// +// Only NULL and text types are considered polymorphic types. +func inferPolymorphicLiteral(result *ResolverResult) string { + // Note: result cannot be "NULL" identifier when called in [inferPolymorphicLiteral], + // because we already handled "NULL" seperately before calling this function. + // See [resolveEqualExpr] for details. + if isNullIdentifier(result) { + return "null" + } + + if result.Identifier == `''` { + return "text" + } + + if len(result.Params) == 1 { + for _, p := range result.Params { + switch p.(type) { + case nil: + panic("Unexpected nil type, nil is supposed to be parsed as NULL identifier") + case string: + return "text" + } + } + } + return "" +} + +// There are some json types: +// 1. null -> Undetermine Polymorphic Type, can be any PostgreSQL types +// 2. text -> Undetermine Polymorphic Type, can be Date, TimeStamp, text, etc. +// 3. numbers -> Deterministic type, always numeric, no type cast needed +// 4. bool -> Deterministic type, always boolean, no type cast needed +func inferDeterministicType(result *ResolverResult) string { + // If there is a explict type cast suffix, then we can use it to determine the type. + match := regexRightMostTypeCast.FindStringSubmatch(strings.TrimRight(result.Identifier, " ")) + if len(match) > 0 { + // can be any explict type: "text", "jsonb", "numeric", etc. + return match[1] + } + + // If the type is boolean, we can use it directly. + if strings.ToLower(result.Identifier) == "true" || strings.ToLower(result.Identifier) == "false" { + return "boolean" + } + + // If the type is numbers, we can use it directly. + if _, err := strconv.ParseFloat(result.Identifier, 64); err == nil { + return "numeric" + } + if strings.HasPrefix(result.Identifier, "{:") && len(result.Params) == 1 { + for _, p := range result.Params { + switch p.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return "numeric" + } + } + } + + return "" +} + +var regexRightMostTypeCast = regexp.MustCompile(`::(\w+)$`) + +// PostgreSQL only: +// If either left or right identifier has a specific type cast, we need to add the same type cast to the other identifier. +func typeAwareJoinNoCoalesce(l *ResolverResult, op string, r *ResolverResult) string { + left := strings.TrimRight(l.Identifier, " ") + right := strings.TrimRight(r.Identifier, " ") + + leftType := inferDeterministicType(l) + rightType := inferDeterministicType(r) + if len(leftType) > 0 && len(rightType) > 0 { + // If left and right identifiers have different type cast, force cast both identifiers + // to `jsonb`` type to bypass PostgreSQL's strict type validation error. + if leftType != rightType { + if leftType != "jsonb" { + left = castToJsonb(l) + } + if rightType != "jsonb" { + right = castToJsonb(r) + } + } + // If both identifiers have the same type cast, return it directly. + return fmt.Sprintf("%s %s %s", left, op, right) + } + // If none of the identifiers have type cast + if len(leftType) == 0 && len(rightType) == 0 { + // Handle special cases: + // `PREPARE statement AS SELECT null IS DISTINCT FROM $1` will throw error: "could not determine data type of parameter $1" + if isNullIdentifier(l) && inferPolymorphicLiteral(r) == "text" { + right = withNonJsonbType(right, "text") + } else if isNullIdentifier(r) && inferPolymorphicLiteral(l) == "text" { + left = withNonJsonbType(left, "text") + } + return fmt.Sprintf("%s %s %s", left, op, right) + } + if len(leftType) > 0 { + if leftType == "jsonb" { + // implict cast is not possible for jsonb type + right = castToJsonb(r) + } + + // LeftType is Deterministic, RightType is Polymorphic, allow PostgreSQL to do auto implict cast. + return fmt.Sprintf("%s %s %s", left, op, right) + } + if len(rightType) > 0 { + if rightType == "jsonb" { + left = castToJsonb(l) + } + + return fmt.Sprintf("%s %s %s", left, op, right) + } + panic("should not reach here") +} + +// PostgreSQL only: +func withNonJsonbType(identifier string, targetType string) string { + // Note: + // DO NOT drop existing type cast before adding a new cast. + // Reason: `1::numeric::text` is valid but `1::text` is invalid. + suffix := "::" + targetType + if strings.HasSuffix(identifier, suffix) { + return identifier + } + return identifier + suffix +} + +func hasEmptyParamValue(result *ResolverResult) bool { + for _, p := range result.Params { + switch v := p.(type) { + case nil: + return true + case string: + if v == "" { + return true + } + } + } + + return false +} + +func isKnownNonEmptyIdentifier(result *ResolverResult) bool { + switch strings.ToLower(result.Identifier) { + case "1", "0", "false", `true`: + return true + } + + if len(result.Params) == 0 { + if _, err := strconv.ParseFloat(result.Identifier, 64); err == nil { + return true + } + } + + return len(result.Params) > 0 && !hasEmptyParamValue(result) && !isEmptyIdentifier(result) +} + +func isNullIdentifier(result *ResolverResult) bool { + return strings.EqualFold(result.Identifier, "null") +} + +func isEmptyIdentifier(result *ResolverResult) bool { + switch strings.ToLower(result.Identifier) { + case "", "null", "''", `""`, "``": + return true + default: + return false + } +} + +func isAnyMatchOp(op fexpr.SignOp) bool { + switch op { + case + fexpr.SignAnyEq, + fexpr.SignAnyNeq, + fexpr.SignAnyLike, + fexpr.SignAnyNlike, + fexpr.SignAnyLt, + fexpr.SignAnyLte, + fexpr.SignAnyGt, + fexpr.SignAnyGte: + return true + } + + return false +} + +// mergeParams returns new dbx.Params where each provided params item +// is merged in the order they are specified. +func mergeParams(params ...dbx.Params) dbx.Params { + result := dbx.Params{} + + for _, p := range params { + for k, v := range p { + result[k] = v + } + } + + return result +} + +// @todo consider adding support for custom single character wildcard +// +// wrapLikeParams wraps each provided param value string with `%` +// if the param doesn't contain an explicit wildcard (`%`) character already. +func wrapLikeParams(params dbx.Params) dbx.Params { + result := dbx.Params{} + + for k, v := range params { + vStr := cast.ToString(v) + if !containsUnescapedChar(vStr, '%') { + // note: this is done to minimize the breaking changes and to preserve the original autoescape behavior + vStr = escapeUnescapedChars(vStr, '\\', '%', '_') + vStr = "%" + vStr + "%" + } + result[k] = vStr + } + + return result +} + +func escapeUnescapedChars(str string, escapeChars ...rune) string { + rs := []rune(str) + total := len(rs) + result := make([]rune, 0, total) + + var match bool + + for i := total - 1; i >= 0; i-- { + if match { + // check if already escaped + if rs[i] != '\\' { + result = append(result, '\\') + } + match = false + } else { + for _, ec := range escapeChars { + if rs[i] == ec { + match = true + break + } + } + } + + result = append(result, rs[i]) + + // in case the matching char is at the beginning + if i == 0 && match { + result = append(result, '\\') + } + } + + // reverse + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + + return string(result) +} + +func containsUnescapedChar(str string, ch rune) bool { + var prev rune + + for _, c := range str { + if c == ch && prev != '\\' { + return true + } + + if c == '\\' && prev == '\\' { + prev = rune(0) // reset escape sequence + } else { + prev = c + } + } + + return false +} + +// ------------------------------------------------------------------- + +var _ dbx.Expression = (*opExpr)(nil) + +// opExpr defines an expression that contains a raw sql operator string. +type opExpr struct { + op string +} + +// Build converts the expression into a SQL fragment. +// +// Implements [dbx.Expression] interface. +func (e *opExpr) Build(db *dbx.DB, params dbx.Params) string { + return e.op +} + +// ------------------------------------------------------------------- + +var _ dbx.Expression = (*concatExpr)(nil) + +// concatExpr defines an expression that concatenates multiple +// other expressions with a specified separator. +type concatExpr struct { + separator string + parts []dbx.Expression +} + +// Build converts the expression into a SQL fragment. +// +// Implements [dbx.Expression] interface. +func (e *concatExpr) Build(db *dbx.DB, params dbx.Params) string { + if len(e.parts) == 0 { + return "" + } + + stringParts := make([]string, 0, len(e.parts)) + + for _, p := range e.parts { + if p == nil { + continue + } + + if sql := p.Build(db, params); sql != "" { + stringParts = append(stringParts, sql) + } + } + + // skip extra parenthesis for single concat expression + if len(stringParts) == 1 && + // check for already concatenated raw/plain expressions + !strings.Contains(strings.ToUpper(stringParts[0]), " AND ") && + !strings.Contains(strings.ToUpper(stringParts[0]), " OR ") { + return stringParts[0] + } + + return "(" + strings.Join(stringParts, e.separator) + ")" +} + +// ------------------------------------------------------------------- + +var _ dbx.Expression = (*manyVsManyExpr)(nil) + +// manyVsManyExpr constructs a multi-match many<->many db where expression. +// +// Expects leftSubQuery and rightSubQuery to return a subquery with a +// single "multiMatchValue" column. +type manyVsManyExpr struct { + left *ResolverResult + right *ResolverResult + op fexpr.SignOp +} + +// Build converts the expression into a SQL fragment. +// +// Implements [dbx.Expression] interface. +func (e *manyVsManyExpr) Build(db *dbx.DB, params dbx.Params) string { + if e.left.MultiMatchSubQuery == nil || e.right.MultiMatchSubQuery == nil { + return "0=1" + } + + lAlias := "__ml" + security.PseudorandomString(8) + rAlias := "__mr" + security.PseudorandomString(8) + + whereExpr, buildErr := buildResolversExpr( + &ResolverResult{ + NoCoalesce: e.left.NoCoalesce, + Identifier: "[[" + lAlias + ".multiMatchValue]]", + }, + e.op, + &ResolverResult{ + NoCoalesce: e.right.NoCoalesce, + Identifier: "[[" + rAlias + ".multiMatchValue]]", + // note: the AfterBuild needs to be handled only once and it + // doesn't matter whether it is applied on the left or right subquery operand + AfterBuild: dbx.Not, // inverse for the not-exist expression + }, + ) + + if buildErr != nil { + return "0=1" + } + + return fmt.Sprintf( + /* SQLite: + "NOT EXISTS (SELECT 1 FROM (%s) {{%s}} LEFT JOIN (%s) {{%s}} WHERE %s)", + */ + // PostgreSQL: + "NOT EXISTS (SELECT 1 FROM (%s) {{%s}} LEFT JOIN (%s) {{%s}} ON 1 = 1 WHERE %s)", + e.left.MultiMatchSubQuery.Build(db, params), + lAlias, + e.right.MultiMatchSubQuery.Build(db, params), + rAlias, + whereExpr.Build(db, params), + ) +} + +// ------------------------------------------------------------------- + +var _ dbx.Expression = (*manyVsOneExpr)(nil) + +// manyVsOneExpr constructs a multi-match many<->one db where expression. +// +// Expects subQuery to return a subquery with a single "multiMatchValue" column. +// +// You can set inverse=false to reverse the condition sides (aka. one<->many). +type manyVsOneExpr struct { + otherOperand *ResolverResult + subQuery dbx.Expression + op fexpr.SignOp + inverse bool + noCoalesce bool +} + +// Build converts the expression into a SQL fragment. +// +// Implements [dbx.Expression] interface. +func (e *manyVsOneExpr) Build(db *dbx.DB, params dbx.Params) string { + if e.subQuery == nil { + return "0=1" + } + + alias := "__sm" + security.PseudorandomString(8) + + r1 := &ResolverResult{ + NoCoalesce: e.noCoalesce, + Identifier: "[[" + alias + ".multiMatchValue]]", + AfterBuild: dbx.Not, // inverse for the not-exist expression + } + + r2 := &ResolverResult{ + Identifier: e.otherOperand.Identifier, + Params: e.otherOperand.Params, + } + + var whereExpr dbx.Expression + var buildErr error + + if e.inverse { + whereExpr, buildErr = buildResolversExpr(r2, e.op, r1) + } else { + whereExpr, buildErr = buildResolversExpr(r1, e.op, r2) + } + + if buildErr != nil { + return "0=1" + } + + return fmt.Sprintf( + "NOT EXISTS (SELECT 1 FROM (%s) {{%s}} WHERE %s)", + e.subQuery.Build(db, params), + alias, + whereExpr.Build(db, params), + ) +} diff --git a/server/core_backend-server/tools/search/filter_test.go b/server/core_backend-server/tools/search/filter_test.go new file mode 100644 index 0000000..a3fd197 --- /dev/null +++ b/server/core_backend-server/tools/search/filter_test.go @@ -0,0 +1,470 @@ +package search_test + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "slices" + "strings" + "testing" + "time" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/search" +) + +func TestFilterDataBuildExpr(t *testing.T) { + resolver := search.NewSimpleFieldResolver("test1", "test2", "test3", `^test4_\w+$`, `^test5\.[\w\.\:]*\w+$`) + + scenarios := []struct { + name string + filterData search.FilterData + expectError bool + expectPattern string + }{ + { + "empty", + "", + true, + "", + }, + { + "invalid format", + "(test1 > 1", + true, + "", + }, + { + "invalid operator", + "test1 + 123", + true, + "", + }, + { + "unknown field", + "test1 = 'example' && unknown > 1", + true, + "", + }, + { + "simple expression", + "test1 > 1", + false, + /* SQLite: + "[[test1]] > {:TEST}", + */ + // PostgreSQL: + "[[test1]] > 1", + }, + { + "empty string vs null", + "'' = null && null != ''", + false, + /* SQLite: + "('' = '' AND '' IS NOT '')", + */ + // PostgreSQL: + "('' = '' AND '' IS DISTINCT FROM '')", + }, + { + "like with 2 columns", + "test1 ~ test2", + false, + "[[test1]] LIKE ('%' || [[test2]] || '%') ESCAPE '\\'", + }, + { + "like with right column operand", + "'lorem' ~ test1", + false, + "{:TEST} LIKE ('%' || [[test1]] || '%') ESCAPE '\\'", + }, + { + "like with left column operand and text as right operand", + "test1 ~ 'lorem'", + false, + "[[test1]] LIKE {:TEST} ESCAPE '\\'", + }, + { + "not like with 2 columns", + "test1 !~ test2", + false, + "[[test1]] NOT LIKE ('%' || [[test2]] || '%') ESCAPE '\\'", + }, + { + "not like with right column operand", + "'lorem' !~ test1", + false, + "{:TEST} NOT LIKE ('%' || [[test1]] || '%') ESCAPE '\\'", + }, + { + "like with left column operand and text as right operand", + "test1 !~ 'lorem'", + false, + "[[test1]] NOT LIKE {:TEST} ESCAPE '\\'", + }, + { + "nested json no coalesce", + "test5.a = test5.b || test5.c != test5.d", + false, + /* SQLite: + "(JSON_EXTRACT([[test5]], '$.a') IS JSON_EXTRACT([[test5]], '$.b') OR JSON_EXTRACT([[test5]], '$.c') IS NOT JSON_EXTRACT([[test5]], '$.d'))", + , + */ + // PostgreSQL: + `(JSON_QUERY([[test5]]::jsonb, '$.a')::jsonb IS NOT DISTINCT FROM JSON_QUERY([[test5]]::jsonb, '$.b')::jsonb OR JSON_QUERY([[test5]]::jsonb, '$.c')::jsonb IS DISTINCT FROM JSON_QUERY([[test5]]::jsonb, '$.d')::jsonb)`, + }, + { + "macros", + ` + test4_1 > @now && + test4_2 > @second && + test4_3 > @minute && + test4_4 > @hour && + test4_5 > @day && + test4_6 > @year && + test4_7 > @month && + test4_9 > @weekday && + test4_9 > @todayStart && + test4_10 > @todayEnd && + test4_11 > @monthStart && + test4_12 > @monthEnd && + test4_13 > @yearStart && + test4_14 > @yearEnd + `, + false, + /* SQLite: + "([[test4_1]] > {:TEST} AND [[test4_2]] > {:TEST} AND [[test4_3]] > {:TEST} AND [[test4_4]] > {:TEST} AND [[test4_5]] > {:TEST} AND [[test4_6]] > {:TEST} AND [[test4_7]] > {:TEST} AND [[test4_9]] > {:TEST} AND [[test4_9]] > {:TEST} AND [[test4_10]] > {:TEST} AND [[test4_11]] > {:TEST} AND [[test4_12]] > {:TEST} AND [[test4_13]] > {:TEST} AND [[test4_14]] > {:TEST})", + */ + // PostgreSQL: + `([[test4_1]] > {:TEST} AND [[test4_2]] > {:TEST} AND [[test4_3]] > {:TEST} AND [[test4_4]] > {:TEST} AND [[test4_5]] > {:TEST} AND [[test4_6]] > {:TEST} AND [[test4_7]] > {:TEST} AND [[test4_9]] > {:TEST} AND [[test4_9]] > {:TEST} AND [[test4_10]] > {:TEST} AND [[test4_11]] > {:TEST} AND [[test4_12]] > {:TEST} AND [[test4_13]] > {:TEST} AND [[test4_14]] > {:TEST})`, + }, + { + "complex expression", + "((test1 > 1) || (test2 != 2)) && test3 ~ '%%example' && test4_sub = null", + false, + /* SQLite: + "(([[test1]] > {:TEST} OR [[test2]] IS NOT {:TEST}) AND [[test3]] LIKE {:TEST} ESCAPE '\\' AND ([[test4_sub]] = '' OR [[test4_sub]] IS NULL))", + */ + // PostgreSQL: + `(([[test1]] > 1 OR [[test2]] IS DISTINCT FROM 2) AND [[test3]] LIKE {:TEST} ESCAPE '\' AND ([[test4_sub]]::text = '' OR [[test4_sub]] IS NULL))`, + }, + { + "combination of special literals (null, true, false)", + "test1=true && test2 != false && null = test3 || null != test4_sub", + false, + /* SQLite: + "([[test1]] = 1 AND [[test2]] IS NOT 0 AND ('' = [[test3]] OR [[test3]] IS NULL) OR ('' IS NOT [[test4_sub]] AND [[test4_sub]] IS NOT NULL))", + */ + // PostgreSQL: + `([[test1]] = TRUE AND [[test2]] IS DISTINCT FROM FALSE AND ('' = [[test3]]::text OR [[test3]] IS NULL) OR ('' IS DISTINCT FROM [[test4_sub]]::text AND [[test4_sub]] IS NOT NULL))`, + }, + { + "all operators", + "(test1 = test2 || test2 != test3) && (test2 ~ 'example' || test2 !~ '%%abc') && 'switch1%%' ~ test1 && 'switch2' !~ test2 && test3 > 1 && test3 >= 0 && test3 <= 4 && 2 < 5", + false, + /* SQLite: + "((COALESCE([[test1]], '') = COALESCE([[test2]], '') OR COALESCE([[test2]], '') IS NOT COALESCE([[test3]], '')) AND ([[test2]] LIKE {:TEST} ESCAPE '\\' OR [[test2]] NOT LIKE {:TEST} ESCAPE '\\') AND {:TEST} LIKE ('%' || [[test1]] || '%') ESCAPE '\\' AND {:TEST} NOT LIKE ('%' || [[test2]] || '%') ESCAPE '\\' AND [[test3]] > {:TEST} AND [[test3]] >= {:TEST} AND [[test3]] <= {:TEST} AND {:TEST} < {:TEST})", + */ + // PostgreSQL: + `((to_jsonb([[test1]]) IS NOT DISTINCT FROM to_jsonb([[test2]]) OR to_jsonb([[test2]]) IS DISTINCT FROM to_jsonb([[test3]])) AND ([[test2]] LIKE {:TEST} ESCAPE '\' OR [[test2]] NOT LIKE {:TEST} ESCAPE '\') AND {:TEST} LIKE ('%' || [[test1]] || '%') ESCAPE '\' AND {:TEST} NOT LIKE ('%' || [[test2]] || '%') ESCAPE '\' AND [[test3]] > 1 AND [[test3]] >= 0 AND [[test3]] <= 4 AND 2 < 5)`, + }, + { + "geoDistance function", + "geoDistance(1,2,3,4) < 567", + false, + /* SQLite: + "(6371 * acos(cos(radians({:TEST})) * cos(radians({:TEST})) * cos(radians({:TEST}) - radians({:TEST})) + sin(radians({:TEST})) * sin(radians({:TEST})))) < {:TEST}", + */ + // PostgreSQL: + `(6371 * acos(cos(radians(2)) * cos(radians(4)) * cos(radians(3) - radians(1)) + sin(radians(2)) * sin(radians(4)))) < 567`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + expr, err := s.filterData.BuildExpr(resolver) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err) + } + + if hasErr { + return + } + + dummyDB := &dbx.DB{} + + rawSql := expr.Build(dummyDB, dbx.Params{}) + + // replace TEST placeholder with .+ regex pattern + expectPattern := strings.ReplaceAll( + "^"+regexp.QuoteMeta(s.expectPattern)+"$", + "TEST", + `\w+`, + ) + + pattern := regexp.MustCompile(expectPattern) + if !pattern.MatchString(rawSql) { + t.Fatalf("[%s] Pattern %v don't match with expression: \n%v", s.name, expectPattern, rawSql) + } + }) + } +} + +func TestFilterDataBuildExprWithParams(t *testing.T) { + // create a dummy db + /* SQLite: + sqlDB, err := sql.Open("sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatal(err) + } + db := dbx.NewFromDB(sqlDB, "sqlite") + */ + // PostgreSQL: + db, cleanup := search.NewTestDBX() + defer cleanup() + + calledQueries := []string{} + db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { + calledQueries = append(calledQueries, sql) + } + db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) { + calledQueries = append(calledQueries, sql) + } + + date, err := time.Parse("2006-01-02", "2023-01-01") + if err != nil { + t.Fatal(err) + } + + resolver := search.NewSimpleFieldResolver(`^test\w+$`) + + filter := search.FilterData(` + test1 = {:test1} || + test2 = {:test2} || + test3a = {:test3} || + test3b = {:test3} || + test4 = {:test4} || + test5 = {:test5} || + test6 = {:test6} || + test7 = {:test7} || + test8 = {:test8} || + test9 = {:test9} || + test10 = {:test10} || + test11 = {:test11} || + test12 = {:test12} + `) + + replacements := []dbx.Params{ + {"test1": true}, + {"test2": false}, + {"test3": 123.456}, + {"test4": nil}, + {"test5": "", "test6": "simple", "test7": `'single_quotes'`, "test8": `"double_quotes"`, "test9": `escape\"quote`}, + {"test10": date}, + {"test11": []string{"a", "b", `"quote`}}, + {"test12": map[string]any{"a": 123, "b": `quote"`}}, + } + + expr, err := filter.BuildExpr(resolver, replacements...) + if err != nil { + t.Fatal(err) + } + + db.Select().Where(expr).Build().Execute() + + if len(calledQueries) != 1 { + t.Fatalf("Expected 1 query, got %d", len(calledQueries)) + } + + /* SQLite: + expectedQuery := `SELECT * WHERE ([[test1]] = 1 OR [[test2]] = 0 OR [[test3a]] = 123.456 OR [[test3b]] = 123.456 OR ([[test4]] = '' OR [[test4]] IS NULL) OR [[test5]] = '""' OR [[test6]] = 'simple' OR [[test7]] = '''single_quotes''' OR [[test8]] = '"double_quotes"' OR [[test9]] = 'escape\\"quote' OR [[test10]] = '2023-01-01 00:00:00 +0000 UTC' OR [[test11]] = '["a","b","\\"quote"]' OR [[test12]] = '{"a":123,"b":"quote\\""}')` + */ + // PostgreSQL: + expectedQuery := `SELECT * WHERE ([[test1]] = TRUE OR [[test2]] = FALSE OR [[test3a]] = 123.456 OR [[test3b]] = 123.456 OR ([[test4]]::text = '' OR [[test4]] IS NULL) OR [[test5]] = '""' OR [[test6]] = 'simple' OR [[test7]] = '''single_quotes''' OR [[test8]] = '"double_quotes"' OR [[test9]] = 'escape\\"quote' OR [[test10]] = '2023-01-01 00:00:00 +0000 UTC' OR [[test11]] = '["a","b","\\"quote"]' OR [[test12]] = '{"a":123,"b":"quote\\""}')` + if expectedQuery != calledQueries[0] { + t.Fatalf("Expected query \n%s, \ngot \n%s", expectedQuery, calledQueries[0]) + } +} + +func TestFilterDataBuildExprWithLimit(t *testing.T) { + resolver := search.NewSimpleFieldResolver(`^\w+$`) + + scenarios := []struct { + limit int + filter search.FilterData + expectError bool + }{ + {1, "1 = 1", false}, + {0, "1 = 1", true}, // new cache entry should be created + {2, "1 = 1 || 1 = 1", false}, + {1, "1 = 1 || 1 = 1", true}, + {3, "1 = 1 || 1 = 1", false}, + {6, "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", false}, + {5, "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", true}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("limit_%d:%d", i, s.limit), func(t *testing.T) { + _, err := s.filter.BuildExprWithLimit(resolver, s.limit) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) + } + }) + } +} + +func TestLikeParamsWrapping(t *testing.T) { + // create a dummy db + /* SQLite: + sqlDB, err := sql.Open("sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatal(err) + } + db := dbx.NewFromDB(sqlDB, "sqlite") + */ + // PostgreSQL: + db, cleanup := search.NewTestDBX() + defer cleanup() + + calledQueries := []string{} + db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { + calledQueries = append(calledQueries, sql) + } + db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) { + calledQueries = append(calledQueries, sql) + } + + resolver := search.NewSimpleFieldResolver(`^test\w+$`) + + filter := search.FilterData(` + test1 ~ {:p1} || + test2 ~ {:p2} || + test3 ~ {:p3} || + test4 ~ {:p4} || + test5 ~ {:p5} || + test6 ~ {:p6} || + test7 ~ {:p7} || + test8 ~ {:p8} || + test9 ~ {:p9} || + test10 ~ {:p10} || + test11 ~ {:p11} || + test12 ~ {:p12} + `) + + replacements := []dbx.Params{ + {"p1": `abc`}, + {"p2": `ab%c`}, + {"p3": `ab\%c`}, + {"p4": `%ab\%c`}, + {"p5": `ab\\%c`}, + {"p6": `ab\\\%c`}, + {"p7": `ab_c`}, + {"p8": `ab\_c`}, + {"p9": `%ab_c`}, + {"p10": `ab\c`}, + {"p11": `_ab\c_`}, + {"p12": `ab\c%`}, + } + + expr, err := filter.BuildExpr(resolver, replacements...) + if err != nil { + t.Fatal(err) + } + + db.Select().Where(expr).Build().Execute() + + if len(calledQueries) != 1 { + t.Fatalf("Expected 1 query, got %d", len(calledQueries)) + } + + expectedQuery := `SELECT * WHERE ([[test1]] LIKE '%abc%' ESCAPE '\' OR [[test2]] LIKE 'ab%c' ESCAPE '\' OR [[test3]] LIKE 'ab\\%c' ESCAPE '\' OR [[test4]] LIKE '%ab\\%c' ESCAPE '\' OR [[test5]] LIKE 'ab\\\\%c' ESCAPE '\' OR [[test6]] LIKE 'ab\\\\\\%c' ESCAPE '\' OR [[test7]] LIKE '%ab\_c%' ESCAPE '\' OR [[test8]] LIKE '%ab\\\_c%' ESCAPE '\' OR [[test9]] LIKE '%ab_c' ESCAPE '\' OR [[test10]] LIKE '%ab\\c%' ESCAPE '\' OR [[test11]] LIKE '%\_ab\\c\_%' ESCAPE '\' OR [[test12]] LIKE 'ab\\c%' ESCAPE '\')` + if expectedQuery != calledQueries[0] { + t.Fatalf("Expected query \n%s, \ngot \n%s", expectedQuery, calledQueries[0]) + } +} + +// PostgreSQL Only: +func TestEqualOperater(t *testing.T) { + db, cleanup := search.NewTestDBX() + defer cleanup() + + _, err := db.DB.DB().Exec(` + CREATE TABLE demo ( + id INTEGER, + col_int INTEGER, + col_bool BOOLEAN, + col_text TEXT + ); + INSERT INTO demo (id, col_int, col_bool, col_text) VALUES (1, NULL, NULL, NULL); + INSERT INTO demo (id, col_int, col_bool, col_text) VALUES (2, 0, FALSE, ''); + INSERT INTO demo (id, col_int, col_bool, col_text) VALUES (3, 1, TRUE, 'abc'); + `) + if err != nil { + panic(err) + } + + scenarios := []struct { + filter string + ids []int + }{ + {"col_int = NULL", []int{1}}, + {"col_int != NULL", []int{2, 3}}, + {"col_int = 0", []int{2}}, + {"col_int = 1", []int{3}}, + {"col_bool = NULL", []int{1}}, + {"col_bool != NULL", []int{2, 3}}, + {"col_bool = false", []int{2}}, + {"col_bool = true", []int{3}}, + {"col_text = 'abc'", []int{3}}, + {"col_text = NULL", []int{1, 2}}, // For text columns, pocketbase treats NULL and empty string as equal + {"col_text != NULL", []int{3}}, // For text columns, pocketbase treats NULL and empty string as equal + {"col_text = ''", []int{1, 2}}, // For text columns, pocketbase treats NULL and empty string as equal + } + + for _, s := range scenarios { + t.Run(s.filter, func(t *testing.T) { + rows, err := QueryRowsByFilter(db.DB, s.filter) + if err != nil { + t.Fatalf("Error executing filter '%s': %v", s.filter, err) + return + } + if len(rows) != len(s.ids) { + t.Fatalf("Filter '%s': expected %d rows, got %d", s.filter, len(s.ids), len(rows)) + } + + rowIds := make([]int, len(rows)) + for i, row := range rows { + rowIds[i] = row.Id + } + + if !slices.Equal(rowIds, s.ids) { + t.Fatalf("Filter '%s': expected IDs %v, got %v", s.filter, s.ids, rowIds) + } + }) + } +} + +func QueryRowsByFilter(db *dbx.DB, filter string) ([]Row, error) { + var rows []Row + + resolver := search.NewSimpleFieldResolver(`^col_\w+$`) + expr, _ := search.FilterData(filter).BuildExpr(resolver) + print(expr.Build(db, dbx.Params{})) + err := db.Select("id", "col_int", "col_bool", "col_text").From("demo").Where(expr).Build().All(&rows) + if err != nil { + return nil, err + } + return rows, nil +} + +type Row struct { + Id int `db:"id"` + ColInt *int `db:"col_int"` + ColBool *bool `db:"col_bool"` + ColText *string `db:"col_text"` +} diff --git a/server/core_backend-server/tools/search/identifier_macros.go b/server/core_backend-server/tools/search/identifier_macros.go new file mode 100644 index 0000000..b0314de --- /dev/null +++ b/server/core_backend-server/tools/search/identifier_macros.go @@ -0,0 +1,135 @@ +package search + +import ( + "fmt" + "time" + + "github.com/pocketbase/pocketbase/tools/types" +) + +// note: used primarily for the tests +var timeNow = func() time.Time { + return time.Now() +} + +var identifierMacros = map[string]func() (any, error){ + "@now": func() (any, error) { + today := timeNow().UTC() + + d, err := types.ParseDateTime(today) + if err != nil { + return "", fmt.Errorf("@now: %w", err) + } + + return d.String(), nil + }, + "@yesterday": func() (any, error) { + yesterday := timeNow().UTC().AddDate(0, 0, -1) + + d, err := types.ParseDateTime(yesterday) + if err != nil { + return "", fmt.Errorf("@yesterday: %w", err) + } + + return d.String(), nil + }, + "@tomorrow": func() (any, error) { + tomorrow := timeNow().UTC().AddDate(0, 0, 1) + + d, err := types.ParseDateTime(tomorrow) + if err != nil { + return "", fmt.Errorf("@tomorrow: %w", err) + } + + return d.String(), nil + }, + "@second": func() (any, error) { + return timeNow().UTC().Second(), nil + }, + "@minute": func() (any, error) { + return timeNow().UTC().Minute(), nil + }, + "@hour": func() (any, error) { + return timeNow().UTC().Hour(), nil + }, + "@day": func() (any, error) { + return timeNow().UTC().Day(), nil + }, + "@month": func() (any, error) { + return int(timeNow().UTC().Month()), nil + }, + "@weekday": func() (any, error) { + return int(timeNow().UTC().Weekday()), nil + }, + "@year": func() (any, error) { + return timeNow().UTC().Year(), nil + }, + "@todayStart": func() (any, error) { + today := timeNow().UTC() + start := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC) + + d, err := types.ParseDateTime(start) + if err != nil { + return "", fmt.Errorf("@todayStart: %w", err) + } + + return d.String(), nil + }, + "@todayEnd": func() (any, error) { + today := timeNow().UTC() + + start := time.Date(today.Year(), today.Month(), today.Day(), 23, 59, 59, 999999999, time.UTC) + + d, err := types.ParseDateTime(start) + if err != nil { + return "", fmt.Errorf("@todayEnd: %w", err) + } + + return d.String(), nil + }, + "@monthStart": func() (any, error) { + today := timeNow().UTC() + start := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) + + d, err := types.ParseDateTime(start) + if err != nil { + return "", fmt.Errorf("@monthStart: %w", err) + } + + return d.String(), nil + }, + "@monthEnd": func() (any, error) { + today := timeNow().UTC() + start := time.Date(today.Year(), today.Month(), 1, 23, 59, 59, 999999999, time.UTC) + end := start.AddDate(0, 1, -1) + + d, err := types.ParseDateTime(end) + if err != nil { + return "", fmt.Errorf("@monthEnd: %w", err) + } + + return d.String(), nil + }, + "@yearStart": func() (any, error) { + today := timeNow().UTC() + start := time.Date(today.Year(), 1, 1, 0, 0, 0, 0, time.UTC) + + d, err := types.ParseDateTime(start) + if err != nil { + return "", fmt.Errorf("@yearStart: %w", err) + } + + return d.String(), nil + }, + "@yearEnd": func() (any, error) { + today := timeNow().UTC() + end := time.Date(today.Year(), 12, 31, 23, 59, 59, 999999999, time.UTC) + + d, err := types.ParseDateTime(end) + if err != nil { + return "", fmt.Errorf("@yearEnd: %w", err) + } + + return d.String(), nil + }, +} diff --git a/server/core_backend-server/tools/search/identifier_macros_test.go b/server/core_backend-server/tools/search/identifier_macros_test.go new file mode 100644 index 0000000..0c58df4 --- /dev/null +++ b/server/core_backend-server/tools/search/identifier_macros_test.go @@ -0,0 +1,58 @@ +package search + +import ( + "testing" + "time" +) + +func TestIdentifierMacros(t *testing.T) { + originalTimeNow := timeNow + + timeNow = func() time.Time { + return time.Date(2023, 2, 3, 4, 5, 6, 7, time.UTC) + } + + testMacros := map[string]any{ + "@now": "2023-02-03 04:05:06.000Z", + "@yesterday": "2023-02-02 04:05:06.000Z", + "@tomorrow": "2023-02-04 04:05:06.000Z", + "@second": 6, + "@minute": 5, + "@hour": 4, + "@day": 3, + "@month": 2, + "@weekday": 5, + "@year": 2023, + "@todayStart": "2023-02-03 00:00:00.000Z", + "@todayEnd": "2023-02-03 23:59:59.999Z", + "@monthStart": "2023-02-01 00:00:00.000Z", + "@monthEnd": "2023-02-28 23:59:59.999Z", + "@yearStart": "2023-01-01 00:00:00.000Z", + "@yearEnd": "2023-12-31 23:59:59.999Z", + } + + if len(testMacros) != len(identifierMacros) { + t.Fatalf("Expected %d macros, got %d", len(testMacros), len(identifierMacros)) + } + + for key, expected := range testMacros { + t.Run(key, func(t *testing.T) { + macro, ok := identifierMacros[key] + if !ok { + t.Fatalf("Missing macro %s", key) + } + + result, err := macro() + if err != nil { + t.Fatal(err) + } + + if result != expected { + t.Fatalf("Expected %q, got %q", expected, result) + } + }) + } + + // restore + timeNow = originalTimeNow +} diff --git a/server/core_backend-server/tools/search/provider.go b/server/core_backend-server/tools/search/provider.go new file mode 100644 index 0000000..51dc3f6 --- /dev/null +++ b/server/core_backend-server/tools/search/provider.go @@ -0,0 +1,360 @@ +package search + +import ( + "errors" + "math" + "net/url" + "strconv" + "strings" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/inflector" + "golang.org/x/sync/errgroup" +) + +const ( + // DefaultPerPage specifies the default number of returned search result items. + DefaultPerPage int = 30 + + // DefaultFilterExprLimit specifies the default filter expressions limit. + DefaultFilterExprLimit int = 200 + + // DefaultSortExprLimit specifies the default sort expressions limit. + DefaultSortExprLimit int = 8 + + // MaxPerPage specifies the max allowed search result items returned in a single page. + MaxPerPage int = 1000 + + // MaxFilterLength specifies the max allowed individual search filter parsable length. + MaxFilterLength int = 3500 + + // MaxSortFieldLength specifies the max allowed individual sort field parsable length. + MaxSortFieldLength int = 255 +) + +// Common search errors. +var ( + ErrEmptyQuery = errors.New("search query is not set") + ErrSortExprLimit = errors.New("max sort expressions limit reached") + ErrFilterExprLimit = errors.New("max filter expressions limit reached") + ErrFilterLengthLimit = errors.New("max filter length limit reached") + ErrSortFieldLengthLimit = errors.New("max sort field length limit reached") +) + +// URL search query params +const ( + PageQueryParam string = "page" + PerPageQueryParam string = "perPage" + SortQueryParam string = "sort" + FilterQueryParam string = "filter" + SkipTotalQueryParam string = "skipTotal" +) + +// Result defines the returned search result structure. +type Result struct { + Items any `json:"items"` + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` +} + +// Provider represents a single configured search provider instance. +type Provider struct { + fieldResolver FieldResolver + query *dbx.SelectQuery + countCol string + sort []SortField + filter []FilterData + page int + perPage int + skipTotal bool + maxFilterExprLimit int + maxSortExprLimit int +} + +// NewProvider initializes and returns a new search provider. +// +// Example: +// +// baseQuery := db.Select("*").From("user") +// fieldResolver := search.NewSimpleFieldResolver("id", "name") +// models := []*YourDataStruct{} +// +// result, err := search.NewProvider(fieldResolver). +// Query(baseQuery). +// ParseAndExec("page=2&filter=id>0&sort=-email", &models) +func NewProvider(fieldResolver FieldResolver) *Provider { + return &Provider{ + fieldResolver: fieldResolver, + countCol: "id", + page: 1, + perPage: DefaultPerPage, + sort: []SortField{}, + filter: []FilterData{}, + maxFilterExprLimit: DefaultFilterExprLimit, + maxSortExprLimit: DefaultSortExprLimit, + } +} + +// MaxFilterExprLimit changes the default max allowed filter expressions. +// +// Note that currently the limit is applied individually for each separate filter. +func (s *Provider) MaxFilterExprLimit(max int) *Provider { + s.maxFilterExprLimit = max + return s +} + +// MaxSortExprLimit changes the default max allowed sort expressions. +func (s *Provider) MaxSortExprLimit(max int) *Provider { + s.maxSortExprLimit = max + return s +} + +// Query sets the base query that will be used to fetch the search items. +func (s *Provider) Query(query *dbx.SelectQuery) *Provider { + s.query = query + return s +} + +// SkipTotal changes the `skipTotal` field of the current search provider. +func (s *Provider) SkipTotal(skipTotal bool) *Provider { + s.skipTotal = skipTotal + return s +} + +// CountCol allows changing the default column (id) that is used +// to generate the COUNT SQL query statement. +// +// This field is ignored if skipTotal is true. +func (s *Provider) CountCol(name string) *Provider { + s.countCol = name + return s +} + +// Page sets the `page` field of the current search provider. +// +// Normalization on the `page` value is done during `Exec()`. +func (s *Provider) Page(page int) *Provider { + s.page = page + return s +} + +// PerPage sets the `perPage` field of the current search provider. +// +// Normalization on the `perPage` value is done during `Exec()`. +func (s *Provider) PerPage(perPage int) *Provider { + s.perPage = perPage + return s +} + +// Sort sets the `sort` field of the current search provider. +func (s *Provider) Sort(sort []SortField) *Provider { + s.sort = sort + return s +} + +// AddSort appends the provided SortField to the existing provider's sort field. +func (s *Provider) AddSort(field SortField) *Provider { + s.sort = append(s.sort, field) + return s +} + +// Filter sets the `filter` field of the current search provider. +func (s *Provider) Filter(filter []FilterData) *Provider { + s.filter = filter + return s +} + +// AddFilter appends the provided FilterData to the existing provider's filter field. +func (s *Provider) AddFilter(filter FilterData) *Provider { + if filter != "" { + s.filter = append(s.filter, filter) + } + return s +} + +// Parse parses the search query parameter from the provided query string +// and assigns the found fields to the current search provider. +// +// The data from the "sort" and "filter" query parameters are appended +// to the existing provider's `sort` and `filter` fields +// (aka. using `AddSort` and `AddFilter`). +func (s *Provider) Parse(urlQuery string) error { + params, err := url.ParseQuery(urlQuery) + if err != nil { + return err + } + + if raw := params.Get(SkipTotalQueryParam); raw != "" { + v, err := strconv.ParseBool(raw) + if err != nil { + return err + } + s.SkipTotal(v) + } + + if raw := params.Get(PageQueryParam); raw != "" { + v, err := strconv.Atoi(raw) + if err != nil { + return err + } + s.Page(v) + } + + if raw := params.Get(PerPageQueryParam); raw != "" { + v, err := strconv.Atoi(raw) + if err != nil { + return err + } + s.PerPage(v) + } + + if raw := params.Get(SortQueryParam); raw != "" { + for _, sortField := range ParseSortFromString(raw) { + s.AddSort(sortField) + } + } + + if raw := params.Get(FilterQueryParam); raw != "" { + s.AddFilter(FilterData(raw)) + } + + return nil +} + +// Exec executes the search provider and fills/scans +// the provided `items` slice with the found models. +func (s *Provider) Exec(items any) (*Result, error) { + if s.query == nil { + return nil, ErrEmptyQuery + } + + // shallow clone the provider's query + modelsQuery := *s.query + + // build filters + for _, f := range s.filter { + if len(f) > MaxFilterLength { + return nil, ErrFilterLengthLimit + } + expr, err := f.BuildExprWithLimit(s.fieldResolver, s.maxFilterExprLimit) + if err != nil { + return nil, err + } + if expr != nil { + modelsQuery.AndWhere(expr) + } + } + + // apply sorting + if len(s.sort) > s.maxSortExprLimit { + return nil, ErrSortExprLimit + } + for _, sortField := range s.sort { + if len(sortField.Name) > MaxSortFieldLength { + return nil, ErrSortFieldLengthLimit + } + expr, err := sortField.BuildExpr(s.fieldResolver) + if err != nil { + return nil, err + } + if expr != "" { + // ensure that _rowid_ expressions are always prefixed with the first FROM table + if sortField.Name == rowidSortKey && !strings.Contains(expr, ".") { + queryInfo := modelsQuery.Info() + if len(queryInfo.From) > 0 { + expr = "[[" + inflector.Columnify(queryInfo.From[0]) + "]]." + expr + } + } + + modelsQuery.AndOrderBy(expr) + } + } + + // apply field resolver query modifications (if any) + if err := s.fieldResolver.UpdateQuery(&modelsQuery); err != nil { + return nil, err + } + + // normalize page + if s.page <= 0 { + s.page = 1 + } + + // normalize perPage + if s.perPage <= 0 { + s.perPage = DefaultPerPage + } else if s.perPage > MaxPerPage { + s.perPage = MaxPerPage + } + + // negative value to differentiate from the zero default + totalCount := -1 + totalPages := -1 + + // prepare a count query from the base one + countQuery := modelsQuery // shallow clone + countExec := func() error { + queryInfo := countQuery.Info() + countCol := s.countCol + if len(queryInfo.From) > 0 { + countCol = queryInfo.From[0] + "." + countCol + } + + // note: countQuery is shallow cloned and slice/map in-place modifications should be avoided + err := countQuery.Distinct(false). + Select("COUNT(DISTINCT [[" + countCol + "]])"). + OrderBy( /* reset */ ). + Row(&totalCount) + if err != nil { + return err + } + + totalPages = int(math.Ceil(float64(totalCount) / float64(s.perPage))) + + return nil + } + + // apply pagination to the original query and fetch the models + modelsExec := func() error { + modelsQuery.Limit(int64(s.perPage)) + modelsQuery.Offset(int64(s.perPage * (s.page - 1))) + + return modelsQuery.All(items) + } + + if !s.skipTotal { + // execute the 2 queries concurrently + errg := new(errgroup.Group) + errg.Go(countExec) + errg.Go(modelsExec) + if err := errg.Wait(); err != nil { + return nil, err + } + } else { + if err := modelsExec(); err != nil { + return nil, err + } + } + + result := &Result{ + Page: s.page, + PerPage: s.perPage, + TotalItems: totalCount, + TotalPages: totalPages, + Items: items, + } + + return result, nil +} + +// ParseAndExec is a short convenient method to trigger both +// `Parse()` and `Exec()` in a single call. +func (s *Provider) ParseAndExec(urlQuery string, modelsSlice any) (*Result, error) { + if err := s.Parse(urlQuery); err != nil { + return nil, err + } + + return s.Exec(modelsSlice) +} diff --git a/server/core_backend-server/tools/search/provider_test.go b/server/core_backend-server/tools/search/provider_test.go new file mode 100644 index 0000000..4a27e50 --- /dev/null +++ b/server/core_backend-server/tools/search/provider_test.go @@ -0,0 +1,853 @@ +package search + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/security" + _ "modernc.org/sqlite" +) + +func TestNewProvider(t *testing.T) { + r := &testFieldResolver{} + p := NewProvider(r) + + if p.page != 1 { + t.Fatalf("Expected page %d, got %d", 1, p.page) + } + + if p.perPage != DefaultPerPage { + t.Fatalf("Expected perPage %d, got %d", DefaultPerPage, p.perPage) + } + + if p.maxFilterExprLimit != DefaultFilterExprLimit { + t.Fatalf("Expected maxFilterExprLimit %d, got %d", DefaultFilterExprLimit, p.maxFilterExprLimit) + } + + if p.maxSortExprLimit != DefaultSortExprLimit { + t.Fatalf("Expected maxSortExprLimit %d, got %d", DefaultSortExprLimit, p.maxSortExprLimit) + } +} + +func TestMaxFilterExprLimit(t *testing.T) { + p := NewProvider(&testFieldResolver{}) + + testVals := []int{0, -10, 10} + + for _, val := range testVals { + t.Run("max_"+strconv.Itoa(val), func(t *testing.T) { + p.MaxFilterExprLimit(val) + + if p.maxFilterExprLimit != val { + t.Fatalf("Expected maxFilterExprLimit to change to %d, got %d", val, p.maxFilterExprLimit) + } + }) + } +} + +func TestMaxSortExprLimit(t *testing.T) { + p := NewProvider(&testFieldResolver{}) + + testVals := []int{0, -10, 10} + + for _, val := range testVals { + t.Run("max_"+strconv.Itoa(val), func(t *testing.T) { + p.MaxSortExprLimit(val) + + if p.maxSortExprLimit != val { + t.Fatalf("Expected maxSortExprLimit to change to %d, got %d", val, p.maxSortExprLimit) + } + }) + } +} + +func TestProviderQuery(t *testing.T) { + db := dbx.NewFromDB(nil, "") + query := db.Select("id").From("test") + querySql := query.Build().SQL() + + r := &testFieldResolver{} + p := NewProvider(r).Query(query) + + expected := p.query.Build().SQL() + + if querySql != expected { + t.Fatalf("Expected %v, got %v", expected, querySql) + } +} + +func TestProviderSkipTotal(t *testing.T) { + p := NewProvider(&testFieldResolver{}) + + if p.skipTotal { + t.Fatalf("Expected the default skipTotal to be %v, got %v", false, p.skipTotal) + } + + p.SkipTotal(true) + + if !p.skipTotal { + t.Fatalf("Expected skipTotal to change to %v, got %v", true, p.skipTotal) + } +} + +func TestProviderCountCol(t *testing.T) { + p := NewProvider(&testFieldResolver{}) + + if p.countCol != "id" { + t.Fatalf("Expected the default countCol to be %s, got %s", "id", p.countCol) + } + + p.CountCol("test") + + if p.countCol != "test" { + t.Fatalf("Expected colCount to change to %s, got %s", "test", p.countCol) + } +} + +func TestProviderPage(t *testing.T) { + r := &testFieldResolver{} + p := NewProvider(r).Page(10) + + if p.page != 10 { + t.Fatalf("Expected page %v, got %v", 10, p.page) + } +} + +func TestProviderPerPage(t *testing.T) { + r := &testFieldResolver{} + p := NewProvider(r).PerPage(456) + + if p.perPage != 456 { + t.Fatalf("Expected perPage %v, got %v", 456, p.perPage) + } +} + +func TestProviderSort(t *testing.T) { + initialSort := []SortField{{"test1", SortAsc}, {"test2", SortAsc}} + r := &testFieldResolver{} + p := NewProvider(r). + Sort(initialSort). + AddSort(SortField{"test3", SortDesc}) + + encoded, _ := json.Marshal(p.sort) + expected := `[{"name":"test1","direction":"ASC"},{"name":"test2","direction":"ASC"},{"name":"test3","direction":"DESC"}]` + + if string(encoded) != expected { + t.Fatalf("Expected sort %v, got \n%v", expected, string(encoded)) + } +} + +func TestProviderFilter(t *testing.T) { + initialFilter := []FilterData{"test1", "test2"} + r := &testFieldResolver{} + p := NewProvider(r). + Filter(initialFilter). + AddFilter("test3") + + encoded, _ := json.Marshal(p.filter) + expected := `["test1","test2","test3"]` + + if string(encoded) != expected { + t.Fatalf("Expected filter %v, got \n%v", expected, string(encoded)) + } +} + +func TestProviderParse(t *testing.T) { + initialPage := 2 + initialPerPage := 123 + initialSort := []SortField{{"test1", SortAsc}, {"test2", SortAsc}} + initialFilter := []FilterData{"test1", "test2"} + + scenarios := []struct { + query string + expectError bool + expectPage int + expectPerPage int + expectSort string + expectFilter string + }{ + // empty + { + "", + false, + initialPage, + initialPerPage, + `[{"name":"test1","direction":"ASC"},{"name":"test2","direction":"ASC"}]`, + `["test1","test2"]`, + }, + // invalid query + { + "invalid;", + true, + initialPage, + initialPerPage, + `[{"name":"test1","direction":"ASC"},{"name":"test2","direction":"ASC"}]`, + `["test1","test2"]`, + }, + // invalid page + { + "page=a", + true, + initialPage, + initialPerPage, + `[{"name":"test1","direction":"ASC"},{"name":"test2","direction":"ASC"}]`, + `["test1","test2"]`, + }, + // invalid perPage + { + "perPage=a", + true, + initialPage, + initialPerPage, + `[{"name":"test1","direction":"ASC"},{"name":"test2","direction":"ASC"}]`, + `["test1","test2"]`, + }, + // valid query parameters + { + "page=3&perPage=456&filter=test3&sort=-a,b,+c&other=123", + false, + 3, + 456, + `[{"name":"test1","direction":"ASC"},{"name":"test2","direction":"ASC"},{"name":"a","direction":"DESC"},{"name":"b","direction":"ASC"},{"name":"c","direction":"ASC"}]`, + `["test1","test2","test3"]`, + }, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.query), func(t *testing.T) { + r := &testFieldResolver{} + p := NewProvider(r). + Page(initialPage). + PerPage(initialPerPage). + Sort(initialSort). + Filter(initialFilter) + + err := p.Parse(s.query) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if p.page != s.expectPage { + t.Fatalf("Expected page %v, got %v", s.expectPage, p.page) + } + + if p.perPage != s.expectPerPage { + t.Fatalf("Expected perPage %v, got %v", s.expectPerPage, p.perPage) + } + + encodedSort, _ := json.Marshal(p.sort) + if string(encodedSort) != s.expectSort { + t.Fatalf("Expected sort %v, got \n%v", s.expectSort, string(encodedSort)) + } + + encodedFilter, _ := json.Marshal(p.filter) + if string(encodedFilter) != s.expectFilter { + t.Fatalf("Expected filter %v, got \n%v", s.expectFilter, string(encodedFilter)) + } + }) + } +} + +func TestProviderExecEmptyQuery(t *testing.T) { + p := NewProvider(&testFieldResolver{}). + Query(nil) + + _, err := p.Exec(&[]testTableStruct{}) + if err == nil { + t.Fatalf("Expected error with empty query, got nil") + } +} + +func TestProviderExecNonEmptyQuery(t *testing.T) { + testDB, cleanup := createTestDB() + defer cleanup() + + query := testDB.Select("*"). + From("test"). + Where(dbx.Not(dbx.HashExp{"test1": nil})). + OrderBy("test1 ASC") + + scenarios := []struct { + name string + page int + perPage int + sort []SortField + filter []FilterData + skipTotal bool + expectError bool + expectResult string + expectQueries []string + }{ + { + "page normalization", + -1, + 10, + []SortField{}, + []FilterData{}, + false, + false, + `{"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":10,"totalItems":2,"totalPages":1}`, + []string{ + /* SQLite: + "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE NOT (`test1` IS NULL)", + "SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 10", + */ + // PostgreSQL: + `SELECT COUNT(DISTINCT [[test.id]]) FROM "test" WHERE NOT ("test1" IS NULL)`, + `SELECT * FROM "test" WHERE NOT ("test1" IS NULL) ORDER BY "test1" ASC LIMIT 10`, + }, + }, + { + "perPage normalization", + 10, + 0, // fallback to default + []SortField{}, + []FilterData{}, + false, + false, + `{"items":[],"page":10,"perPage":30,"totalItems":2,"totalPages":1}`, + []string{ + /* SQLite: + "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE NOT (`test1` IS NULL)", + "SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 30 OFFSET 270", + */ + // PostgreSQL: + `SELECT COUNT(DISTINCT [[test.id]]) FROM "test" WHERE NOT ("test1" IS NULL)`, + `SELECT * FROM "test" WHERE NOT ("test1" IS NULL) ORDER BY "test1" ASC LIMIT 30 OFFSET 270`, + }, + }, + { + "invalid sort field", + 1, + 10, + []SortField{{"unknown", SortAsc}}, + []FilterData{}, + false, + true, + "", + nil, + }, + { + "invalid filter", + 1, + 10, + []SortField{}, + []FilterData{"test2 = 'test2.1'", "invalid"}, + false, + true, + "", + nil, + }, + { + "valid sort and filter fields", + 1, + 5555, // will be limited by MaxPerPage + []SortField{{"test2", SortDesc}}, + []FilterData{"test2 != null", "test1 >= 2"}, + false, + false, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"totalPages":1}`, + []string{ + /* SQLite: + "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 IS NOT '' AND test2 IS NOT NULL)))) AND (test1 >= 2)", + "SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 IS NOT '' AND test2 IS NOT NULL)))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT " + fmt.Sprint(MaxPerPage), + */ + // PostgreSQL: + `SELECT COUNT(DISTINCT [[test.id]]) FROM "test" WHERE ((NOT ("test1" IS NULL)) AND (((test2::text IS DISTINCT FROM '' AND test2 IS NOT NULL)))) AND (test1 >= 2)`, + `SELECT * FROM "test" WHERE ((NOT ("test1" IS NULL)) AND (((test2::text IS DISTINCT FROM '' AND test2 IS NOT NULL)))) AND (test1 >= 2) ORDER BY "test1" ASC, "test2" DESC LIMIT ` + fmt.Sprint(MaxPerPage), + }, + }, + { + "valid sort and filter fields (skipTotal=1)", + 1, + 5555, // will be limited by MaxPerPage + []SortField{{"test2", SortDesc}}, + []FilterData{"test2 != null", "test1 >= 2"}, + true, + false, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":-1,"totalPages":-1}`, + []string{ + /* SQLite: + "SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 IS NOT '' AND test2 IS NOT NULL)))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT " + fmt.Sprint(MaxPerPage), + */ + // PostgreSQL: + `SELECT * FROM "test" WHERE ((NOT ("test1" IS NULL)) AND (((test2::text IS DISTINCT FROM '' AND test2 IS NOT NULL)))) AND (test1 >= 2) ORDER BY "test1" ASC, "test2" DESC LIMIT ` + fmt.Sprint(MaxPerPage), + }, + }, + { + "valid sort and filter fields (zero results)", + 1, + 10, + []SortField{{"test3", SortAsc}}, + []FilterData{"test3 != ''"}, + false, + false, + `{"items":[],"page":1,"perPage":10,"totalItems":0,"totalPages":0}`, + []string{ + /* SQLite: + "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 IS NOT '' AND test3 IS NOT NULL)))", + "SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 IS NOT '' AND test3 IS NOT NULL))) ORDER BY `test1` ASC, `test3` ASC LIMIT 10", + */ + // PostgreSQL: + `SELECT COUNT(DISTINCT [[test.id]]) FROM "test" WHERE (NOT ("test1" IS NULL)) AND (((test3::text IS DISTINCT FROM '' AND test3 IS NOT NULL)))`, + `SELECT * FROM "test" WHERE (NOT ("test1" IS NULL)) AND (((test3::text IS DISTINCT FROM '' AND test3 IS NOT NULL))) ORDER BY "test1" ASC, "test3" ASC LIMIT 10`, + }, + }, + { + "valid sort and filter fields (zero results; skipTotal=1)", + 1, + 10, + []SortField{{"test3", SortAsc}}, + []FilterData{"test3 != ''"}, + true, + false, + `{"items":[],"page":1,"perPage":10,"totalItems":-1,"totalPages":-1}`, + []string{ + /* SQLite: + "SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 IS NOT '' AND test3 IS NOT NULL))) ORDER BY `test1` ASC, `test3` ASC LIMIT 10", + */ + // PostgreSQL: + `SELECT * FROM "test" WHERE (NOT ("test1" IS NULL)) AND (((test3::text IS DISTINCT FROM '' AND test3 IS NOT NULL))) ORDER BY "test1" ASC, "test3" ASC LIMIT 10`, + }, + }, + { + "pagination test", + 2, + 1, + []SortField{}, + []FilterData{}, + false, + false, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":2,"perPage":1,"totalItems":2,"totalPages":2}`, + []string{ + /* SQLite: + "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE NOT (`test1` IS NULL)", + "SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1", + */ + // PostgreSQL: + `SELECT COUNT(DISTINCT [[test.id]]) FROM "test" WHERE NOT ("test1" IS NULL)`, + `SELECT * FROM "test" WHERE NOT ("test1" IS NULL) ORDER BY "test1" ASC LIMIT 1 OFFSET 1`, + }, + }, + { + "pagination test (skipTotal=1)", + 2, + 1, + []SortField{}, + []FilterData{}, + true, + false, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":2,"perPage":1,"totalItems":-1,"totalPages":-1}`, + []string{ + /* SQLite: + "SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1", + */ + // PostgreSQL: + `SELECT * FROM "test" WHERE NOT ("test1" IS NULL) ORDER BY "test1" ASC LIMIT 1 OFFSET 1`, + }, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + testDB.CalledQueries = []string{} // reset + + testResolver := &testFieldResolver{} + p := NewProvider(testResolver). + Query(query). + Page(s.page). + PerPage(s.perPage). + Sort(s.sort). + SkipTotal(s.skipTotal). + Filter(s.filter) + + result, err := p.Exec(&[]testTableStruct{}) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + if testResolver.UpdateQueryCalls != 1 { + t.Fatalf("Expected resolver.Update to be called %d, got %d", 1, testResolver.UpdateQueryCalls) + } + + encoded, _ := json.Marshal(result) + if string(encoded) != s.expectResult { + t.Fatalf("Expected result %v, got \n%v", s.expectResult, string(encoded)) + } + + if len(s.expectQueries) != len(testDB.CalledQueries) { + t.Fatalf("Expected %d queries, got %d: \n%v", len(s.expectQueries), len(testDB.CalledQueries), testDB.CalledQueries) + } + + for _, q := range testDB.CalledQueries { + if !list.ExistInSliceWithRegex(q, s.expectQueries) { + t.Fatalf("Didn't expect query \n%v \nin \n%v", q, s.expectQueries) + } + } + }) + } +} + +func TestProviderFilterAndSortLimits(t *testing.T) { + testDB, cleanup := createTestDB() + defer cleanup() + + query := testDB.Select("*"). + From("test"). + Where(dbx.Not(dbx.HashExp{"test1": nil})). + OrderBy("test1 ASC") + + scenarios := []struct { + name string + filter []FilterData + sort []SortField + maxFilterExprLimit int + maxSortExprLimit int + expectError bool + }{ + // filter + { + "<= max filter length", + []FilterData{ + "1=2", + /* SQLite: + FilterData("1='" + strings.Repeat("a", MaxFilterLength-4) + "'"), + */ + // PostgreSQL: + FilterData("'1'='" + strings.Repeat("a", MaxFilterLength-6) + "'"), + }, + []SortField{}, + 1, + 0, + false, + }, + { + "> max filter length", + []FilterData{ + "1=2", + FilterData("1='" + strings.Repeat("a", MaxFilterLength-3) + "'"), + }, + []SortField{}, + 1, + 0, + true, + }, + { + "<= max filter exprs", + []FilterData{ + "1=2", + "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", + }, + []SortField{}, + 6, + 0, + false, + }, + { + "> max filter exprs", + []FilterData{ + "1=2", + "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", + }, + []SortField{}, + 5, + 0, + true, + }, + + // sort + { + "<= max sort field length", + []FilterData{}, + []SortField{ + {"id", SortAsc}, + {"test1", SortDesc}, + {strings.Repeat("a", MaxSortFieldLength), SortDesc}, + }, + 0, + 10, + false, + }, + { + "> max sort field length", + []FilterData{}, + []SortField{ + {"id", SortAsc}, + {"test1", SortDesc}, + {strings.Repeat("b", MaxSortFieldLength+1), SortDesc}, + }, + 0, + 10, + true, + }, + { + "<= max sort exprs", + []FilterData{}, + []SortField{ + {"id", SortAsc}, + {"test1", SortDesc}, + }, + 0, + 2, + false, + }, + { + "> max sort exprs", + []FilterData{}, + []SortField{ + {"id", SortAsc}, + {"test1", SortDesc}, + }, + 0, + 1, + true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + testResolver := &testFieldResolver{} + p := NewProvider(testResolver). + Query(query). + Sort(s.sort). + Filter(s.filter). + MaxFilterExprLimit(s.maxFilterExprLimit). + MaxSortExprLimit(s.maxSortExprLimit) + + _, err := p.Exec(&[]testTableStruct{}) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) + } + }) + } +} + +func TestProviderParseAndExec(t *testing.T) { + testDB, cleanup := createTestDB() + defer cleanup() + + query := testDB.Select("*"). + From("test"). + Where(dbx.Not(dbx.HashExp{"test1": nil})). + OrderBy("test1 ASC") + + scenarios := []struct { + name string + queryString string + expectError bool + expectResult string + }{ + { + "no extra query params (aka. use the provider presets)", + "", + false, + `{"items":[],"page":2,"perPage":123,"totalItems":2,"totalPages":1}`, + }, + { + "invalid query", + "invalid;", + true, + "", + }, + { + "invalid page", + "page=a", + true, + "", + }, + { + "invalid perPage", + "perPage=a", + true, + "", + }, + { + "invalid skipTotal", + "skipTotal=a", + true, + "", + }, + { + "invalid sorting field", + "sort=-unknown", + true, + "", + }, + { + "invalid filter field", + "filter=unknown>1", + true, + "", + }, + { + "page > existing", + "page=3&perPage=9999", + false, + `{"items":[],"page":3,"perPage":1000,"totalItems":2,"totalPages":1}`, + }, + { + "valid query params", + "page=1&perPage=9999&filter=test1>1&sort=-test2,test3", + false, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":1000,"totalItems":1,"totalPages":1}`, + }, + { + "valid query params with skipTotal=1", + "page=1&perPage=9999&filter=test1>1&sort=-test2,test3&skipTotal=1", + false, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":1000,"totalItems":-1,"totalPages":-1}`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + testDB.CalledQueries = []string{} // reset + + testResolver := &testFieldResolver{} + provider := NewProvider(testResolver). + Query(query). + Page(2). + PerPage(123). + Sort([]SortField{{"test2", SortAsc}}). + Filter([]FilterData{"test1 > 0"}) + + result, err := provider.ParseAndExec(s.queryString, &[]testTableStruct{}) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + if testResolver.UpdateQueryCalls != 1 { + t.Fatalf("Expected resolver.Update to be called %d, got %d", 1, testResolver.UpdateQueryCalls) + } + + expectedQueries := 2 + if provider.skipTotal { + expectedQueries = 1 + } + + if len(testDB.CalledQueries) != expectedQueries { + t.Fatalf("Expected %d db queries, got %d: \n%v", expectedQueries, len(testDB.CalledQueries), testDB.CalledQueries) + } + + encoded, _ := json.Marshal(result) + if string(encoded) != s.expectResult { + t.Fatalf("Expected result \n%v\ngot\n%v", s.expectResult, string(encoded)) + } + }) + } +} + +// ------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------- + +type testTableStruct struct { + Test1 int `db:"test1" json:"test1"` + Test2 string `db:"test2" json:"test2"` + Test3 string `db:"test3" json:"test3"` +} + +type testDB struct { + *dbx.DB + CalledQueries []string +} + +// NB! Don't forget to call `defer cleanup()` at the end of the test. +func createTestDB() (*testDB, func()) { + /* SQLite: + // using a shared cache to allow multiple connections access to + // the same in memory database https://www.sqlite.org/inmemorydb.html + sqlDB, err := sql.Open("sqlite", "file::memory:?cache=shared") + if err != nil { + return nil, err + } + + db := testDB{DB: dbx.NewFromDB(sqlDB, "sqlite")} + */ + db, cleanup := NewTestDBX() + + db.CreateTable("test", map[string]string{ + "id": "int default 0", + "test1": "int default 0", + "test2": "text default ''", + "test3": "text default ''", + strings.Repeat("a", MaxSortFieldLength): "text default ''", + strings.Repeat("b", MaxSortFieldLength+1): "text default ''", + }).Execute() + db.Insert("test", dbx.Params{"id": 1, "test1": 1, "test2": "test2.1"}).Execute() + db.Insert("test", dbx.Params{"id": 2, "test1": 2, "test2": "test2.2"}).Execute() + db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { + db.CalledQueries = append(db.CalledQueries, sql) + } + + return db, cleanup +} + +func NewTestDBX() (db *testDB, cleanup func()) { + dbName := security.RandomString(5) + exec.Command("sh", "-c", fmt.Sprintf("PGPASSWORD=pass createdb -h 127.0.0.1 -U user %s", dbName)).Run() + + sqlDB, err := sql.Open("pgx", fmt.Sprintf("postgres://user:pass@localhost:6432/%s?sslmode=disable", dbName)) + if err != nil { + panic(err) + } + + testDB := testDB{DB: dbx.NewFromDB(sqlDB, "pgx")} + cleanup = func() { + if err := sqlDB.Close(); err != nil { + panic(err) + } + exec.Command("sh", "-c", fmt.Sprintf("PGPASSWORD=pass dropdb --if-exists -h 127.0.0.1 -U user %s", dbName)).Run() + } + + return &testDB, cleanup +} + +// --- + +type testFieldResolver struct { + UpdateQueryCalls int + ResolveCalls int +} + +func (t *testFieldResolver) UpdateQuery(query *dbx.SelectQuery) error { + t.UpdateQueryCalls++ + return nil +} + +func (t *testFieldResolver) Resolve(field string) (*ResolverResult, error) { + t.ResolveCalls++ + + if field == "unknown" { + return nil, errors.New("test error") + } + + return &ResolverResult{Identifier: field}, nil +} diff --git a/server/core_backend-server/tools/search/simple_field_resolver.go b/server/core_backend-server/tools/search/simple_field_resolver.go new file mode 100644 index 0000000..7c3a55c --- /dev/null +++ b/server/core_backend-server/tools/search/simple_field_resolver.go @@ -0,0 +1,131 @@ +package search + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/inflector" + "github.com/pocketbase/pocketbase/tools/list" +) + +// ResolverResult defines a single FieldResolver.Resolve() successfully parsed result. +type ResolverResult struct { + // Identifier is the plain SQL identifier/column that will be used + // in the final db expression as left or right operand. + Identifier string + + // NoCoalesce instructs to not use COALESCE or NULL fallbacks + // when building the identifier expression. + NoCoalesce bool + + // Params is a map with db placeholder->value pairs that will be added + // to the query when building both resolved operands/sides in a single expression. + Params dbx.Params + + // MultiMatchSubQuery is an optional sub query expression that will be added + // in addition to the combined ResolverResult expression during build. + MultiMatchSubQuery dbx.Expression + + // AfterBuild is an optional function that will be called after building + // and combining the result of both resolved operands/sides in a single expression. + AfterBuild func(expr dbx.Expression) dbx.Expression +} + +// FieldResolver defines an interface for managing search fields. +type FieldResolver interface { + // UpdateQuery allows to updated the provided db query based on the + // resolved search fields (eg. adding joins aliases, etc.). + // + // Called internally by `search.Provider` before executing the search request. + UpdateQuery(query *dbx.SelectQuery) error + + // Resolve parses the provided field and returns a properly + // formatted db identifier (eg. NULL, quoted column, placeholder parameter, etc.). + Resolve(field string) (*ResolverResult, error) +} + +// NewSimpleFieldResolver creates a new `SimpleFieldResolver` with the +// provided `allowedFields`. +// +// Each `allowedFields` could be a plain string (eg. "name") +// or a regexp pattern (eg. `^\w+[\w\.]*$`). +func NewSimpleFieldResolver(allowedFields ...string) *SimpleFieldResolver { + return &SimpleFieldResolver{ + allowedFields: allowedFields, + } +} + +// SimpleFieldResolver defines a generic search resolver that allows +// only its listed fields to be resolved and take part in a search query. +// +// If `allowedFields` are empty no fields filtering is applied. +type SimpleFieldResolver struct { + allowedFields []string +} + +// UpdateQuery implements `search.UpdateQuery` interface. +func (r *SimpleFieldResolver) UpdateQuery(query *dbx.SelectQuery) error { + // nothing to update... + return nil +} + +// Resolve implements `search.Resolve` interface. +// +// Returns error if `field` is not in `r.allowedFields`. +func (r *SimpleFieldResolver) Resolve(field string) (*ResolverResult, error) { + if !list.ExistInSliceWithRegex(field, r.allowedFields) { + return nil, fmt.Errorf("failed to resolve field %q", field) + } + + parts := strings.Split(field, ".") + + // single regular field + if len(parts) == 1 { + return &ResolverResult{ + Identifier: "[[" + inflector.Columnify(parts[0]) + "]]", + }, nil + } + + // treat as json path + var jsonPath strings.Builder + jsonPath.WriteString("$") + for _, part := range parts[1:] { + if _, err := strconv.Atoi(part); err == nil { + jsonPath.WriteString("[") + jsonPath.WriteString(inflector.Columnify(part)) + jsonPath.WriteString("]") + } else { + jsonPath.WriteString(".") + jsonPath.WriteString(inflector.Columnify(part)) + } + } + + dbType := strings.ToLower(os.Getenv("DB_TYPE")) + identifier := "" + if dbType == "postgres" || dbType == "postgresql" { + // PostgreSQL: + // Note: we have to cast it to text because PostgreSQL always + // requires a determistic type for any expression. We will do + // more type casting while building the final db expression. + identifier = fmt.Sprintf( + "JSON_QUERY([[%s]]::jsonb, '%s')::jsonb", + inflector.Columnify(parts[0]), + jsonPath.String(), + ) + } else { + // SQLite: + identifier = fmt.Sprintf( + "JSON_EXTRACT([[%s]], '%s')", + inflector.Columnify(parts[0]), + jsonPath.String(), + ) + } + + return &ResolverResult{ + NoCoalesce: true, + Identifier: identifier, + }, nil +} diff --git a/server/core_backend-server/tools/search/simple_field_resolver_test.go b/server/core_backend-server/tools/search/simple_field_resolver_test.go new file mode 100644 index 0000000..fb31182 --- /dev/null +++ b/server/core_backend-server/tools/search/simple_field_resolver_test.go @@ -0,0 +1,91 @@ +package search_test + +import ( + "fmt" + "testing" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/search" +) + +func TestSimpleFieldResolverUpdateQuery(t *testing.T) { + r := search.NewSimpleFieldResolver("test") + + scenarios := []struct { + fieldName string + expectQuery string + }{ + // missing field (the query shouldn't change) + {"", `SELECT "id" FROM "test"`}, + // unknown field (the query shouldn't change) + {"unknown", `SELECT "id" FROM "test"`}, + // allowed field (the query shouldn't change) + {"test", `SELECT "id" FROM "test"`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.fieldName), func(t *testing.T) { + db := dbx.NewFromDB(nil, "") + query := db.Select("id").From("test") + + r.Resolve(s.fieldName) + + if err := r.UpdateQuery(nil); err != nil { + t.Fatalf("UpdateQuery failed with error %v", err) + } + + rawQuery := query.Build().SQL() + + if rawQuery != s.expectQuery { + t.Fatalf("Expected query %v, got \n%v", s.expectQuery, rawQuery) + } + }) + } +} + +func TestSimpleFieldResolverResolve(t *testing.T) { + r := search.NewSimpleFieldResolver("test", `^test_regex\d+$`, "Test columnify!", "data.test") + + scenarios := []struct { + fieldName string + expectError bool + expectName string + }{ + {"", true, ""}, + {" ", true, ""}, + {"unknown", true, ""}, + {"test", false, "[[test]]"}, + {"test.sub", true, ""}, + {"test_regex", true, ""}, + {"test_regex1", false, "[[test_regex1]]"}, + {"Test columnify!", false, "[[Testcolumnify]]"}, + /* SQLite: + {"data.test", false, "JSON_EXTRACT([[data]], '$.test')"}, + */ + // PostgreSQL: + {"data.test", false, "JSON_QUERY([[data]]::jsonb, '$.test')::jsonb"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.fieldName), func(t *testing.T) { + r, err := r.Resolve(s.fieldName) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + if r.Identifier != s.expectName { + t.Fatalf("Expected r.Identifier %q, got %q", s.expectName, r.Identifier) + } + + if len(r.Params) != 0 { + t.Fatalf("r.Params should be empty, got %v", r.Params) + } + }) + } +} diff --git a/server/core_backend-server/tools/search/sort.go b/server/core_backend-server/tools/search/sort.go new file mode 100644 index 0000000..dda2eac --- /dev/null +++ b/server/core_backend-server/tools/search/sort.go @@ -0,0 +1,77 @@ +package search + +import ( + "fmt" + "os" + "strings" +) + +const ( + randomSortKey string = "@random" + rowidSortKey string = "@rowid" +) + +// sort field directions +const ( + SortAsc string = "ASC" + SortDesc string = "DESC" +) + +// SortField defines a single search sort field. +type SortField struct { + Name string `json:"name"` + Direction string `json:"direction"` +} + +// BuildExpr resolves the sort field into a valid db sort expression. +func (s *SortField) BuildExpr(fieldResolver FieldResolver) (string, error) { + // special case for random sort + if s.Name == randomSortKey { + return "RANDOM()", nil + } + + // special case for the builtin SQLite rowid column + if s.Name == rowidSortKey { + dbType := strings.ToLower(os.Getenv("DB_TYPE")) + if dbType == "postgres" || dbType == "postgresql" { + return fmt.Sprintf("[[ctid]] %s", s.Direction), nil + } + if dbType == "mysql" { + // MySQL doesn't have rowid, fallback to id + return fmt.Sprintf("[[id]] %s", s.Direction), nil + } + // default to SQLite + return fmt.Sprintf("[[_rowid_]] %s", s.Direction), nil + } + + result, err := fieldResolver.Resolve(s.Name) + + // invalidate empty fields and non-column identifiers + if err != nil || len(result.Params) > 0 || result.Identifier == "" || strings.ToLower(result.Identifier) == "null" { + return "", fmt.Errorf("invalid sort field %q", s.Name) + } + + return fmt.Sprintf("%s %s", result.Identifier, s.Direction), nil +} + +// ParseSortFromString parses the provided string expression +// into a slice of SortFields. +// +// Example: +// +// fields := search.ParseSortFromString("-name,+created") +func ParseSortFromString(str string) (fields []SortField) { + data := strings.Split(str, ",") + + for _, field := range data { + // trim whitespaces + field = strings.TrimSpace(field) + if strings.HasPrefix(field, "-") { + fields = append(fields, SortField{strings.TrimPrefix(field, "-"), SortDesc}) + } else { + fields = append(fields, SortField{strings.TrimPrefix(field, "+"), SortAsc}) + } + } + + return +} diff --git a/server/core_backend-server/tools/search/sort_test.go b/server/core_backend-server/tools/search/sort_test.go new file mode 100644 index 0000000..92f2113 --- /dev/null +++ b/server/core_backend-server/tools/search/sort_test.go @@ -0,0 +1,82 @@ +package search_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/pocketbase/pocketbase/tools/search" +) + +func TestSortFieldBuildExpr(t *testing.T) { + resolver := search.NewSimpleFieldResolver("test1", "test2", "test3", "test4.sub") + + scenarios := []struct { + sortField search.SortField + expectError bool + expectExpression string + }{ + // empty + {search.SortField{"", search.SortDesc}, true, ""}, + // unknown field + {search.SortField{"unknown", search.SortAsc}, true, ""}, + // placeholder field + {search.SortField{"'test'", search.SortAsc}, true, ""}, + // null field + {search.SortField{"null", search.SortAsc}, true, ""}, + // allowed field - asc + {search.SortField{"test1", search.SortAsc}, false, "[[test1]] ASC"}, + // allowed field - desc + {search.SortField{"test1", search.SortDesc}, false, "[[test1]] DESC"}, + // special @random field (ignore direction) + {search.SortField{"@random", search.SortDesc}, false, "RANDOM()"}, + // special _rowid_ field + /* SQLite: + {search.SortField{"@rowid", search.SortDesc}, false, "[[_rowid_]] DESC"}, + */ + // PostgreSQL: + {search.SortField{"@rowid", search.SortDesc}, false, "[[ctid]] DESC"}, + } + + for _, s := range scenarios { + t.Run(fmt.Sprintf("%s_%s", s.sortField.Name, s.sortField.Name), func(t *testing.T) { + result, err := s.sortField.BuildExpr(resolver) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if result != s.expectExpression { + t.Fatalf("Expected expression %v, got %v", s.expectExpression, result) + } + }) + } +} + +func TestParseSortFromString(t *testing.T) { + scenarios := []struct { + value string + expected string + }{ + {"", `[{"name":"","direction":"ASC"}]`}, + {"test", `[{"name":"test","direction":"ASC"}]`}, + {"+test", `[{"name":"test","direction":"ASC"}]`}, + {"-test", `[{"name":"test","direction":"DESC"}]`}, + {"test1,-test2,+test3", `[{"name":"test1","direction":"ASC"},{"name":"test2","direction":"DESC"},{"name":"test3","direction":"ASC"}]`}, + {"@random,-test", `[{"name":"@random","direction":"ASC"},{"name":"test","direction":"DESC"}]`}, + {"-@rowid,-test", `[{"name":"@rowid","direction":"DESC"},{"name":"test","direction":"DESC"}]`}, + } + + for _, s := range scenarios { + t.Run(s.value, func(t *testing.T) { + result := search.ParseSortFromString(s.value) + encoded, _ := json.Marshal(result) + encodedStr := string(encoded) + + if encodedStr != s.expected { + t.Fatalf("Expected expression %s, got %s", s.expected, encodedStr) + } + }) + } +} diff --git a/server/core_backend-server/tools/search/token_functions.go b/server/core_backend-server/tools/search/token_functions.go new file mode 100644 index 0000000..15f2471 --- /dev/null +++ b/server/core_backend-server/tools/search/token_functions.go @@ -0,0 +1,56 @@ +package search + +import ( + "fmt" + + "github.com/ganigeorgiev/fexpr" +) + +var TokenFunctions = map[string]func( + argTokenResolverFunc func(fexpr.Token) (*ResolverResult, error), + args ...fexpr.Token, +) (*ResolverResult, error){ + // geoDistance(lonA, latA, lonB, latB) calculates the Haversine + // distance between 2 points in kilometres (https://www.movable-type.co.uk/scripts/latlong.html). + // + // The accepted arguments at the moment could be either a plain number or a column identifier (including NULL). + // If the column identifier cannot be resolved and converted to a numeric value, it resolves to NULL. + // + // Similar to the built-in SQLite functions, geoDistance doesn't apply + // a "match-all" constraints in case there are multiple relation fields arguments. + // Or in other words, if a collection has "orgs" multiple relation field pointing to "orgs" collection that has "office" as "geoPoint" field, + // then the filter: `geoDistance(orgs.office.lon, orgs.office.lat, 1, 2) < 200` + // will evaluate to true if for at-least-one of the "orgs.office" records the function result in a value satisfying the condition (aka. "result < 200"). + "geoDistance": func(argTokenResolverFunc func(fexpr.Token) (*ResolverResult, error), args ...fexpr.Token) (*ResolverResult, error) { + if len(args) != 4 { + return nil, fmt.Errorf("[geoDistance] expected 4 arguments, got %d", len(args)) + } + + resolvedArgs := make([]*ResolverResult, 4) + for i, arg := range args { + if arg.Type != fexpr.TokenIdentifier && arg.Type != fexpr.TokenNumber { + return nil, fmt.Errorf("[geoDistance] argument %d must be an identifier or number", i) + } + resolved, err := argTokenResolverFunc(arg) + if err != nil { + return nil, fmt.Errorf("[geoDistance] failed to resolve argument %d: %w", i, err) + } + resolvedArgs[i] = resolved + } + + lonA := resolvedArgs[0].Identifier + latA := resolvedArgs[1].Identifier + lonB := resolvedArgs[2].Identifier + latB := resolvedArgs[3].Identifier + + return &ResolverResult{ + NoCoalesce: true, + Identifier: `(6371 * acos(` + + `cos(radians(` + latA + `)) * cos(radians(` + latB + `)) * ` + + `cos(radians(` + lonB + `) - radians(` + lonA + `)) + ` + + `sin(radians(` + latA + `)) * sin(radians(` + latB + `))` + + `))`, + Params: mergeParams(resolvedArgs[0].Params, resolvedArgs[1].Params, resolvedArgs[2].Params, resolvedArgs[3].Params), + }, nil + }, +} diff --git a/server/core_backend-server/tools/search/token_functions_test.go b/server/core_backend-server/tools/search/token_functions_test.go new file mode 100644 index 0000000..f4555c3 --- /dev/null +++ b/server/core_backend-server/tools/search/token_functions_test.go @@ -0,0 +1,265 @@ +package search + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/ganigeorgiev/fexpr" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestTokenFunctionsGeoDistance(t *testing.T) { + t.Parallel() + + fn, ok := TokenFunctions["geoDistance"] + if !ok { + t.Error("Expected geoDistance token function to be registered.") + } + + baseTokenResolver := func(t fexpr.Token) (*ResolverResult, error) { + placeholder := "t" + security.PseudorandomString(5) + return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil + } + + scenarios := []struct { + name string + args []fexpr.Token + resolver func(t fexpr.Token) (*ResolverResult, error) + result *ResolverResult + expectErr bool + }{ + { + "no args", + nil, + baseTokenResolver, + nil, + true, + }, + { + "< 4 args", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenNumber}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "> 4 args", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenNumber}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + {Literal: "5", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "unsupported function argument", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenFunction}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "unsupported text argument", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "4 valid arguments but with resolver error", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenNumber}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + func(t fexpr.Token) (*ResolverResult, error) { + return nil, errors.New("test") + }, + nil, + true, + }, + { + "4 valid arguments", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenNumber}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + &ResolverResult{ + NoCoalesce: true, + Identifier: `(6371 * acos(cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))`, + Params: map[string]any{ + "lonA": 1, + "latA": 2, + "lonB": 3, + "latB": 4, + }, + }, + false, + }, + { + "mixed arguments", + []fexpr.Token{ + {Literal: "null", Type: fexpr.TokenIdentifier}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "false", Type: fexpr.TokenIdentifier}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + &ResolverResult{ + NoCoalesce: true, + Identifier: `(6371 * acos(cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))`, + Params: map[string]any{ + "lonA": "null", + "latA": 2, + "lonB": false, + "latB": 4, + }, + }, + false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, err := fn(s.resolver, s.args...) + + hasErr := err != nil + if hasErr != s.expectErr { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectErr, hasErr, err) + } + + testCompareResults(t, s.result, result) + }) + } +} + +func TestTokenFunctionsGeoDistanceExec(t *testing.T) { + t.Parallel() + + testDB, cleanup := createTestDB() + defer cleanup() + + fn, ok := TokenFunctions["geoDistance"] + if !ok { + t.Error("Expected geoDistance token function to be registered.") + } + + result, err := fn( + func(t fexpr.Token) (*ResolverResult, error) { + placeholder := "t" + security.PseudorandomString(5) + return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil + }, + fexpr.Token{Literal: "23.23033854945808", Type: fexpr.TokenNumber}, + fexpr.Token{Literal: "42.713146090563384", Type: fexpr.TokenNumber}, + fexpr.Token{Literal: "23.44920680886216", Type: fexpr.TokenNumber}, + fexpr.Token{Literal: "42.7078484153991", Type: fexpr.TokenNumber}, + ) + if err != nil { + t.Fatal(err) + } + + column := []float64{} + err = testDB.NewQuery("select " + result.Identifier).Bind(result.Params).Column(&column) + if err != nil { + t.Fatal(err) + } + + if len(column) != 1 { + t.Fatalf("Expected exactly 1 column value as result, got %v", column) + } + + expected := "17.89" + distance := fmt.Sprintf("%.2f", column[0]) + if distance != expected { + t.Fatalf("Expected distance value %s, got %s", expected, distance) + } +} + +// ------------------------------------------------------------------- + +func testCompareResults(t *testing.T, a, b *ResolverResult) { + testDB, cleanup := createTestDB() + defer cleanup() // Updated to use cleanup instead of testDB.Close() + + aIsNil := a == nil + bIsNil := b == nil + if aIsNil != bIsNil { + t.Fatalf("Expected aIsNil and bIsNil to be the same, got %v vs %v", aIsNil, bIsNil) + } + + if aIsNil && bIsNil { + return + } + + aHasAfterBuild := a.AfterBuild == nil + bHasAfterBuild := b.AfterBuild == nil + if aHasAfterBuild != bHasAfterBuild { + t.Fatalf("Expected aHasAfterBuild and bHasAfterBuild to be the same, got %v vs %v", aHasAfterBuild, bHasAfterBuild) + } + + var aAfterBuild string + if a.AfterBuild != nil { + aAfterBuild = a.AfterBuild(dbx.NewExp("test")).Build(testDB.DB, a.Params) + } + var bAfterBuild string + if b.AfterBuild != nil { + bAfterBuild = b.AfterBuild(dbx.NewExp("test")).Build(testDB.DB, a.Params) + } + if aAfterBuild != bAfterBuild { + t.Fatalf("Expected bAfterBuild and bAfterBuild to be the same, got\n%s\nvs\n%s", aAfterBuild, bAfterBuild) + } + + var aMultiMatchSubQuery string + if a.MultiMatchSubQuery != nil { + aMultiMatchSubQuery = a.MultiMatchSubQuery.Build(testDB.DB, a.Params) + } + var bMultiMatchSubQuery string + if b.MultiMatchSubQuery != nil { + bMultiMatchSubQuery = b.MultiMatchSubQuery.Build(testDB.DB, b.Params) + } + if aMultiMatchSubQuery != bMultiMatchSubQuery { + t.Fatalf("Expected bMultiMatchSubQuery and bMultiMatchSubQuery to be the same, got\n%s\nvs\n%s", aMultiMatchSubQuery, bMultiMatchSubQuery) + } + + if a.NoCoalesce != b.NoCoalesce { + t.Fatalf("Expected NoCoalesce to match, got %v vs %v", a.NoCoalesce, b.NoCoalesce) + } + + // loose placeholders replacement + var aResolved = a.Identifier + for k, v := range a.Params { + aResolved = strings.ReplaceAll(aResolved, "{:"+k+"}", fmt.Sprintf("%v", v)) + } + var bResolved = b.Identifier + for k, v := range b.Params { + bResolved = strings.ReplaceAll(bResolved, "{:"+k+"}", fmt.Sprintf("%v", v)) + } + if aResolved != bResolved { + t.Fatalf("Expected resolved identifiers to match, got\n%s\nvs\n%s", aResolved, bResolved) + } +} diff --git a/server/core_backend-server/tools/security/crypto.go b/server/core_backend-server/tools/security/crypto.go new file mode 100644 index 0000000..4577873 --- /dev/null +++ b/server/core_backend-server/tools/security/crypto.go @@ -0,0 +1,62 @@ +package security + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" +) + +// S256Challenge creates base64 encoded sha256 challenge string derived from code. +// The padding of the result base64 string is stripped per [RFC 7636]. +// +// [RFC 7636]: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 +func S256Challenge(code string) string { + h := sha256.New() + h.Write([]byte(code)) + return strings.TrimRight(base64.URLEncoding.EncodeToString(h.Sum(nil)), "=") +} + +// MD5 creates md5 hash from the provided plain text. +func MD5(text string) string { + h := md5.New() + h.Write([]byte(text)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// SHA256 creates sha256 hash as defined in FIPS 180-4 from the provided text. +func SHA256(text string) string { + h := sha256.New() + h.Write([]byte(text)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// SHA512 creates sha512 hash as defined in FIPS 180-4 from the provided text. +func SHA512(text string) string { + h := sha512.New() + h.Write([]byte(text)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// HS256 creates a HMAC hash with sha256 digest algorithm. +func HS256(text string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(text)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// HS512 creates a HMAC hash with sha512 digest algorithm. +func HS512(text string, secret string) string { + h := hmac.New(sha512.New, []byte(secret)) + h.Write([]byte(text)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// Equal compares two hash strings for equality without leaking timing information. +func Equal(hash1 string, hash2 string) bool { + return subtle.ConstantTimeCompare([]byte(hash1), []byte(hash2)) == 1 +} diff --git a/server/core_backend-server/tools/security/crypto_test.go b/server/core_backend-server/tools/security/crypto_test.go new file mode 100644 index 0000000..36134d4 --- /dev/null +++ b/server/core_backend-server/tools/security/crypto_test.go @@ -0,0 +1,156 @@ +package security_test + +import ( + "fmt" + "testing" + + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestS256Challenge(t *testing.T) { + scenarios := []struct { + code string + expected string + }{ + {"", "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"}, + {"123", "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM"}, + } + + for _, s := range scenarios { + t.Run(s.code, func(t *testing.T) { + result := security.S256Challenge(s.code) + + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) + } +} + +func TestMD5(t *testing.T) { + scenarios := []struct { + code string + expected string + }{ + {"", "d41d8cd98f00b204e9800998ecf8427e"}, + {"123", "202cb962ac59075b964b07152d234b70"}, + } + + for _, s := range scenarios { + t.Run(s.code, func(t *testing.T) { + result := security.MD5(s.code) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestSHA256(t *testing.T) { + scenarios := []struct { + code string + expected string + }{ + {"", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, + {"123", "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"}, + } + + for _, s := range scenarios { + t.Run(s.code, func(t *testing.T) { + result := security.SHA256(s.code) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestSHA512(t *testing.T) { + scenarios := []struct { + code string + expected string + }{ + {"", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"}, + {"123", "3c9909afec25354d551dae21590bb26e38d53f2173b8d3dc3eee4c047e7ab1c1eb8b85103e3be7ba613b31bb5c9c36214dc9f14a42fd7a2fdb84856bca5c44c2"}, + } + + for _, s := range scenarios { + t.Run(s.code, func(t *testing.T) { + result := security.SHA512(s.code) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestHS256(t *testing.T) { + scenarios := []struct { + text string + secret string + expected string + }{ + {" ", "test", "9fb4e4a12d50728683a222b4fc466a69ee977332cfcdd6b9ebb44c7121dbd99f"}, + {" ", "test2", "d792417a504716e22805d940125ec12e68e8cb18fc84674703bd96c59f1e1228"}, + {"hello", "test", "f151ea24bda91a18e89b8bb5793ef324b2a02133cce15a28a719acbd2e58a986"}, + {"hello", "test2", "16436e8dcbf3d7b5b0455573b27e6372699beb5bfe94e6a2a371b14b4ae068f4"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d-%s", i, s.text), func(t *testing.T) { + result := security.HS256(s.text, s.secret) + + if result != s.expected { + t.Fatalf("Expected \n%v, \ngot \n%v", s.expected, result) + } + }) + } +} + +func TestHS512(t *testing.T) { + scenarios := []struct { + text string + secret string + expected string + }{ + {" ", "test", "eb3bdb0352c95c38880c1f645fc7e1d1332644f938f50de0d73876e42d6f302e599bb526531ba79940e8b314369aaef3675322d8d851f9fc6ea9ed121286d196"}, + {" ", "test2", "8b69e84e9252af78ae8b1c4bed3c9f737f69a3df33064cfbefe76b36d19d1827285e543cdf066cdc8bd556cc0cd0e212d52e9c12a50cd16046181ff127f4cf7f"}, + {"hello", "test", "44f280e11103e295c26cd61dd1cdd8178b531b860466867c13b1c37a26b6389f8af110efbe0bb0717b9d9c87f6fe1c97b3b1690936578890e5669abf279fe7fd"}, + {"hello", "test2", "d7f10b1b66941b20817689b973ca9dfc971090e28cfb8becbddd6824569b323eca6a0cdf2c387aa41e15040007dca5a011dd4e4bb61cfd5011aa7354d866f6ef"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d-%q", i, s.text), func(t *testing.T) { + result := security.HS512(s.text, s.secret) + + if result != s.expected { + t.Fatalf("Expected \n%v, \ngot \n%v", s.expected, result) + } + }) + } +} + +func TestEqual(t *testing.T) { + scenarios := []struct { + hash1 string + hash2 string + expected bool + }{ + {"", "", true}, + {"abc", "abd", false}, + {"abc", "abc", true}, + } + + for _, s := range scenarios { + t.Run(fmt.Sprintf("%qVS%q", s.hash1, s.hash2), func(t *testing.T) { + result := security.Equal(s.hash1, s.hash2) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} diff --git a/server/core_backend-server/tools/security/encrypt.go b/server/core_backend-server/tools/security/encrypt.go new file mode 100644 index 0000000..7e25e9e --- /dev/null +++ b/server/core_backend-server/tools/security/encrypt.go @@ -0,0 +1,62 @@ +package security + +import ( + "crypto/aes" + "crypto/cipher" + crand "crypto/rand" + "encoding/base64" + "io" +) + +// Encrypt encrypts "data" with the specified "key" (must be valid 32 char AES key). +// +// This method uses AES-256-GCM block cypher mode. +func Encrypt(data []byte, key string) (string, error) { + block, err := aes.NewCipher([]byte(key)) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + + // populates the nonce with a cryptographically secure random sequence + if _, err := io.ReadFull(crand.Reader, nonce); err != nil { + return "", err + } + + cipherByte := gcm.Seal(nonce, nonce, data, nil) + + result := base64.StdEncoding.EncodeToString(cipherByte) + + return result, nil +} + +// Decrypt decrypts encrypted text with key (must be valid 32 chars AES key). +// +// This method uses AES-256-GCM block cypher mode. +func Decrypt(cipherText string, key string) ([]byte, error) { + block, err := aes.NewCipher([]byte(key)) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + + cipherByte, err := base64.StdEncoding.DecodeString(cipherText) + if err != nil { + return nil, err + } + + nonce, cipherByteClean := cipherByte[:nonceSize], cipherByte[nonceSize:] + return gcm.Open(nil, nonce, cipherByteClean, nil) +} diff --git a/server/core_backend-server/tools/security/encrypt_test.go b/server/core_backend-server/tools/security/encrypt_test.go new file mode 100644 index 0000000..1931672 --- /dev/null +++ b/server/core_backend-server/tools/security/encrypt_test.go @@ -0,0 +1,80 @@ +package security_test + +import ( + "fmt" + "testing" + + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestEncrypt(t *testing.T) { + scenarios := []struct { + data string + key string + expectError bool + }{ + {"", "", true}, + {"123", "test", true}, // key must be valid 32 char aes string + {"123", "abcdabcdabcdabcdabcdabcdabcdabcd", false}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.data), func(t *testing.T) { + result, err := security.Encrypt([]byte(s.data), s.key) + + hasErr := err != nil + + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + if result != "" { + t.Fatalf("Expected empty Encrypt result on error, got %q", result) + } + + return + } + + // try to decrypt + decrypted, err := security.Decrypt(result, s.key) + if err != nil || string(decrypted) != s.data { + t.Fatalf("Expected decrypted value to match with the data input, got %q (%v)", decrypted, err) + } + }) + } +} + +func TestDecrypt(t *testing.T) { + scenarios := []struct { + cipher string + key string + expectError bool + expectedData string + }{ + {"", "", true, ""}, + {"123", "test", true, ""}, // key must be valid 32 char aes string + {"8kcEqilvvYKYcfnSr0aSC54gmnQCsB02SaB8ATlnA==", "abcdabcdabcdabcdabcdabcdabcdabcd", true, ""}, // illegal base64 encoded cipherText + {"8kcEqilvv+YKYcfnSr0aSC54gmnQCsB02SaB8ATlnA==", "abcdabcdabcdabcdabcdabcdabcdabcd", false, "123"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) { + result, err := security.Decrypt(s.cipher, s.key) + + hasErr := err != nil + + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + if str := string(result); str != s.expectedData { + t.Fatalf("Expected %q, got %q", s.expectedData, str) + } + }) + } +} diff --git a/server/core_backend-server/tools/security/jwt.go b/server/core_backend-server/tools/security/jwt.go new file mode 100644 index 0000000..9169468 --- /dev/null +++ b/server/core_backend-server/tools/security/jwt.go @@ -0,0 +1,56 @@ +package security + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// ParseUnverifiedJWT parses JWT and returns its claims +// but DOES NOT verify the signature. +// +// It verifies only the exp, iat and nbf claims. +func ParseUnverifiedJWT(token string) (jwt.MapClaims, error) { + claims := jwt.MapClaims{} + + parser := &jwt.Parser{} + _, _, err := parser.ParseUnverified(token, claims) + + if err == nil { + err = jwt.NewValidator(jwt.WithIssuedAt()).Validate(claims) + } + + return claims, err +} + +// ParseJWT verifies and parses JWT and returns its claims. +func ParseJWT(token string, verificationKey string) (jwt.MapClaims, error) { + parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"})) + + parsedToken, err := parser.Parse(token, func(t *jwt.Token) (any, error) { + return []byte(verificationKey), nil + }) + if err != nil { + return nil, err + } + + if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok && parsedToken.Valid { + return claims, nil + } + + return nil, errors.New("unable to parse token") +} + +// NewJWT generates and returns new HS256 signed JWT. +func NewJWT(payload jwt.MapClaims, signingKey string, duration time.Duration) (string, error) { + claims := jwt.MapClaims{ + "exp": time.Now().Add(duration).Unix(), + } + + for k, v := range payload { + claims[k] = v + } + + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(signingKey)) +} diff --git a/server/core_backend-server/tools/security/jwt_test.go b/server/core_backend-server/tools/security/jwt_test.go new file mode 100644 index 0000000..afb636d --- /dev/null +++ b/server/core_backend-server/tools/security/jwt_test.go @@ -0,0 +1,185 @@ +package security_test + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestParseUnverifiedJWT(t *testing.T) { + // invalid formatted JWT + result1, err1 := security.ParseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9") + if err1 == nil { + t.Error("Expected error got nil") + } + if len(result1) > 0 { + t.Error("Expected no parsed claims, got", result1) + } + + // properly formatted JWT with INVALID claims + // {"name": "test", "exp":1516239022} + result2, err2 := security.ParseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MTUxNjIzOTAyMn0.xYHirwESfSEW3Cq2BL47CEASvD_p_ps3QCA54XtNktU") + if err2 == nil { + t.Error("Expected error got nil") + } + if len(result2) != 2 || result2["name"] != "test" { + t.Errorf("Expected to have 2 claims, got %v", result2) + } + + // properly formatted JWT with VALID claims (missing exp) + // {"name": "test"} + result3, err3 := security.ParseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9.ml0QsTms3K9wMygTu41ZhKlTyjmW9zHQtoS8FUsCCjU") + if err3 != nil { + t.Error("Expected nil, got", err3) + } + if len(result3) != 1 || result3["name"] != "test" { + t.Errorf("Expected to have 1 claim, got %v", result3) + } + + // properly formatted JWT with VALID claims (valid exp) + // {"name": "test", "exp": 2524604461} + result4, err4 := security.ParseUnverifiedJWT("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MjUyNDYwNDQ2MX0.VIEO73GP5QRQOSfHgQhaqeuYqcx59vL3xlxmFP-fytQ") + if err4 != nil { + t.Error("Expected nil, got", err4) + } + if len(result4) != 2 || result4["name"] != "test" { + t.Errorf("Expected to have 2 claims, got %v", result4) + } +} + +func TestParseJWT(t *testing.T) { + scenarios := []struct { + name string + token string + secret string + expectError bool + expectClaims jwt.MapClaims + }{ + { + "invalid formatted JWT", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9", + "test", + true, + nil, + }, + { + "properly formatted JWT with INVALID claims and INVALID secret", + // {"name": "test", "exp": 1516239022} + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MTUxNjIzOTAyMn0.xYHirwESfSEW3Cq2BL47CEASvD_p_ps3QCA54XtNktU", + "invalid", + true, + nil, + }, + { + "properly formatted JWT with INVALID claims and VALID secret", + // {"name": "test", "exp": 1516239022} + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MTUxNjIzOTAyMn0.xYHirwESfSEW3Cq2BL47CEASvD_p_ps3QCA54XtNktU", + "test", + true, + nil, + }, + { + "properly formatted JWT with VALID claims and INVALID secret", + // {"name": "test", "exp": 2524604461} + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MjUyNDYwNDQ2MX0.VIEO73GP5QRQOSfHgQhaqeuYqcx59vL3xlxmFP-fytQ", + "invalid", + true, + nil, + }, + { + "properly formatted JWT with VALID claims and VALID secret", + // {"name": "test", "exp": 2524604461} + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MjUyNDYwNDQ2MX0.VIEO73GP5QRQOSfHgQhaqeuYqcx59vL3xlxmFP-fytQ", + "test", + false, + jwt.MapClaims{"name": "test", "exp": 2524604461.0}, + }, + { + "properly formatted JWT with VALID claims (without exp) and VALID secret", + // {"name": "test"} + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9.ml0QsTms3K9wMygTu41ZhKlTyjmW9zHQtoS8FUsCCjU", + "test", + false, + jwt.MapClaims{"name": "test"}, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, err := security.ParseJWT(s.token, s.secret) + + hasErr := err != nil + + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if len(result) != len(s.expectClaims) { + t.Fatalf("Expected %v claims got %v", s.expectClaims, result) + } + + for k, v := range s.expectClaims { + v2, ok := result[k] + if !ok { + t.Fatalf("Missing expected claim %q", k) + } + if v != v2 { + t.Fatalf("Expected %v for %q claim, got %v", v, k, v2) + } + } + }) + } +} + +func TestNewJWT(t *testing.T) { + scenarios := []struct { + name string + claims jwt.MapClaims + key string + duration time.Duration + expectError bool + }{ + {"empty, zero duration", jwt.MapClaims{}, "", 0, true}, + {"empty, 10 seconds duration", jwt.MapClaims{}, "", 10 * time.Second, false}, + {"non-empty, 10 seconds duration", jwt.MapClaims{"name": "test"}, "test", 10 * time.Second, false}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + token, tokenErr := security.NewJWT(s.claims, s.key, s.duration) + if tokenErr != nil { + t.Fatalf("Expected NewJWT to succeed, got error %v", tokenErr) + } + + claims, parseErr := security.ParseJWT(token, s.key) + + hasParseErr := parseErr != nil + if hasParseErr != s.expectError { + t.Fatalf("Expected hasParseErr to be %v, got %v (%v)", s.expectError, hasParseErr, parseErr) + } + + if s.expectError { + return + } + + if _, ok := claims["exp"]; !ok { + t.Fatalf("Missing required claim exp, got %v", claims) + } + + // clear exp claim to match with the scenario ones + delete(claims, "exp") + + if len(claims) != len(s.claims) { + t.Fatalf("Expected %v claims, got %v", s.claims, claims) + } + + for k, v := range claims { + if v != s.claims[k] { + t.Fatalf("Expected %v for %q claim, got %v", s.claims[k], k, v) + } + } + }) + } +} diff --git a/server/core_backend-server/tools/security/random.go b/server/core_backend-server/tools/security/random.go new file mode 100644 index 0000000..b931c84 --- /dev/null +++ b/server/core_backend-server/tools/security/random.go @@ -0,0 +1,59 @@ +package security + +import ( + cryptoRand "crypto/rand" + "math/big" + mathRand "math/rand/v2" +) + +const defaultRandomAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +// RandomString generates a cryptographically random string with the specified length. +// +// The generated string matches [A-Za-z0-9]+ and it's transparent to URL-encoding. +func RandomString(length int) string { + return RandomStringWithAlphabet(length, defaultRandomAlphabet) +} + +// RandomStringWithAlphabet generates a cryptographically random string +// with the specified length and characters set. +// +// It panics if for some reason rand.Int returns a non-nil error. +func RandomStringWithAlphabet(length int, alphabet string) string { + b := make([]byte, length) + max := big.NewInt(int64(len(alphabet))) + + for i := range b { + n, err := cryptoRand.Int(cryptoRand.Reader, max) + if err != nil { + panic(err) + } + b[i] = alphabet[n.Int64()] + } + + return string(b) +} + +// PseudorandomString generates a pseudorandom string with the specified length. +// +// The generated string matches [A-Za-z0-9]+ and it's transparent to URL-encoding. +// +// For a cryptographically random string (but a little bit slower) use RandomString instead. +func PseudorandomString(length int) string { + return PseudorandomStringWithAlphabet(length, defaultRandomAlphabet) +} + +// PseudorandomStringWithAlphabet generates a pseudorandom string +// with the specified length and characters set. +// +// For a cryptographically random (but a little bit slower) use RandomStringWithAlphabet instead. +func PseudorandomStringWithAlphabet(length int, alphabet string) string { + b := make([]byte, length) + max := len(alphabet) + + for i := range b { + b[i] = alphabet[mathRand.IntN(max)] + } + + return string(b) +} diff --git a/server/core_backend-server/tools/security/random_by_regex.go b/server/core_backend-server/tools/security/random_by_regex.go new file mode 100644 index 0000000..c7eb78f --- /dev/null +++ b/server/core_backend-server/tools/security/random_by_regex.go @@ -0,0 +1,187 @@ +package security + +import ( + cryptoRand "crypto/rand" + "errors" + "fmt" + "math/big" + "regexp/syntax" + "strings" + + "github.com/google/uuid" +) + +const defaultMaxRepeat = 6 + +var anyCharNotNLPairs = []rune{'A', 'Z', 'a', 'z', '0', '9'} + +const PredefinedAutoGeneratePattern_uuidv7 = "" + +// RandomStringByRegex generates a random string matching the regex pattern. +// If optFlags is not set, fallbacks to [syntax.Perl]. +// +// NB! While the source of the randomness comes from [crypto/rand] this method +// is not recommended to be used on its own in critical secure contexts because +// the generated length could vary too much on the used pattern and may not be +// as secure as simply calling [security.RandomString]. +// If you still insist on using it for such purposes, consider at least +// a large enough minimum length for the generated string, e.g. `[a-z0-9]{30}`. +// +// This function is inspired by github.com/pipe01/revregexp, github.com/lucasjones/reggen and other similar packages. +func RandomStringByRegex(pattern string, optFlags ...syntax.Flags) (string, error) { + if pattern == PredefinedAutoGeneratePattern_uuidv7 { + id, _ := uuid.NewV7() + return id.String(), nil + } + var flags syntax.Flags + if len(optFlags) == 0 { + flags = syntax.Perl + } else { + for _, f := range optFlags { + flags |= f + } + } + + r, err := syntax.Parse(pattern, flags) + if err != nil { + return "", err + } + + var sb = new(strings.Builder) + + err = writeRandomStringByRegex(r, sb) + if err != nil { + return "", err + } + + return sb.String(), nil +} + +func writeRandomStringByRegex(r *syntax.Regexp, sb *strings.Builder) error { + // https://pkg.go.dev/regexp/syntax#Op + switch r.Op { + case syntax.OpCharClass: + c, err := randomRuneFromPairs(r.Rune) + if err != nil { + return err + } + _, err = sb.WriteRune(c) + return err + case syntax.OpAnyChar, syntax.OpAnyCharNotNL: + c, err := randomRuneFromPairs(anyCharNotNLPairs) + if err != nil { + return err + } + _, err = sb.WriteRune(c) + return err + case syntax.OpAlternate: + idx, err := randomNumber(len(r.Sub)) + if err != nil { + return err + } + return writeRandomStringByRegex(r.Sub[idx], sb) + case syntax.OpConcat: + var err error + for _, sub := range r.Sub { + err = writeRandomStringByRegex(sub, sb) + if err != nil { + break + } + } + return err + case syntax.OpRepeat: + return repeatRandomStringByRegex(r.Sub[0], sb, r.Min, r.Max) + case syntax.OpQuest: + return repeatRandomStringByRegex(r.Sub[0], sb, 0, 1) + case syntax.OpPlus: + return repeatRandomStringByRegex(r.Sub[0], sb, 1, -1) + case syntax.OpStar: + return repeatRandomStringByRegex(r.Sub[0], sb, 0, -1) + case syntax.OpCapture: + return writeRandomStringByRegex(r.Sub[0], sb) + case syntax.OpLiteral: + _, err := sb.WriteString(string(r.Rune)) + return err + default: + return fmt.Errorf("unsupported pattern operator %d", r.Op) + } +} + +func repeatRandomStringByRegex(r *syntax.Regexp, sb *strings.Builder, min int, max int) error { + if max < 0 { + max = defaultMaxRepeat + } + + if max < min { + max = min + } + + n := min + if max != min { + randRange, err := randomNumber(max - min) + if err != nil { + return err + } + n += randRange + } + + var err error + for i := 0; i < n; i++ { + err = writeRandomStringByRegex(r, sb) + if err != nil { + return err + } + } + + return nil +} + +func randomRuneFromPairs(pairs []rune) (rune, error) { + if len(pairs)%2 != 0 { + return 0, fmt.Errorf("invalid pairs slice: odd number of elements") + } + + // Pre-calculate the cumulative size of all ranges to make the selection process more efficient. + cumulativeSizes := make([]int, len(pairs)/2) + totalRunes := 0 + for i := 0; i < len(pairs); i += 2 { + start, end := pairs[i], pairs[i+1] + if start > end { + return 0, fmt.Errorf("invalid range: start '%c' > end '%c'", start, end) + } + totalRunes += int(end - start + 1) + cumulativeSizes[i/2] = totalRunes + } + + if totalRunes == 0 { + return 0, errors.New("no runes to choose from") + } + + // Select a random number in the range of total runes. + runeNumber, err := randomNumber(totalRunes) + if err != nil { + return 0, fmt.Errorf("failed to generate random number: %w", err) + } + + // Find which range the selected number falls into using the pre-calculated cumulative sizes. + for i, size := range cumulativeSizes { + if runeNumber < size { + startRune := pairs[i*2] + previousSize := 0 + if i > 0 { + previousSize = cumulativeSizes[i-1] + } + return startRune + rune(runeNumber-previousSize), nil + } + } + + // This part should be unreachable if the logic is correct. + // It indicates a bug in this function or in randomNumber. + panic("unreachable: failed to find a rune") +} + +func randomNumber(maxSoft int) (int, error) { + randRange, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(int64(maxSoft))) + + return int(randRange.Int64()), err +} diff --git a/server/core_backend-server/tools/security/random_by_regex_test.go b/server/core_backend-server/tools/security/random_by_regex_test.go new file mode 100644 index 0000000..208e855 --- /dev/null +++ b/server/core_backend-server/tools/security/random_by_regex_test.go @@ -0,0 +1,75 @@ +package security_test + +import ( + "fmt" + "regexp" + "regexp/syntax" + "slices" + "testing" + + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestRandomStringByRegex(t *testing.T) { + generated := []string{} + + scenarios := []struct { + pattern string + flags []syntax.Flags + expectError bool + }{ + {``, nil, true}, + {`test`, nil, false}, + {`\d+`, []syntax.Flags{syntax.POSIX}, true}, + {`\d+`, nil, false}, + {`\d*`, nil, false}, + {`\d{1,20}`, nil, false}, + {`\d{5}`, nil, false}, + {`\d{0,}-abc`, nil, false}, + {`[a-zA-Z_]*`, nil, false}, + {`[^a-zA-Z]{5,30}`, nil, false}, + {`\w+_abc`, nil, false}, + {`[2-9]{10}-\w+`, nil, false}, + {`(a|b|c)`, nil, false}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%q", i, s.pattern), func(t *testing.T) { + var run func(int) + run = func(attempt int) { + str, err := security.RandomStringByRegex(s.pattern, s.flags...) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + r, err := regexp.Compile(s.pattern) + if err != nil { + t.Fatal(err) + } + if !r.Match([]byte(str)) { + t.Fatalf("Expected %q to match pattern %v", str, s.pattern) + } + + if slices.Contains(generated, str) { + if attempt > 3 { + t.Fatalf("The generated string %q already exists in\n%v", str, generated) + } + + // rerun + run(attempt + 1) + return + } + + generated = append(generated, str) + } + + run(1) + }) + } +} diff --git a/server/core_backend-server/tools/security/random_test.go b/server/core_backend-server/tools/security/random_test.go new file mode 100644 index 0000000..d0f8ece --- /dev/null +++ b/server/core_backend-server/tools/security/random_test.go @@ -0,0 +1,111 @@ +package security_test + +import ( + "fmt" + "regexp" + "slices" + "testing" + + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestRandomString(t *testing.T) { + testRandomString(t, security.RandomString) +} + +func TestRandomStringWithAlphabet(t *testing.T) { + testRandomStringWithAlphabet(t, security.RandomStringWithAlphabet) +} + +func TestPseudorandomString(t *testing.T) { + testRandomString(t, security.PseudorandomString) +} + +func TestPseudorandomStringWithAlphabet(t *testing.T) { + testRandomStringWithAlphabet(t, security.PseudorandomStringWithAlphabet) +} + +// ------------------------------------------------------------------- + +func testRandomStringWithAlphabet(t *testing.T, randomFunc func(n int, alphabet string) string) { + scenarios := []struct { + alphabet string + expectPattern string + }{ + {"0123456789_", `[0-9_]+`}, + {"abcdef123", `[abcdef123]+`}, + {"!@#$%^&*()", `[\!\@\#\$\%\^\&\*\(\)]+`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%q", i, s.alphabet), func(t *testing.T) { + generated := make([]string, 0, 500) + length := 10 + + for j := 0; j < 500; j++ { + var run func(int) + run = func(attempt int) { + result := randomFunc(length, s.alphabet) + + if len(result) != length { + t.Fatalf("(%d) Expected the length of the string to be %d, got %d", j, length, len(result)) + } + + reg := regexp.MustCompile(s.expectPattern) + if match := reg.MatchString(result); !match { + t.Fatalf("(%d) The generated string should have only %s characters, got %q", j, s.expectPattern, result) + } + + if slices.Contains(generated, result) { + if attempt > 3 { + t.Fatalf("(%d) Repeating random string - found %q in %q", j, result, generated) + } + + // rerun + run(attempt + 1) + return + } + + generated = append(generated, result) + } + + run(1) + } + }) + } +} + +func testRandomString(t *testing.T, randomFunc func(n int) string) { + generated := make([]string, 0, 500) + reg := regexp.MustCompile(`[a-zA-Z0-9]+`) + length := 10 + + for i := 0; i < 500; i++ { + var run func(int) + run = func(attempt int) { + result := randomFunc(length) + + if len(result) != length { + t.Fatalf("(%d) Expected the length of the string to be %d, got %d", i, length, len(result)) + } + + if match := reg.MatchString(result); !match { + t.Fatalf("(%d) The generated string should have only [a-zA-Z0-9]+ characters, got %q", i, result) + } + + if slices.Contains(generated, result) { + if attempt > 3 { + t.Fatalf("(%d) Repeating random string - found %q in \n%v", i, result, generated) + } + + // rerun + run(attempt + 1) + return + } + + generated = append(generated, result) + } + + run(1) + } +} diff --git a/server/core_backend-server/tools/sort/topological_sort.go b/server/core_backend-server/tools/sort/topological_sort.go new file mode 100644 index 0000000..e465a1a --- /dev/null +++ b/server/core_backend-server/tools/sort/topological_sort.go @@ -0,0 +1,161 @@ +package sort + +import ( + "errors" + "fmt" +) + +// NodeState represents the state of a node during DFS traversal +type NodeState int + +const ( + UNVISITED NodeState = 0 // unvisited + VISITING NodeState = 1 // visiting (in the DFS stack) + VISITED NodeState = 2 // visited +) + +// CycleError means that a cycle was detected in the graph during topological sorting +var CycleError = errors.New("graph contains a cycle") + +// TopologicalSortReachable sorts the nodes reachable from startNodeID in topological order. +// allNodes: a map of all nodes (nodeID -> node) +// graph: an adjacency list representation of the graph (nodeID -> []neighborIDs) +// startNodeID: the ID of the starting node for traversal +// Returns: a slice of nodes in topological order reachable from startNodeID; or an error if a cycle is detected +func TopologicalSortReachable[T any](allNodes map[string]T, graph map[string][]string, startNodeID string) ([]T, error) { + if _, ok := graph[startNodeID]; !ok { + return nil, fmt.Errorf("start node '%s' not found in the graph", startNodeID) + } + if _, ok := allNodes[startNodeID]; !ok { + return nil, fmt.Errorf("start node '%s' not found in the allNodes", startNodeID) + } + + visitedStatus := make(map[string]NodeState) + sortedNodes := make([]T, 0) + + // Init all nodes to UNVISITED + for node := range allNodes { + visitedStatus[node] = UNVISITED + } + + // dfsVisit is a recursive function that performs DFS on the graph + // It returns an error if a cycle is detected + var dfsVisit func(node string) error + dfsVisit = func(node string) error { + // Mark the current node as VISITING + visitedStatus[node] = VISITING + + // Iterate through all neighbors of the current node + neighbors := graph[node] + for _, neighbor := range neighbors { + neighborStatus, statusExists := visitedStatus[neighbor] + if !statusExists { + return fmt.Errorf("The key '%s' is in the graph, but not in allNodes", neighbor) + } + + switch neighborStatus { + case VISITING: + // If the neighbor is already being visited, a cycle is detected + return fmt.Errorf("%w: detected edge from %s to %s", CycleError, node, neighbor) + case UNVISITED: + // If the neighbor has not been visited yet, recursively visit it + if err := dfsVisit(neighbor); err != nil { + return err + } + case VISITED: + continue + } + } + + // All neighbors of the current node have been visited + // Mark the current node as VISITED + visitedStatus[node] = VISITED + // Append the current node to the end of the result list (to be reversed later) + sortedNodes = append(sortedNodes, allNodes[node]) + return nil + } + + // Start DFS from the specified startNodeID + if err := dfsVisit(startNodeID); err != nil { + return nil, err + } + + // Reverse sortedIDs to get the correct topological order + for i, j := 0, len(sortedNodes)-1; i < j; i, j = i+1, j-1 { + sortedNodes[i], sortedNodes[j] = sortedNodes[j], sortedNodes[i] + } + + return sortedNodes, nil +} + +// TopologicalSortAll sorts the nodes in topological order (DFS implementation). +// allNodes: a map of all nodes (nodeID -> node data of type T) +// graph: an adjacency list representation of the graph (nodeID -> []neighborIDs) +// Returns: a slice of nodes (of type T) in topological order; or an error if a cycle is detected +func TopologicalSortAll[T any](allNodes map[string]T, graph map[string][]string) ([]T, error) { + // visited map tracks the state of each node (UNVISITED, VISITING, VISITED) + visited := make(map[string]NodeState) + // result slice will store the nodes in topological order (built in reverse) + result := make([]T, 0, len(allNodes)) + + // Define the recursive DFS function using a closure to capture visited, graph, allNodes, and result + var dfs func(nodeID string) error + dfs = func(nodeID string) error { + // Check the current state of the node + state := visited[nodeID] + switch state { + case VISITING: + // If we encounter a node marked as VISITING, we have found a back edge, indicating a cycle. + return CycleError + case VISITED: + // If the node is already VISITED, we have already processed this node and its descendants. + return nil // No error, just skip + case UNVISITED: + // Mark the node as VISITING (currently in the recursion stack) + visited[nodeID] = VISITING + + // Recursively visit all neighbors of the current node + neighbors, exists := graph[nodeID] + if exists { // Check if the node has outgoing edges listed in the graph + for _, neighborID := range neighbors { + // Important: Ensure neighbor exists in allNodes before recursing. + // If the graph might contain edges to nodes not in allNodes, handle appropriately. + // Assuming graph only refers to nodes present in allNodes based on typical usage. + if _, nodeExists := allNodes[neighborID]; !nodeExists { + return fmt.Errorf("neighbor node '%s' not found in allNodes", neighborID) + } + + if err := dfs(neighborID); err != nil { + return err + } + } + } + + // After visiting all neighbors (and their subtrees), mark the node as VISITED. + visited[nodeID] = VISITED + // Add the node's data to the *end* of the result slice. + // Since we add nodes *after* all their dependencies are processed, + // this builds the topological sort in reverse order. + result = append(result, allNodes[nodeID]) + return nil // No error for this path + } + return fmt.Errorf("Expected error. This should not happen.") + } + + // Iterate through all nodes provided in allNodes map. + // This ensures we handle disconnected components in the graph. + for nodeID := range allNodes { + if visited[nodeID] == UNVISITED { + if err := dfs(nodeID); err != nil { + return nil, err + } + } + } + + // Reverse the slice to get the correct topological order. + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + + return result, nil +} diff --git a/server/core_backend-server/tools/sort/topological_sort_test.go b/server/core_backend-server/tools/sort/topological_sort_test.go new file mode 100644 index 0000000..a7fed9e --- /dev/null +++ b/server/core_backend-server/tools/sort/topological_sort_test.go @@ -0,0 +1,483 @@ +package sort + +import ( + "errors" + "fmt" + "reflect" // Needed for deep slice comparison + "testing" +) + +func TestTopologicalSortReachable(t *testing.T) { + // Define test cases using a table + testCases := []struct { + name string // Name for the test case + graph map[string][]string // Input graph + startNodeID string // Input start node + wantResult []string // Expected successful result (nil if error expected) + wantErr error // Expected error (nil if success expected) - use errors.Is for matching + }{ + { + name: "Basic DAG from B", + graph: map[string][]string{ + "A": {"C"}, + "B": {"C", "D"}, + "C": {"E"}, + "D": {"F"}, + "E": {"F"}, + "F": {}, + "G": {"A", "B"}, + }, + startNodeID: "B", + // Note: Topological sort can have multiple valid orders. + // We expect one valid order here. A more robust test might + // verify the dependency constraints instead of exact order. + // Let's assume the implementation might produce this order: + wantResult: []string{"B", "D", "C", "E", "F"}, + // Alternative valid result: []string{"B", "C", "D", "E", "F"} + // If your implementation consistently gives the alternative, update wantResult. + wantErr: nil, + }, + { + name: "Disconnected component from 1", + graph: map[string][]string{ + "1": {"3"}, + "2": {"3"}, + "3": {"4"}, + "4": {}, + "5": {"6"}, + "6": {}, + }, + startNodeID: "1", + wantResult: []string{"1", "3", "4"}, + wantErr: nil, + }, + { + name: "Graph with cycle", + graph: map[string][]string{ + "X": {"Y"}, + "Y": {"Z"}, + "Z": {"X"}, // Cycle Z -> X + "W": {"X"}, + }, + startNodeID: "W", + wantResult: nil, // Expecting an error, so result should be nil + wantErr: CycleError, // Expect the specific CycleError type + }, + { + name: "Start node not found", + graph: map[string][]string{ + "A": {"B"}, + }, + startNodeID: "C", + wantResult: nil, + wantErr: fmt.Errorf("start node 'C' not found in the graph"), // Expect specific error message/type + }, + { + name: "Start node exists but no outgoing edges", + graph: map[string][]string{ + "A": {}, + "B": {"A"}, + }, + startNodeID: "A", + wantResult: []string{"A"}, + wantErr: nil, + }, + { + name: "Start node is isolated", + graph: map[string][]string{ + "X": {"Y"}, + "Z": {}, // Isolated node + }, + startNodeID: "Z", + wantResult: []string{"Z"}, + wantErr: nil, + }, + { + name: "Empty graph", + graph: map[string][]string{}, + startNodeID: "A", + wantResult: nil, + wantErr: fmt.Errorf("start node 'A' not found in the graph"), + }, + } + + // Iterate over test cases + for _, tc := range testCases { + // Run each case as a subtest + t.Run(tc.name, func(t *testing.T) { + allNodes := make(map[string]string) // ไฝฟ็”จ็ฉบ็ป“ๆž„ไฝ“่Š‚็œ็ฉบ้—ด + for node, neighbors := range tc.graph { + allNodes[node] = node + for _, neighbor := range neighbors { + allNodes[neighbor] = neighbor + } + } + gotResult, gotErr := TopologicalSortReachable(allNodes, tc.graph, tc.startNodeID) + + // --- Assertions --- + + // 1. Check for unexpected errors + if tc.wantErr == nil && gotErr != nil { + t.Fatalf("TopologicalSortReachable() returned an unexpected error: %v", gotErr) + } + + // 2. Check if expected error occurred and has the right type/value + if tc.wantErr != nil { + if gotErr == nil { + t.Fatalf("TopologicalSortReachable() expected error '%v', but got nil", tc.wantErr) + } + // Use errors.Is for checking wrapped errors like CycleError + // Use direct comparison for specific fmt.Errorf instances (if messages must match exactly) + if errors.Is(tc.wantErr, CycleError) { // Check if it's CycleError or wraps it + if !errors.Is(gotErr, CycleError) { + t.Errorf("TopologicalSortReachable() expected error type '%T', but got type '%T' (%v)", CycleError, gotErr, gotErr) + } + } else if tc.wantErr.Error() != gotErr.Error() { // For other specific errors, compare messages + t.Errorf("TopologicalSortReachable() expected error '%v', but got '%v'", tc.wantErr, gotErr) + } + // Also check if result is nil on error + if gotResult != nil { + t.Errorf("TopologicalSortReachable() expected nil result on error, but got %v", gotResult) + } + } + + // 3. Check the result slice if no error was expected + if tc.wantErr == nil { + // Use reflect.DeepEqual for comparing slice contents + if !reflect.DeepEqual(gotResult, tc.wantResult) { + // Handle the potential ambiguity for the first test case + if tc.name == "Basic DAG from B" { + alternativeWant := []string{"B", "C", "D", "E", "F"} + if !reflect.DeepEqual(gotResult, alternativeWant) { + t.Errorf("TopologicalSortReachable() = %v, want %v (or %v)", gotResult, tc.wantResult, alternativeWant) + } + } else { + t.Errorf("TopologicalSortReachable() = %v, want %v", gotResult, tc.wantResult) + } + } + } + }) + } +} + +// Helper type for testing +type TestNode struct { + ID string +} + +// TestTopologicalSortAll tests the TopologicalSortAll function +func TestTopologicalSortAll(t *testing.T) { + // Helper to get ID from TestNode + getNodeID := func(n TestNode) string { return n.ID } + + // Define test cases + testCases := []struct { + name string + allNodes map[string]TestNode + graph map[string][]string + expectError error + // We don't specify expectedResult slice because multiple valid sorts can exist. + // Instead, we'll use isValidTopologicalSort. + }{ + { + name: "Empty Graph", + allNodes: map[string]TestNode{}, + graph: map[string][]string{}, + expectError: nil, + }, + { + name: "Single Node", + allNodes: map[string]TestNode{ + "A": {ID: "A"}, + }, + graph: map[string][]string{ + "A": {}, + }, + expectError: nil, + }, + { + name: "Simple Linear Chain", // A -> B -> C + allNodes: map[string]TestNode{ + "A": {ID: "A"}, + "B": {ID: "B"}, + "C": {ID: "C"}, + }, + graph: map[string][]string{ + "A": {"B"}, + "B": {"C"}, + "C": {}, + }, + expectError: nil, + }, + { + name: "Multiple Independent Nodes", // A, B + allNodes: map[string]TestNode{ + "A": {ID: "A"}, + "B": {ID: "B"}, + }, + graph: map[string][]string{ + "A": {}, + "B": {}, + }, + expectError: nil, + }, + { + name: "DAG with Multiple Paths", // A->B, A->C, B->D, C->D + allNodes: map[string]TestNode{ + "A": {ID: "A"}, + "B": {ID: "B"}, + "C": {ID: "C"}, + "D": {ID: "D"}, + }, + graph: map[string][]string{ + "A": {"B", "C"}, + "B": {"D"}, + "C": {"D"}, + "D": {}, + }, + expectError: nil, + }, + { + name: "Disconnected Components", // A->B, C->D + allNodes: map[string]TestNode{ + "A": {ID: "A"}, + "B": {ID: "B"}, + "C": {ID: "C"}, + "D": {ID: "D"}, + }, + graph: map[string][]string{ + "A": {"B"}, + "B": {}, + "C": {"D"}, + "D": {}, + }, + expectError: nil, + }, + { + name: "More Complex DAG", // 5->{11,2}, 7->{11,8}, 3->{8,10}, 11->{2,9,10}, 8->9 + allNodes: map[string]TestNode{ + "2": {ID: "2"}, + "3": {ID: "3"}, + "5": {ID: "5"}, + "7": {ID: "7"}, + "8": {ID: "8"}, + "9": {ID: "9"}, + "10": {ID: "10"}, + "11": {ID: "11"}, + }, + graph: map[string][]string{ + "5": {"11", "2"}, + "7": {"11", "8"}, + "3": {"8", "10"}, + "11": {"2", "9", "10"}, + "8": {"9"}, + "2": {}, + "9": {}, + "10": {}, + }, + expectError: nil, + // Possible sorts: [3, 5, 7, 8, 11, 2, 9, 10], [5, 7, 3, 11, 8, 10, 9, 2], etc. + }, + { + name: "Simple Cycle", // A -> B, B -> A + allNodes: map[string]TestNode{ + "A": {ID: "A"}, + "B": {ID: "B"}, + }, + graph: map[string][]string{ + "A": {"B"}, + "B": {"A"}, + }, + expectError: CycleError, + }, + { + name: "Longer Cycle", // A -> B -> C -> A + allNodes: map[string]TestNode{ + "A": {ID: "A"}, + "B": {ID: "B"}, + "C": {ID: "C"}, + }, + graph: map[string][]string{ + "A": {"B"}, + "B": {"C"}, + "C": {"A"}, + }, + expectError: CycleError, + }, + { + name: "Self Loop", // A -> A + allNodes: map[string]TestNode{ + "A": {ID: "A"}, + }, + graph: map[string][]string{ + "A": {"A"}, + }, + expectError: CycleError, + }, + { + name: "Cycle within Larger Graph", // A->B, B->C, C->D, D->B (cycle BCD), E->A + allNodes: map[string]TestNode{ + "A": {ID: "A"}, + "B": {ID: "B"}, + "C": {ID: "C"}, + "D": {ID: "D"}, + "E": {ID: "E"}, + }, + graph: map[string][]string{ + "A": {"B"}, + "B": {"C"}, + "C": {"D"}, + "D": {"B"}, // Cycle back to B + "E": {"A"}, + }, + expectError: CycleError, + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // --- Run the function under test --- + result, err := TopologicalSortAll(tc.allNodes, tc.graph) + // --- --- + + // Check error expectation + if tc.expectError != nil { + if err == nil { + t.Errorf("Expected error '%v', but got nil", tc.expectError) + } else if !errors.Is(err, tc.expectError) { // Use errors.Is for potential wrapping + t.Errorf("Expected error '%v', but got '%v'", tc.expectError, err) + } + // If error was expected, don't check the result slice (should be nil) + if result != nil { + t.Errorf("Expected nil result on error, but got %v", result) + } + return // End test case for expected error + } + + // If no error was expected, check if one occurred + if err != nil { + t.Fatalf("Expected no error, but got: %v", err) + } + + // Validate the topological sort result + if ok, reason := isValidTopologicalSort(tc.allNodes, tc.graph, result, getNodeID); !ok { + // The helper function prints details, just mark failure here + // Convert result to IDs for easier debugging output + resultIDs := make([]string, len(result)) + for i, n := range result { + resultIDs[i] = getNodeID(n) + } + t.Errorf("Result %v is not a valid topological sort for the given graph: %s", resultIDs, reason) + } else { + // Optional: Log the valid sort found + resultIDs := make([]string, len(result)) + for i, n := range result { + resultIDs[i] = getNodeID(n) + } + t.Logf("Valid topological sort found: %v", resultIDs) + } + }) + } +} + +func isValidTopologicalSort[T any]( + allNodes map[string]T, + graph map[string][]string, + result []T, + idGetter func(T) string, +) (bool, string) { + + // --- Check 1: Length Mismatch --- + // The result must contain the same number of nodes as expected. + if len(result) != len(allNodes) { + return false, fmt.Sprintf("length mismatch: expected %d nodes, but result has %d", len(allNodes), len(result)) + } + + // Handle the trivial case of an empty graph explicitly for clarity + if len(allNodes) == 0 { + if len(result) == 0 { + return true, "" // Empty graph has an empty valid sort + } + // Already covered by length check above, but reinforces logic + // return false, "length mismatch: expected 0 nodes for empty graph, but result is not empty" + } + + // --- Check 2: Node Set Verification (Completeness & Uniqueness) --- + // Build a map to track nodes seen in the result and their positions. + nodePositions := make(map[string]int, len(result)) + seenIDs := make(map[string]bool, len(result)) + resultIDs := make([]string, len(result)) // Keep IDs for potential error messages + + for i, node := range result { + nodeID := idGetter(node) + resultIDs[i] = nodeID // Store for error messages + + // 2a: Check if the node from the result is one of the expected nodes. + if _, expected := allNodes[nodeID]; !expected { + return false, fmt.Sprintf("unexpected node: result contains node '%s' which was not in the expected set of nodes", nodeID) + } + + // 2b: Check for duplicate nodes in the result. + if seenIDs[nodeID] { + return false, fmt.Sprintf("duplicate node: node '%s' appears more than once in the result", nodeID) + } + + // Mark node as seen and record its position. + seenIDs[nodeID] = true + nodePositions[nodeID] = i + } + + // 2c: Verify all expected nodes were present in the result. + // This check is technically redundant if the length check passed and no duplicates were found, + // but it adds clarity and catches potential logic errors elsewhere. + if len(seenIDs) != len(allNodes) { + // This case implies len(result) == len(allNodes) but len(seenIDs) < len(allNodes), + // meaning duplicates *must* have existed, which should have been caught earlier. + // Or, it implies a node in `result` wasn't in `allNodes`, also caught earlier. + // It primarily guards against unexpected states or logic flaws. + return false, fmt.Sprintf("missing nodes: expected %d unique nodes, but result only contains %d unique nodes from the expected set", len(allNodes), len(seenIDs)) + } + + // --- Check 3: Dependency Order Verification --- + // Iterate through all defined edges in the graph. + for uID, neighbors := range graph { + // Get the position of the source node 'u'. + // We only care about edges where the source node 'uID' was actually expected + // to be part of the sort (i.e., it's a key in allNodes). + if _, uIsExpected := allNodes[uID]; !uIsExpected { + continue // Skip edges originating from nodes not in the defined set. + } + + uPos, uOk := nodePositions[uID] + if !uOk { + // This indicates that an expected node 'uID' (which is part of an edge in the graph) + // was NOT found in the 'result' slice. This contradicts Check 2c passing. + // This signifies a logic error either here or in Check 2, or inconsistent input state. + return false, fmt.Sprintf("internal validation error: expected node '%s' (source of edge) not found in result positions map, though it should be present", uID) + } + + // Check each dependency (edge u -> v). + for _, vID := range neighbors { + // Similarly, only check dependencies where the target node 'vID' was expected. + if _, vIsExpected := allNodes[vID]; !vIsExpected { + continue // Skip edges pointing to nodes not in the defined set. + } + + vPos, vOk := nodePositions[vID] + if !vOk { + // An expected node 'vID' (target of an edge) was not found in the result. + return false, fmt.Sprintf("internal validation error: expected node '%s' (target of edge from '%s') not found in result positions map, though it should be present", vID, uID) + } + + // The core topological sort check: u must come before v. + if uPos >= vPos { + return false, fmt.Sprintf("dependency violation: node '%s' must appear before '%s' (due to edge %s -> %s), but positions are %d and %d in result %v", uID, vID, uID, vID, uPos, vPos, resultIDs) + } + } + } + + // --- Success --- + // If all checks passed, the result is a valid topological sort. + return true, "" +} diff --git a/server/core_backend-server/tools/store/store.go b/server/core_backend-server/tools/store/store.go new file mode 100644 index 0000000..0c39571 --- /dev/null +++ b/server/core_backend-server/tools/store/store.go @@ -0,0 +1,250 @@ +package store + +import ( + "encoding/json" + "sync" +) + +// @todo remove after https://github.com/golang/go/issues/20135 +const ShrinkThreshold = 200 // the number is arbitrary chosen + +// Store defines a concurrent safe in memory key-value data store. +type Store[K comparable, T any] struct { + data map[K]T + mu sync.RWMutex + deleted int64 +} + +// New creates a new Store[T] instance with a shallow copy of the provided data (if any). +func New[K comparable, T any](data map[K]T) *Store[K, T] { + s := &Store[K, T]{} + + s.Reset(data) + + return s +} + +// Reset clears the store and replaces the store data with a +// shallow copy of the provided newData. +func (s *Store[K, T]) Reset(newData map[K]T) { + s.mu.Lock() + defer s.mu.Unlock() + + if len(newData) > 0 { + s.data = make(map[K]T, len(newData)) + for k, v := range newData { + s.data[k] = v + } + } else { + s.data = make(map[K]T) + } + + s.deleted = 0 +} + +// Length returns the current number of elements in the store. +func (s *Store[K, T]) Length() int { + s.mu.RLock() + defer s.mu.RUnlock() + + return len(s.data) +} + +// RemoveAll removes all the existing store entries. +func (s *Store[K, T]) RemoveAll() { + s.Reset(nil) +} + +// Remove removes a single entry from the store. +// +// Remove does nothing if key doesn't exist in the store. +func (s *Store[K, T]) Remove(key K) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.data, key) + s.deleted++ + + // reassign to a new map so that the old one can be gc-ed because it doesn't shrink + // + // @todo remove after https://github.com/golang/go/issues/20135 + if s.deleted >= ShrinkThreshold { + newData := make(map[K]T, len(s.data)) + for k, v := range s.data { + newData[k] = v + } + s.data = newData + s.deleted = 0 + } +} + +// Has checks if element with the specified key exist or not. +func (s *Store[K, T]) Has(key K) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + _, ok := s.data[key] + + return ok +} + +// Get returns a single element value from the store. +// +// If key is not set, the zero T value is returned. +func (s *Store[K, T]) Get(key K) T { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.data[key] +} + +// GetOk is similar to Get but returns also a boolean indicating whether the key exists or not. +func (s *Store[K, T]) GetOk(key K) (T, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + v, ok := s.data[key] + + return v, ok +} + +// GetAll returns a shallow copy of the current store data. +func (s *Store[K, T]) GetAll() map[K]T { + s.mu.RLock() + defer s.mu.RUnlock() + + var clone = make(map[K]T, len(s.data)) + + for k, v := range s.data { + clone[k] = v + } + + return clone +} + +// Values returns a slice with all of the current store values. +func (s *Store[K, T]) Values() []T { + s.mu.RLock() + defer s.mu.RUnlock() + + var values = make([]T, 0, len(s.data)) + + for _, v := range s.data { + values = append(values, v) + } + + return values +} + +// Set sets (or overwrite if already exists) a new value for key. +func (s *Store[K, T]) Set(key K, value T) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.data == nil { + s.data = make(map[K]T) + } + + s.data[key] = value +} + +// SetFunc sets (or overwrite if already exists) a new value resolved +// from the function callback for the provided key. +// +// The function callback receives as argument the old store element value (if exists). +// If there is no old store element, the argument will be the T zero value. +// +// Example: +// +// s := store.New[string, int](nil) +// s.SetFunc("count", func(old int) int { +// return old + 1 +// }) +func (s *Store[K, T]) SetFunc(key K, fn func(old T) T) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.data == nil { + s.data = make(map[K]T) + } + + s.data[key] = fn(s.data[key]) +} + +// GetOrSet retrieves a single existing value for the provided key +// or stores a new one if it doesn't exist. +func (s *Store[K, T]) GetOrSet(key K, setFunc func() T) T { + // lock only reads to minimize locks contention + s.mu.RLock() + v, ok := s.data[key] + s.mu.RUnlock() + + if !ok { + s.mu.Lock() + v = setFunc() + if s.data == nil { + s.data = make(map[K]T) + } + s.data[key] = v + s.mu.Unlock() + } + + return v +} + +// SetIfLessThanLimit sets (or overwrite if already exist) a new value for key. +// +// This method is similar to Set() but **it will skip adding new elements** +// to the store if the store length has reached the specified limit. +// false is returned if maxAllowedElements limit is reached. +func (s *Store[K, T]) SetIfLessThanLimit(key K, value T, maxAllowedElements int) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if s.data == nil { + s.data = make(map[K]T) + } + + // check for existing item + _, ok := s.data[key] + + if !ok && len(s.data) >= maxAllowedElements { + // cannot add more items + return false + } + + // add/overwrite item + s.data[key] = value + + return true +} + +// UnmarshalJSON implements [json.Unmarshaler] and imports the +// provided JSON data into the store. +// +// The store entries that match with the ones from the data will be overwritten with the new value. +func (s *Store[K, T]) UnmarshalJSON(data []byte) error { + raw := map[K]T{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.data == nil { + s.data = make(map[K]T) + } + + for k, v := range raw { + s.data[k] = v + } + + return nil +} + +// MarshalJSON implements [json.Marshaler] and export the current +// store data into valid JSON. +func (s *Store[K, T]) MarshalJSON() ([]byte, error) { + return json.Marshal(s.GetAll()) +} diff --git a/server/core_backend-server/tools/store/store_test.go b/server/core_backend-server/tools/store/store_test.go new file mode 100644 index 0000000..24f2874 --- /dev/null +++ b/server/core_backend-server/tools/store/store_test.go @@ -0,0 +1,416 @@ +package store_test + +import ( + "bytes" + "encoding/json" + "slices" + "strconv" + "testing" + + "github.com/pocketbase/pocketbase/tools/store" +) + +func TestNew(t *testing.T) { + data := map[string]int{"test1": 1, "test2": 2} + originalRawData, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + s := store.New(data) + s.Set("test3", 3) // add 1 item + s.Remove("test1") // remove 1 item + + // check if data was shallow copied + rawData, _ := json.Marshal(data) + if !bytes.Equal(originalRawData, rawData) { + t.Fatalf("Expected data \n%s, \ngot \n%s", originalRawData, rawData) + } + + if s.Has("test1") { + t.Fatalf("Expected test1 to be deleted, got %v", s.Get("test1")) + } + + if v := s.Get("test2"); v != 2 { + t.Fatalf("Expected test2 to be %v, got %v", 2, v) + } + + if v := s.Get("test3"); v != 3 { + t.Fatalf("Expected test3 to be %v, got %v", 3, v) + } +} + +func TestReset(t *testing.T) { + s := store.New(map[string]int{"test1": 1}) + + data := map[string]int{"test2": 2} + originalRawData, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + s.Reset(data) + s.Set("test3", 3) + + // check if data was shallow copied + rawData, _ := json.Marshal(data) + if !bytes.Equal(originalRawData, rawData) { + t.Fatalf("Expected data \n%s, \ngot \n%s", originalRawData, rawData) + } + + if s.Has("test1") { + t.Fatalf("Expected test1 to be deleted, got %v", s.Get("test1")) + } + + if v := s.Get("test2"); v != 2 { + t.Fatalf("Expected test2 to be %v, got %v", 2, v) + } + + if v := s.Get("test3"); v != 3 { + t.Fatalf("Expected test3 to be %v, got %v", 3, v) + } +} + +func TestLength(t *testing.T) { + s := store.New(map[string]int{"test1": 1}) + s.Set("test2", 2) + + if v := s.Length(); v != 2 { + t.Fatalf("Expected length %d, got %d", 2, v) + } +} + +func TestRemoveAll(t *testing.T) { + s := store.New(map[string]bool{"test1": true, "test2": true}) + + keys := []string{"test1", "test2"} + + s.RemoveAll() + + for i, key := range keys { + if s.Has(key) { + t.Errorf("(%d) Expected %q to be removed", i, key) + } + } +} + +func TestRemove(t *testing.T) { + s := store.New(map[string]bool{"test": true}) + + keys := []string{"test", "missing"} + + for i, key := range keys { + s.Remove(key) + if s.Has(key) { + t.Errorf("(%d) Expected %q to be removed", i, key) + } + } +} + +func TestHas(t *testing.T) { + s := store.New(map[string]int{"test1": 0, "test2": 1}) + + scenarios := []struct { + key string + exist bool + }{ + {"test1", true}, + {"test2", true}, + {"missing", false}, + } + + for i, scenario := range scenarios { + exist := s.Has(scenario.key) + if exist != scenario.exist { + t.Errorf("(%d) Expected %v, got %v", i, scenario.exist, exist) + } + } +} + +func TestGet(t *testing.T) { + s := store.New(map[string]int{"test1": 0, "test2": 1}) + + scenarios := []struct { + key string + expect int + }{ + {"test1", 0}, + {"test2", 1}, + {"missing", 0}, // should auto fallback to the zero value + } + + for _, scenario := range scenarios { + t.Run(scenario.key, func(t *testing.T) { + val := s.Get(scenario.key) + if val != scenario.expect { + t.Fatalf("Expected %v, got %v", scenario.expect, val) + } + }) + } +} + +func TestGetOk(t *testing.T) { + s := store.New(map[string]int{"test1": 0, "test2": 1}) + + scenarios := []struct { + key string + expectValue int + expectOk bool + }{ + {"test1", 0, true}, + {"test2", 1, true}, + {"missing", 0, false}, // should auto fallback to the zero value + } + + for _, scenario := range scenarios { + t.Run(scenario.key, func(t *testing.T) { + val, ok := s.GetOk(scenario.key) + + if ok != scenario.expectOk { + t.Fatalf("Expected ok %v, got %v", scenario.expectOk, ok) + } + + if val != scenario.expectValue { + t.Fatalf("Expected %v, got %v", scenario.expectValue, val) + } + }) + } +} + +func TestGetAll(t *testing.T) { + data := map[string]int{ + "a": 1, + "b": 2, + } + + s := store.New(data) + + // fetch and delete each key to make sure that it was shallow copied + result := s.GetAll() + for k := range result { + delete(result, k) + } + + // refetch again + result = s.GetAll() + + if len(result) != len(data) { + t.Fatalf("Expected %d, got %d items", len(data), len(result)) + } + + for k := range result { + if result[k] != data[k] { + t.Fatalf("Expected %s to be %v, got %v", k, data[k], result[k]) + } + } +} + +func TestValues(t *testing.T) { + data := map[string]int{ + "a": 1, + "b": 2, + } + + values := store.New(data).Values() + + expected := []int{1, 2} + + if len(values) != len(expected) { + t.Fatalf("Expected %d values, got %d", len(expected), len(values)) + } + + for _, v := range expected { + if !slices.Contains(values, v) { + t.Fatalf("Missing value %v in\n%v", v, values) + } + } +} + +func TestSet(t *testing.T) { + s := store.Store[string, int]{} + + data := map[string]int{"test1": 0, "test2": 1, "test3": 3} + + // set values + for k, v := range data { + s.Set(k, v) + } + + // verify that the values are set + for k, v := range data { + if !s.Has(k) { + t.Errorf("Expected key %q", k) + } + + val := s.Get(k) + if val != v { + t.Errorf("Expected %v, got %v for key %q", v, val, k) + } + } +} + +func TestSetFunc(t *testing.T) { + s := store.Store[string, int]{} + + // non existing value + s.SetFunc("test", func(old int) int { + if old != 0 { + t.Fatalf("Expected old value %d, got %d", 0, old) + } + return old + 2 + }) + if v := s.Get("test"); v != 2 { + t.Fatalf("Expected the stored value to be %d, got %d", 2, v) + } + + // increment existing value + s.SetFunc("test", func(old int) int { + if old != 2 { + t.Fatalf("Expected old value %d, got %d", 2, old) + } + return old + 1 + }) + if v := s.Get("test"); v != 3 { + t.Fatalf("Expected the stored value to be %d, got %d", 3, v) + } +} + +func TestGetOrSet(t *testing.T) { + s := store.New(map[string]int{ + "test1": 0, + "test2": 1, + "test3": 3, + }) + + scenarios := []struct { + key string + value int + expected int + }{ + {"test2", 20, 1}, + {"test3", 2, 3}, + {"test_new", 20, 20}, + {"test_new", 50, 20}, // should return the previously inserted value + } + + for _, scenario := range scenarios { + t.Run(scenario.key, func(t *testing.T) { + result := s.GetOrSet(scenario.key, func() int { + return scenario.value + }) + + if result != scenario.expected { + t.Fatalf("Expected %v, got %v", scenario.expected, result) + } + }) + } +} + +func TestSetIfLessThanLimit(t *testing.T) { + s := store.Store[string, int]{} + + limit := 2 + + // set values + scenarios := []struct { + key string + value int + expected bool + }{ + {"test1", 1, true}, + {"test2", 2, true}, + {"test3", 3, false}, + {"test2", 4, true}, // overwrite + } + + for i, scenario := range scenarios { + result := s.SetIfLessThanLimit(scenario.key, scenario.value, limit) + + if result != scenario.expected { + t.Errorf("(%d) Expected result %v, got %v", i, scenario.expected, result) + } + + if !scenario.expected && s.Has(scenario.key) { + t.Errorf("(%d) Expected key %q to not be set", i, scenario.key) + } + + val := s.Get(scenario.key) + if scenario.expected && val != scenario.value { + t.Errorf("(%d) Expected value %v, got %v", i, scenario.value, val) + } + } +} + +func TestUnmarshalJSON(t *testing.T) { + s := store.Store[string, string]{} + s.Set("b", "old") // should be overwritten + s.Set("c", "test3") // ensures that the old values are not removed + + raw := []byte(`{"a":"test1", "b":"test2"}`) + if err := json.Unmarshal(raw, &s); err != nil { + t.Fatal(err) + } + + if v := s.Get("a"); v != "test1" { + t.Fatalf("Expected store.a to be %q, got %q", "test1", v) + } + + if v := s.Get("b"); v != "test2" { + t.Fatalf("Expected store.b to be %q, got %q", "test2", v) + } + + if v := s.Get("c"); v != "test3" { + t.Fatalf("Expected store.c to be %q, got %q", "test3", v) + } +} + +func TestMarshalJSON(t *testing.T) { + s := &store.Store[string, string]{} + s.Set("a", "test1") + s.Set("b", "test2") + + expected := []byte(`{"a":"test1", "b":"test2"}`) + + result, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + + if bytes.Equal(result, expected) { + t.Fatalf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestShrink(t *testing.T) { + s := &store.Store[string, int]{} + + total := 1000 + + for i := 0; i < total; i++ { + s.Set(strconv.Itoa(i), i) + } + + if s.Length() != total { + t.Fatalf("Expected %d items, got %d", total, s.Length()) + } + + // trigger map "shrink" + for i := 0; i < store.ShrinkThreshold; i++ { + s.Remove(strconv.Itoa(i)) + } + + // ensure that after the deletion, the new map was copied properly + if s.Length() != total-store.ShrinkThreshold { + t.Fatalf("Expected %d items, got %d", total-store.ShrinkThreshold, s.Length()) + } + + for k := range s.GetAll() { + kInt, err := strconv.Atoi(k) + if err != nil { + t.Fatalf("failed to convert %s into int: %v", k, err) + } + if kInt < store.ShrinkThreshold { + t.Fatalf("Key %q should have been deleted", k) + } + } +} diff --git a/server/core_backend-server/tools/subscriptions/broker.go b/server/core_backend-server/tools/subscriptions/broker.go new file mode 100644 index 0000000..82cf30d --- /dev/null +++ b/server/core_backend-server/tools/subscriptions/broker.go @@ -0,0 +1,65 @@ +package subscriptions + +import ( + "fmt" + + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/store" +) + +// Broker defines a struct for managing subscriptions clients. +type Broker struct { + store *store.Store[string, Client] +} + +// NewBroker initializes and returns a new Broker instance. +func NewBroker() *Broker { + return &Broker{ + store: store.New[string, Client](nil), + } +} + +// Clients returns a shallow copy of all registered clients indexed +// with their connection id. +func (b *Broker) Clients() map[string]Client { + return b.store.GetAll() +} + +// ChunkedClients splits the current clients into a chunked slice. +func (b *Broker) ChunkedClients(chunkSize int) [][]Client { + return list.ToChunks(b.store.Values(), chunkSize) +} + +// TotalClients returns the total number of registered clients. +func (b *Broker) TotalClients() int { + return b.store.Length() +} + +// ClientById finds a registered client by its id. +// +// Returns non-nil error when client with clientId is not registered. +func (b *Broker) ClientById(clientId string) (Client, error) { + client, ok := b.store.GetOk(clientId) + if !ok { + return nil, fmt.Errorf("no client associated with connection ID %q", clientId) + } + + return client, nil +} + +// Register adds a new client to the broker instance. +func (b *Broker) Register(client Client) { + b.store.Set(client.Id(), client) +} + +// Unregister removes a single client by its id and marks it as discarded. +// +// If client with clientId doesn't exist, this method does nothing. +func (b *Broker) Unregister(clientId string) { + client := b.store.Get(clientId) + if client == nil { + return + } + client.Discard() + b.store.Remove(clientId) +} diff --git a/server/core_backend-server/tools/subscriptions/broker_test.go b/server/core_backend-server/tools/subscriptions/broker_test.go new file mode 100644 index 0000000..74955d6 --- /dev/null +++ b/server/core_backend-server/tools/subscriptions/broker_test.go @@ -0,0 +1,134 @@ +package subscriptions_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/tools/subscriptions" +) + +func TestNewBroker(t *testing.T) { + b := subscriptions.NewBroker() + + if b.Clients() == nil { + t.Fatal("Expected clients map to be initialized") + } +} + +func TestClients(t *testing.T) { + b := subscriptions.NewBroker() + + if total := len(b.Clients()); total != 0 { + t.Fatalf("Expected no clients, got %v", total) + } + + b.Register(subscriptions.NewDefaultClient()) + b.Register(subscriptions.NewDefaultClient()) + + // check if it is a shallow copy + clients := b.Clients() + for k := range clients { + delete(clients, k) + } + + // should return a new copy + if total := len(b.Clients()); total != 2 { + t.Fatalf("Expected 2 clients, got %v", total) + } +} + +func TestChunkedClients(t *testing.T) { + b := subscriptions.NewBroker() + + chunks := b.ChunkedClients(2) + if total := len(chunks); total != 0 { + t.Fatalf("Expected %d chunks, got %d", 0, total) + } + + b.Register(subscriptions.NewDefaultClient()) + b.Register(subscriptions.NewDefaultClient()) + b.Register(subscriptions.NewDefaultClient()) + + chunks = b.ChunkedClients(2) + if total := len(chunks); total != 2 { + t.Fatalf("Expected %d chunks, got %d", 2, total) + } + + if total := len(chunks[0]); total != 2 { + t.Fatalf("Expected the first chunk to have 2 clients, got %d", total) + } + + if total := len(chunks[1]); total != 1 { + t.Fatalf("Expected the second chunk to have 1 client, got %d", total) + } +} + +func TestTotalClients(t *testing.T) { + b := subscriptions.NewBroker() + + if total := b.TotalClients(); total != 0 { + t.Fatalf("Expected no clients, got %d", total) + } + + b.Register(subscriptions.NewDefaultClient()) + b.Register(subscriptions.NewDefaultClient()) + + if total := b.TotalClients(); total != 2 { + t.Fatalf("Expected %d clients, got %d", 2, total) + } +} + +func TestClientById(t *testing.T) { + b := subscriptions.NewBroker() + + clientA := subscriptions.NewDefaultClient() + clientB := subscriptions.NewDefaultClient() + b.Register(clientA) + b.Register(clientB) + + resultClient, err := b.ClientById(clientA.Id()) + if err != nil { + t.Fatalf("Expected client with id %s, got error %v", clientA.Id(), err) + } + if resultClient.Id() != clientA.Id() { + t.Fatalf("Expected client %s, got %s", clientA.Id(), resultClient.Id()) + } + + if c, err := b.ClientById("missing"); err == nil { + t.Fatalf("Expected error, found client %v", c) + } +} + +func TestRegister(t *testing.T) { + b := subscriptions.NewBroker() + + client := subscriptions.NewDefaultClient() + b.Register(client) + + if _, err := b.ClientById(client.Id()); err != nil { + t.Fatalf("Expected client with id %s, got error %v", client.Id(), err) + } +} + +func TestUnregister(t *testing.T) { + b := subscriptions.NewBroker() + + clientA := subscriptions.NewDefaultClient() + clientB := subscriptions.NewDefaultClient() + b.Register(clientA) + b.Register(clientB) + + if _, err := b.ClientById(clientA.Id()); err != nil { + t.Fatalf("Expected client with id %s, got error %v", clientA.Id(), err) + } + + b.Unregister(clientA.Id()) + + if c, err := b.ClientById(clientA.Id()); err == nil { + t.Fatalf("Expected error, found client %v", c) + } + + // clientB shouldn't have been removed + if _, err := b.ClientById(clientB.Id()); err != nil { + t.Fatalf("Expected client with id %s, got error %v", clientB.Id(), err) + } +} diff --git a/server/core_backend-server/tools/subscriptions/client.go b/server/core_backend-server/tools/subscriptions/client.go new file mode 100644 index 0000000..0cd2a58 --- /dev/null +++ b/server/core_backend-server/tools/subscriptions/client.go @@ -0,0 +1,307 @@ +package subscriptions + +import ( + "encoding/json" + "net/url" + "strings" + "sync" + + "github.com/pocketbase/pocketbase/tools/inflector" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/spf13/cast" +) + +const optionsParam = "options" + +// SubscriptionOptions defines the request options (query params, headers, etc.) +// for a single subscription topic. +type SubscriptionOptions struct { + Query map[string]string `json:"query"` + Headers map[string]string `json:"headers"` +} + +// Client is an interface for a generic subscription client. +type Client interface { + // Id Returns the unique id of the client. + Id() string + + // Channel returns the client's communication channel. + // + // NB! The channel shouldn't be used after calling Discard(). + Channel() chan Message + + // Subscriptions returns a shallow copy of the client subscriptions matching the prefixes. + // If no prefix is specified, returns all subscriptions. + Subscriptions(prefixes ...string) map[string]SubscriptionOptions + + // RawSubscriptions returns a slice of all subscription strings that the client is subscribed to. + RawSubscriptions() []string + + // Subscribe subscribes the client to the provided subscriptions list. + // + // Each subscription can also have "options" (json serialized SubscriptionOptions) as query parameter. + // + // Example: + // + // Subscribe( + // "subscriptionA", + // `subscriptionB?options={"query":{"a":1},"headers":{"x_token":"abc"}}`, + // ) + Subscribe(subs ...string) + + // Unsubscribe unsubscribes the client from the provided subscriptions list. + Unsubscribe(subs ...string) + + // HasSubscription checks if the client is subscribed to `sub`. + HasSubscription(sub string) bool + + // Set stores any value to the client's context. + Set(key string, value any) + + // Unset removes a single value from the client's context. + Unset(key string) + + // Get retrieves the key value from the client's context. + Get(key string) any + + // Discard marks the client as "discarded" (and closes its channel), + // meaning that it shouldn't be used anymore for sending new messages. + // + // It is safe to call Discard() multiple times. + Discard() + + // IsDiscarded indicates whether the client has been "discarded" + // and should no longer be used. + IsDiscarded() bool + + // Send sends the specified message to the client's channel (if not discarded). + Send(m Message) +} + +// ensures that DefaultClient satisfies the Client interface +var _ Client = (*DefaultClient)(nil) + +// DefaultClient defines a generic subscription client. +type DefaultClient struct { + store map[string]any + subscriptions map[string]SubscriptionOptions + channel chan Message + id string + mu sync.RWMutex + isDiscarded bool +} + +// NewDefaultClient creates and returns a new DefaultClient instance. +func NewDefaultClient(optionalClientId ...string) *DefaultClient { + var clientId string + if len(optionalClientId) > 0 && optionalClientId[0] != "" { + clientId = optionalClientId[0] + } else { + clientId = security.RandomString(40) + } + + return &DefaultClient{ + id: clientId, + store: map[string]any{}, + channel: make(chan Message), + subscriptions: map[string]SubscriptionOptions{}, + } +} + +// Id implements the [Client.Id] interface method. +func (c *DefaultClient) Id() string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.id +} + +// Channel implements the [Client.Channel] interface method. +func (c *DefaultClient) Channel() chan Message { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.channel +} + +// Subscriptions implements the [Client.Subscriptions] interface method. +// +// It returns a shallow copy of the client subscriptions matching the prefixes. +// If no prefix is specified, returns all subscriptions. +func (c *DefaultClient) Subscriptions(prefixes ...string) map[string]SubscriptionOptions { + c.mu.RLock() + defer c.mu.RUnlock() + + // no prefix -> return copy of all subscriptions + if len(prefixes) == 0 { + result := make(map[string]SubscriptionOptions, len(c.subscriptions)) + + for s, options := range c.subscriptions { + result[s] = options + } + + return result + } + + result := make(map[string]SubscriptionOptions) + + for _, prefix := range prefixes { + for s, options := range c.subscriptions { + // "?" ensures that the options query start character is always there + // so that it can be used as an end separator when looking only for the main subscription topic + if strings.HasPrefix(s+"?", prefix) { + result[s] = options + } + } + } + + return result +} + +// Subscribe implements the [Client.Subscribe] interface method. +// +// Empty subscriptions (aka. "") are ignored. +func (c *DefaultClient) Subscribe(subs ...string) { + c.mu.Lock() + defer c.mu.Unlock() + + for _, s := range subs { + if s == "" { + continue // skip empty + } + + // extract subscription options (if any) + rawOptions := struct { + // note: any instead of string to minimize the breaking changes with earlier versions + Query map[string]any `json:"query"` + Headers map[string]any `json:"headers"` + }{} + u, err := url.Parse(s) + if err == nil { + raw := u.Query().Get(optionsParam) + if raw != "" { + json.Unmarshal([]byte(raw), &rawOptions) + } + } + + options := SubscriptionOptions{ + Query: make(map[string]string, len(rawOptions.Query)), + Headers: make(map[string]string, len(rawOptions.Headers)), + } + + // normalize query + // (currently only single string values are supported for consistency with the default routes handling) + for k, v := range rawOptions.Query { + options.Query[k] = cast.ToString(v) + } + + // normalize headers name and values, eg. "X-Token" is converted to "x_token" + // (currently only single string values are supported for consistency with the default routes handling) + for k, v := range rawOptions.Headers { + options.Headers[inflector.Snakecase(k)] = cast.ToString(v) + } + + c.subscriptions[s] = options + } +} + +// Unsubscribe implements the [Client.Unsubscribe] interface method. +// +// If subs is not set, this method removes all registered client's subscriptions. +func (c *DefaultClient) Unsubscribe(subs ...string) { + c.mu.Lock() + defer c.mu.Unlock() + + if len(subs) > 0 { + for _, s := range subs { + delete(c.subscriptions, s) + } + } else { + // unsubscribe all + for s := range c.subscriptions { + delete(c.subscriptions, s) + } + } +} + +// HasSubscription implements the [Client.HasSubscription] interface method. +func (c *DefaultClient) HasSubscription(sub string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + + _, ok := c.subscriptions[sub] + + return ok +} + +// Get implements the [Client.Get] interface method. +func (c *DefaultClient) Get(key string) any { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.store[key] +} + +// Set implements the [Client.Set] interface method. +func (c *DefaultClient) Set(key string, value any) { + c.mu.Lock() + defer c.mu.Unlock() + + c.store[key] = value +} + +// Unset implements the [Client.Unset] interface method. +func (c *DefaultClient) Unset(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.store, key) +} + +// Discard implements the [Client.Discard] interface method. +func (c *DefaultClient) Discard() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.isDiscarded { + return + } + + close(c.channel) + + c.isDiscarded = true +} + +// IsDiscarded implements the [Client.IsDiscarded] interface method. +func (c *DefaultClient) IsDiscarded() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.isDiscarded +} + +// Send sends the specified message to the client's channel (if not discarded). +func (c *DefaultClient) Send(m Message) { + if c.IsDiscarded() { + return + } + + // "gracefully" handle panics since channel close is not blocking and could cause races + defer func() { + recover() + }() + + c.channel <- m +} + +func (c *DefaultClient) RawSubscriptions() []string { + c.mu.RLock() + defer c.mu.RUnlock() + + subs := make([]string, 0, len(c.subscriptions)) + for key := range c.subscriptions { + subs = append(subs, key) + } + + return subs +} diff --git a/server/core_backend-server/tools/subscriptions/client_test.go b/server/core_backend-server/tools/subscriptions/client_test.go new file mode 100644 index 0000000..1f5a916 --- /dev/null +++ b/server/core_backend-server/tools/subscriptions/client_test.go @@ -0,0 +1,244 @@ +package subscriptions_test + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/pocketbase/pocketbase/tools/subscriptions" +) + +func TestNewDefaultClient(t *testing.T) { + c := subscriptions.NewDefaultClient() + + if c.Channel() == nil { + t.Errorf("Expected channel to be initialized") + } + + if c.Subscriptions() == nil { + t.Errorf("Expected subscriptions map to be initialized") + } + + if c.Id() == "" { + t.Errorf("Expected unique id to be set") + } +} + +func TestId(t *testing.T) { + clients := []*subscriptions.DefaultClient{ + subscriptions.NewDefaultClient(), + subscriptions.NewDefaultClient(), + subscriptions.NewDefaultClient(), + subscriptions.NewDefaultClient(), + } + + ids := map[string]struct{}{} + for i, c := range clients { + // check uniqueness + if _, ok := ids[c.Id()]; ok { + t.Errorf("(%d) Expected unique id, got %v", i, c.Id()) + } else { + ids[c.Id()] = struct{}{} + } + + // check length + if len(c.Id()) != 40 { + t.Errorf("(%d) Expected unique id to have 40 chars length, got %v", i, c.Id()) + } + } +} + +func TestChannel(t *testing.T) { + c := subscriptions.NewDefaultClient() + + if c.Channel() == nil { + t.Fatalf("Expected channel to be initialized, got") + } +} + +func TestSubscriptions(t *testing.T) { + c := subscriptions.NewDefaultClient() + + if len(c.Subscriptions()) != 0 { + t.Fatalf("Expected subscriptions to be empty") + } + + c.Subscribe("sub1", "sub11", "sub2") + + scenarios := []struct { + prefixes []string + expected []string + }{ + {nil, []string{"sub1", "sub11", "sub2"}}, + {[]string{"missing"}, nil}, + {[]string{"sub1"}, []string{"sub1", "sub11"}}, + {[]string{"sub2"}, []string{"sub2"}}, // with extra query start char + } + + for _, s := range scenarios { + t.Run(strings.Join(s.prefixes, ","), func(t *testing.T) { + subs := c.Subscriptions(s.prefixes...) + + if len(subs) != len(s.expected) { + t.Fatalf("Expected %d subscriptions, got %d", len(s.expected), len(subs)) + } + + for _, s := range s.expected { + if _, ok := subs[s]; !ok { + t.Fatalf("Missing subscription %q in \n%v", s, subs) + } + } + }) + } +} + +func TestSubscribe(t *testing.T) { + c := subscriptions.NewDefaultClient() + + subs := []string{"", "sub1", "sub2", "sub3"} + expected := []string{"sub1", "sub2", "sub3"} + + c.Subscribe(subs...) // empty string should be skipped + + if len(c.Subscriptions()) != 3 { + t.Fatalf("Expected 3 subscriptions, got %v", c.Subscriptions()) + } + + for i, s := range expected { + if !c.HasSubscription(s) { + t.Errorf("(%d) Expected sub %s", i, s) + } + } +} + +func TestSubscribeOptions(t *testing.T) { + c := subscriptions.NewDefaultClient() + + sub1 := "test1" + sub2 := `test2?options={"query":{"name":123},"headers":{"X-Token":456}}` + + c.Subscribe(sub1, sub2) + + subs := c.Subscriptions() + + scenarios := []struct { + name string + expectedOptions string + }{ + {sub1, `{"query":{},"headers":{}}`}, + {sub2, `{"query":{"name":"123"},"headers":{"x_token":"456"}}`}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + options, ok := subs[s.name] + if !ok { + t.Fatalf("Missing subscription \n%q \nin \n%v", s.name, subs) + } + + rawBytes, err := json.Marshal(options) + if err != nil { + t.Fatal(err) + } + rawStr := string(rawBytes) + + if rawStr != s.expectedOptions { + t.Fatalf("Expected options\n%v\ngot\n%v", s.expectedOptions, rawStr) + } + }) + } +} + +func TestUnsubscribe(t *testing.T) { + c := subscriptions.NewDefaultClient() + + c.Subscribe("sub1", "sub2", "sub3") + + c.Unsubscribe("sub1") + + if c.HasSubscription("sub1") { + t.Fatalf("Expected sub1 to be removed") + } + + c.Unsubscribe( /* all */ ) + if len(c.Subscriptions()) != 0 { + t.Fatalf("Expected all subscriptions to be removed, got %v", c.Subscriptions()) + } +} + +func TestHasSubscription(t *testing.T) { + c := subscriptions.NewDefaultClient() + + if c.HasSubscription("missing") { + t.Error("Expected false, got true") + } + + c.Subscribe("sub") + + if !c.HasSubscription("sub") { + t.Error("Expected true, got false") + } +} + +func TestSetAndGet(t *testing.T) { + c := subscriptions.NewDefaultClient() + + c.Set("demo", 1) + + result, _ := c.Get("demo").(int) + + if result != 1 { + t.Errorf("Expected 1, got %v", result) + } +} + +func TestDiscard(t *testing.T) { + c := subscriptions.NewDefaultClient() + + if v := c.IsDiscarded(); v { + t.Fatal("Expected false, got true") + } + + c.Discard() + + if v := c.IsDiscarded(); !v { + t.Fatal("Expected true, got false") + } +} + +func TestSend(t *testing.T) { + c := subscriptions.NewDefaultClient() + + received := []string{} + go func() { + for m := range c.Channel() { + received = append(received, m.Name) + } + }() + + c.Send(subscriptions.Message{Name: "m1"}) + c.Send(subscriptions.Message{Name: "m2"}) + c.Discard() + c.Send(subscriptions.Message{Name: "m3"}) + c.Send(subscriptions.Message{Name: "m4"}) + time.Sleep(5 * time.Millisecond) + + expected := []string{"m1", "m2"} + + if len(received) != len(expected) { + t.Fatalf("Expected %d messages, got %d", len(expected), len(received)) + } + for _, name := range expected { + var exists bool + for _, n := range received { + if n == name { + exists = true + break + } + } + if !exists { + t.Fatalf("Missing expected %q message, got %v", name, received) + } + } +} diff --git a/server/core_backend-server/tools/subscriptions/message.go b/server/core_backend-server/tools/subscriptions/message.go new file mode 100644 index 0000000..4d78647 --- /dev/null +++ b/server/core_backend-server/tools/subscriptions/message.go @@ -0,0 +1,37 @@ +package subscriptions + +import ( + "io" +) + +// Message defines a client's channel data. +type Message struct { + Name string `json:"name"` + Data []byte `json:"data"` +} + +// WriteSSE writes the current message in a SSE format into the provided writer. +// +// For example, writing to a router.Event: +// +// m := Message{Name: "users/create", Data: []byte{...}} +// m.WriteSSE(e.Response, "yourEventId") +// e.Flush() +func (m *Message) WriteSSE(w io.Writer, eventId string) error { + parts := [][]byte{ + []byte("id:" + eventId + "\n"), + []byte("event:" + m.Name + "\n"), + []byte("data:"), + m.Data, + []byte("\n\n"), + } + + for _, part := range parts { + _, err := w.Write(part) + if err != nil { + return err + } + } + + return nil +} diff --git a/server/core_backend-server/tools/subscriptions/message_test.go b/server/core_backend-server/tools/subscriptions/message_test.go new file mode 100644 index 0000000..6483c9b --- /dev/null +++ b/server/core_backend-server/tools/subscriptions/message_test.go @@ -0,0 +1,25 @@ +package subscriptions_test + +import ( + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/subscriptions" +) + +func TestMessageWrite(t *testing.T) { + m := subscriptions.Message{ + Name: "test_name", + Data: []byte("test_data"), + } + + var sb strings.Builder + + m.WriteSSE(&sb, "test_id") + + expected := "id:test_id\nevent:test_name\ndata:test_data\n\n" + + if v := sb.String(); v != expected { + t.Fatalf("Expected writer content\n%q\ngot\n%q", expected, v) + } +} diff --git a/server/core_backend-server/tools/template/registry.go b/server/core_backend-server/tools/template/registry.go new file mode 100644 index 0000000..2440206 --- /dev/null +++ b/server/core_backend-server/tools/template/registry.go @@ -0,0 +1,141 @@ +// Package template is a thin wrapper around the standard html/template +// and text/template packages that implements a convenient registry to +// load and cache templates on the fly concurrently. +// +// It was created to assist the JSVM plugin HTML rendering, but could be used in other Go code. +// +// Example: +// +// registry := template.NewRegistry() +// +// html1, err := registry.LoadFiles( +// // the files set wil be parsed only once and then cached +// "layout.html", +// "content.html", +// ).Render(map[string]any{"name": "John"}) +// +// html2, err := registry.LoadFiles( +// // reuse the already parsed and cached files set +// "layout.html", +// "content.html", +// ).Render(map[string]any{"name": "Jane"}) +package template + +import ( + "fmt" + "html/template" + "io/fs" + "path/filepath" + "strings" + + "github.com/pocketbase/pocketbase/tools/store" +) + +// NewRegistry creates and initializes a new templates registry with +// some defaults (eg. global "raw" template function for unescaped HTML). +// +// Use the Registry.Load* methods to load templates into the registry. +func NewRegistry() *Registry { + return &Registry{ + cache: store.New[string, *Renderer](nil), + funcs: template.FuncMap{ + "raw": func(str string) template.HTML { + return template.HTML(str) + }, + }, + } +} + +// Registry defines a templates registry that is safe to be used by multiple goroutines. +// +// Use the Registry.Load* methods to load templates into the registry. +type Registry struct { + cache *store.Store[string, *Renderer] + funcs template.FuncMap +} + +// AddFuncs registers new global template functions. +// +// The key of each map entry is the function name that will be used in the templates. +// If a function with the map entry name already exists it will be replaced with the new one. +// +// The value of each map entry is a function that must have either a +// single return value, or two return values of which the second has type error. +// +// Example: +// +// r.AddFuncs(map[string]any{ +// "toUpper": func(str string) string { +// return strings.ToUppser(str) +// }, +// ... +// }) +func (r *Registry) AddFuncs(funcs map[string]any) *Registry { + for name, f := range funcs { + r.funcs[name] = f + } + + return r +} + +// LoadFiles caches (if not already) the specified filenames set as a +// single template and returns a ready to use Renderer instance. +// +// There must be at least 1 filename specified. +func (r *Registry) LoadFiles(filenames ...string) *Renderer { + key := strings.Join(filenames, ",") + + found := r.cache.Get(key) + + if found == nil { + // parse and cache + tpl, err := template.New(filepath.Base(filenames[0])).Funcs(r.funcs).ParseFiles(filenames...) + found = &Renderer{template: tpl, parseError: err} + r.cache.Set(key, found) + } + + return found +} + +// LoadString caches (if not already) the specified inline string as a +// single template and returns a ready to use Renderer instance. +func (r *Registry) LoadString(text string) *Renderer { + found := r.cache.Get(text) + + if found == nil { + // parse and cache (using the text as key) + tpl, err := template.New("").Funcs(r.funcs).Parse(text) + found = &Renderer{template: tpl, parseError: err} + r.cache.Set(text, found) + } + + return found +} + +// LoadFS caches (if not already) the specified fs and globPatterns +// pair as single template and returns a ready to use Renderer instance. +// +// There must be at least 1 file matching the provided globPattern(s) +// (note that most file names serves as glob patterns matching themselves). +func (r *Registry) LoadFS(fsys fs.FS, globPatterns ...string) *Renderer { + key := fmt.Sprintf("%v%v", fsys, globPatterns) + + found := r.cache.Get(key) + + if found == nil { + // find the first file to use as template name (it is required when specifying Funcs) + var firstFilename string + if len(globPatterns) > 0 { + list, _ := fs.Glob(fsys, globPatterns[0]) + if len(list) > 0 { + firstFilename = filepath.Base(list[0]) + } + } + + tpl, err := template.New(firstFilename).Funcs(r.funcs).ParseFS(fsys, globPatterns...) + found = &Renderer{template: tpl, parseError: err} + r.cache.Set(key, found) + } + + return found +} diff --git a/server/core_backend-server/tools/template/registry_test.go b/server/core_backend-server/tools/template/registry_test.go new file mode 100644 index 0000000..c037fe3 --- /dev/null +++ b/server/core_backend-server/tools/template/registry_test.go @@ -0,0 +1,250 @@ +package template + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func checkRegistryFuncs(t *testing.T, r *Registry, expectedFuncs ...string) { + if v := len(r.funcs); v != len(expectedFuncs) { + t.Fatalf("Expected total %d funcs, got %d", len(expectedFuncs), v) + } + + for _, name := range expectedFuncs { + if _, ok := r.funcs[name]; !ok { + t.Fatalf("Missing %q func", name) + } + } +} + +func TestNewRegistry(t *testing.T) { + r := NewRegistry() + + if r.cache == nil { + t.Fatalf("Expected cache store to be initialized, got nil") + } + + if v := r.cache.Length(); v != 0 { + t.Fatalf("Expected cache store length to be 0, got %d", v) + } + + checkRegistryFuncs(t, r, "raw") +} + +func TestRegistryAddFuncs(t *testing.T) { + r := NewRegistry() + + r.AddFuncs(map[string]any{ + "test": func(a string) string { return a + "-TEST" }, + }) + + checkRegistryFuncs(t, r, "raw", "test") + + result, err := r.LoadString(`{{.|test}}`).Render("example") + if err != nil { + t.Fatalf("Unexpected Render() error, got %v", err) + } + + expected := "example-TEST" + if result != expected { + t.Fatalf("Expected Render() result %q, got %q", expected, result) + } +} + +func TestRegistryLoadFiles(t *testing.T) { + r := NewRegistry() + + t.Run("invalid or missing files", func(t *testing.T) { + r.LoadFiles("file1.missing", "file2.missing") + + key := "file1.missing,file2.missing" + renderer := r.cache.Get(key) + + if renderer == nil { + t.Fatal("Expected renderer to be initialized even if invalid, got nil") + } + + if renderer.template != nil { + t.Fatalf("Expected renderer template to be nil, got %v", renderer.template) + } + + if renderer.parseError == nil { + t.Fatalf("Expected renderer parseError to be set, got nil") + } + }) + + t.Run("valid files", func(t *testing.T) { + // create test templates + dir, err := os.MkdirTemp(os.TempDir(), "template_test") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "base.html"), []byte(`Base:{{template "content" .}}`), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "content.html"), []byte(`{{define "content"}}Content:{{.|raw}}{{end}}`), 0644); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + files := []string{filepath.Join(dir, "base.html"), filepath.Join(dir, "content.html")} + + r.LoadFiles(files...) + + renderer := r.cache.Get(strings.Join(files, ",")) + + if renderer == nil { + t.Fatal("Expected renderer to be initialized even if invalid, got nil") + } + + if renderer.template == nil { + t.Fatal("Expected renderer template to be set, got nil") + } + + if renderer.parseError != nil { + t.Fatalf("Expected renderer parseError to be nil, got %v", renderer.parseError) + } + + result, err := renderer.Render("

123

") + if err != nil { + t.Fatalf("Unexpected Render() error, got %v", err) + } + + expected := "Base:Content:

123

" + if result != expected { + t.Fatalf("Expected Render() result %q, got %q", expected, result) + } + }) +} + +func TestRegistryLoadString(t *testing.T) { + r := NewRegistry() + + t.Run("invalid template string", func(t *testing.T) { + txt := `test {{define "content"}}` + + r.LoadString(txt) + + renderer := r.cache.Get(txt) + + if renderer == nil { + t.Fatal("Expected renderer to be initialized even if invalid, got nil") + } + + if renderer.template != nil { + t.Fatalf("Expected renderer template to be nil, got %v", renderer.template) + } + + if renderer.parseError == nil { + t.Fatalf("Expected renderer parseError to be set, got nil") + } + }) + + t.Run("valid template string", func(t *testing.T) { + txt := `test {{.|raw}}` + + r.LoadString(txt) + + renderer := r.cache.Get(txt) + + if renderer == nil { + t.Fatal("Expected renderer to be initialized even if invalid, got nil") + } + + if renderer.template == nil { + t.Fatal("Expected renderer template to be set, got nil") + } + + if renderer.parseError != nil { + t.Fatalf("Expected renderer parseError to be nil, got %v", renderer.parseError) + } + + result, err := renderer.Render("

123

") + if err != nil { + t.Fatalf("Unexpected Render() error, got %v", err) + } + + expected := "test

123

" + if result != expected { + t.Fatalf("Expected Render() result %q, got %q", expected, result) + } + }) +} + +func TestRegistryLoadFS(t *testing.T) { + r := NewRegistry() + + t.Run("invalid fs", func(t *testing.T) { + fs := os.DirFS("__missing__") + + files := []string{"missing1", "missing2"} + + key := fmt.Sprintf("%v%v", fs, files) + + r.LoadFS(fs, files...) + + renderer := r.cache.Get(key) + + if renderer == nil { + t.Fatal("Expected renderer to be initialized even if invalid, got nil") + } + + if renderer.template != nil { + t.Fatalf("Expected renderer template to be nil, got %v", renderer.template) + } + + if renderer.parseError == nil { + t.Fatalf("Expected renderer parseError to be set, got nil") + } + }) + + t.Run("valid fs", func(t *testing.T) { + // create test templates + dir, err := os.MkdirTemp(os.TempDir(), "template_test2") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "base.html"), []byte(`Base:{{template "content" .}}`), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "content.html"), []byte(`{{define "content"}}Content:{{.|raw}}{{end}}`), 0644); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + fs := os.DirFS(dir) + + files := []string{"base.html", "content.html"} + + key := fmt.Sprintf("%v%v", fs, files) + + r.LoadFS(fs, files...) + + renderer := r.cache.Get(key) + + if renderer == nil { + t.Fatal("Expected renderer to be initialized even if invalid, got nil") + } + + if renderer.template == nil { + t.Fatal("Expected renderer template to be set, got nil") + } + + if renderer.parseError != nil { + t.Fatalf("Expected renderer parseError to be nil, got %v", renderer.parseError) + } + + result, err := renderer.Render("

123

") + if err != nil { + t.Fatalf("Unexpected Render() error, got %v", err) + } + + expected := "Base:Content:

123

" + if result != expected { + t.Fatalf("Expected Render() result %q, got %q", expected, result) + } + }) +} diff --git a/server/core_backend-server/tools/template/renderer.go b/server/core_backend-server/tools/template/renderer.go new file mode 100644 index 0000000..7a2d85d --- /dev/null +++ b/server/core_backend-server/tools/template/renderer.go @@ -0,0 +1,33 @@ +package template + +import ( + "bytes" + "errors" + "html/template" +) + +// Renderer defines a single parsed template. +type Renderer struct { + template *template.Template + parseError error +} + +// Render executes the template with the specified data as the dot object +// and returns the result as plain string. +func (r *Renderer) Render(data any) (string, error) { + if r.parseError != nil { + return "", r.parseError + } + + if r.template == nil { + return "", errors.New("invalid or nil template") + } + + buf := new(bytes.Buffer) + + if err := r.template.Execute(buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/server/core_backend-server/tools/template/renderer_test.go b/server/core_backend-server/tools/template/renderer_test.go new file mode 100644 index 0000000..7c75111 --- /dev/null +++ b/server/core_backend-server/tools/template/renderer_test.go @@ -0,0 +1,63 @@ +package template + +import ( + "errors" + "html/template" + "testing" +) + +func TestRendererRender(t *testing.T) { + tpl, _ := template.New("").Parse("Hello {{.Name}}!") + tpl.Option("missingkey=error") // enforce execute errors + + scenarios := map[string]struct { + renderer *Renderer + data any + expectedHasErr bool + expectedResult string + }{ + "with nil template": { + &Renderer{}, + nil, + true, + "", + }, + "with parse error": { + &Renderer{ + template: tpl, + parseError: errors.New("test"), + }, + nil, + true, + "", + }, + "with execute error": { + &Renderer{template: tpl}, + nil, + true, + "", + }, + "no error": { + &Renderer{template: tpl}, + struct{ Name string }{"world"}, + false, + "Hello world!", + }, + } + + for name, s := range scenarios { + t.Run(name, func(t *testing.T) { + result, err := s.renderer.Render(s.data) + + hasErr := err != nil + + if s.expectedHasErr != hasErr { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectedHasErr, hasErr, err) + } + + if s.expectedResult != result { + t.Fatalf("Expected result %v, got %v", s.expectedResult, result) + } + }) + } +} diff --git a/server/core_backend-server/tools/tokenizer/tokenizer.go b/server/core_backend-server/tools/tokenizer/tokenizer.go new file mode 100644 index 0000000..934d369 --- /dev/null +++ b/server/core_backend-server/tools/tokenizer/tokenizer.go @@ -0,0 +1,221 @@ +// Package tokenizer implements a rudimentary tokens parser of buffered +// io.Reader while respecting quotes and parenthesis boundaries. +// +// Example +// +// tk := tokenizer.NewFromString("a, b, (c, d)") +// result, _ := tk.ScanAll() // ["a", "b", "(c, d)"] +package tokenizer + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strings" +) + +// eof represents a marker rune for the end of the reader. +const eof = rune(0) + +// DefaultSeparators is a list with the default token separator characters. +var DefaultSeparators = []rune{','} + +var whitespaceChars = []rune{'\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0} + +// NewFromString creates new Tokenizer from the provided string. +func NewFromString(str string) *Tokenizer { + return New(strings.NewReader(str)) +} + +// NewFromBytes creates new Tokenizer from the provided bytes slice. +func NewFromBytes(b []byte) *Tokenizer { + return New(bytes.NewReader(b)) +} + +// New creates new Tokenizer from the provided reader with DefaultSeparators. +func New(r io.Reader) *Tokenizer { + t := &Tokenizer{r: bufio.NewReader(r)} + + t.Separators(DefaultSeparators...) + + return t +} + +// Tokenizer defines a struct that parses a reader into tokens while +// respecting quotes and parenthesis boundaries. +type Tokenizer struct { + r *bufio.Reader + + trimCutset string + separators []rune + keepSeparator bool + keepEmptyTokens bool + ignoreParenthesis bool +} + +// Separators defines the provided separatos of the current Tokenizer. +func (t *Tokenizer) Separators(separators ...rune) { + t.separators = separators + + t.rebuildTrimCutset() +} + +// KeepSeparator defines whether to keep the separator rune as part +// of the token (default to false). +func (t *Tokenizer) KeepSeparator(state bool) { + t.keepSeparator = state +} + +// KeepEmptyTokens defines whether to keep empty tokens on Scan() (default to false). +func (t *Tokenizer) KeepEmptyTokens(state bool) { + t.keepEmptyTokens = state +} + +// IgnoreParenthesis defines whether to ignore the parenthesis boundaries +// and to treat the '(' and ')' as regular characters. +func (t *Tokenizer) IgnoreParenthesis(state bool) { + t.ignoreParenthesis = state +} + +// Scan reads and returns the next available token from the Tokenizer's buffer (trimmed!). +// +// Empty tokens are skipped if t.keepEmptyTokens is not set (which is the default). +// +// Returns [io.EOF] error when there are no more tokens to scan. +func (t *Tokenizer) Scan() (string, error) { + ch := t.read() + if ch == eof { + return "", io.EOF + } + t.unread() + + token, err := t.readToken() + if err != nil { + return "", err + } + + if !t.keepEmptyTokens && token == "" { + return t.Scan() + } + + return token, err +} + +// ScanAll reads the entire Tokenizer's buffer and return all found tokens. +func (t *Tokenizer) ScanAll() ([]string, error) { + tokens := []string{} + + for { + token, err := t.Scan() + if err != nil { + if err == io.EOF { + break + } + + return nil, err + } + + tokens = append(tokens, token) + } + + return tokens, nil +} + +// readToken reads a single token from the buffer and returns it. +func (t *Tokenizer) readToken() (string, error) { + var buf bytes.Buffer + var parenthesis int + var quoteCh rune + var prevCh rune + + for { + ch := t.read() + + if ch == eof { + break + } + + if !t.isEscapeRune(prevCh) { + if !t.ignoreParenthesis && ch == '(' && quoteCh == eof { + parenthesis++ // opening parenthesis + } else if !t.ignoreParenthesis && ch == ')' && parenthesis > 0 && quoteCh == eof { + parenthesis-- // closing parenthesis + } else if t.isQuoteRune(ch) { + switch quoteCh { + case ch: + quoteCh = eof // closing quote + case eof: + quoteCh = ch // opening quote + } + } + } + + if t.isSeperatorRune(ch) && parenthesis == 0 && quoteCh == eof { + if t.keepSeparator { + buf.WriteRune(ch) + } + break + } + + prevCh = ch + buf.WriteRune(ch) + } + + if parenthesis > 0 || quoteCh != eof { + return "", fmt.Errorf("unbalanced parenthesis or quoted expression: %q", buf.String()) + } + + return strings.Trim(buf.String(), t.trimCutset), nil +} + +// read reads the next rune from the buffered reader. +// Returns the `rune(0)` if an error or `io.EOF` occurs. +func (t *Tokenizer) read() rune { + ch, _, err := t.r.ReadRune() + if err != nil { + return eof + } + + return ch +} + +// unread places the previously read rune back on the reader. +func (t *Tokenizer) unread() error { + return t.r.UnreadRune() +} + +// rebuildTrimCutset rebuilds the tokenizer trimCutset based on its separator runes. +func (t *Tokenizer) rebuildTrimCutset() { + var cutset strings.Builder + + for _, w := range whitespaceChars { + if t.isSeperatorRune(w) { + continue + } + cutset.WriteRune(w) + } + + t.trimCutset = cutset.String() +} + +// isSeperatorRune checks if a rune is a token part separator. +func (t *Tokenizer) isSeperatorRune(ch rune) bool { + for _, r := range t.separators { + if ch == r { + return true + } + } + + return false +} + +// isQuoteRune checks if a rune is a quote. +func (t *Tokenizer) isQuoteRune(ch rune) bool { + return ch == '\'' || ch == '"' || ch == '`' +} + +// isEscapeRune checks if a rune is an escape character. +func (t *Tokenizer) isEscapeRune(ch rune) bool { + return ch == '\\' +} diff --git a/server/core_backend-server/tools/tokenizer/tokenizer_test.go b/server/core_backend-server/tools/tokenizer/tokenizer_test.go new file mode 100644 index 0000000..c9aab59 --- /dev/null +++ b/server/core_backend-server/tools/tokenizer/tokenizer_test.go @@ -0,0 +1,303 @@ +package tokenizer + +import ( + "io" + "strings" + "testing" +) + +func TestFactories(t *testing.T) { + expectedContent := "test" + + scenarios := []struct { + name string + tk *Tokenizer + }{ + { + "New()", + New(strings.NewReader(expectedContent)), + }, + { + "NewFromString()", + NewFromString(expectedContent), + }, + { + "NewFromBytes()", + NewFromBytes([]byte(expectedContent)), + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + content, _ := s.tk.r.ReadString(0) + + if content != expectedContent { + t.Fatalf("Expected reader with content %q, got %q", expectedContent, content) + } + + if s.tk.keepSeparator != false { + t.Fatal("Expected keepSeparator false, got true") + } + + if s.tk.ignoreParenthesis != false { + t.Fatal("Expected ignoreParenthesis false, got true") + } + + if len(s.tk.separators) != len(DefaultSeparators) { + t.Fatalf("Expected \n%v, \ngot \n%v", DefaultSeparators, s.tk.separators) + } + + for _, r := range s.tk.separators { + exists := false + for _, def := range s.tk.separators { + if r == def { + exists = true + break + } + } + if !exists { + t.Fatalf("Unexpected sepator %s", string(r)) + } + } + }) + } +} + +func TestScan(t *testing.T) { + tk := NewFromString("abc, 123.456, (abc)") + + expectedTokens := []string{"abc", "123.456", "(abc)"} + + for _, token := range expectedTokens { + result, err := tk.Scan() + if err != nil { + t.Fatalf("Expected token %q, got error %v", token, err) + } + + if result != token { + t.Fatalf("Expected token %q, got error %v", token, result) + } + } + + // scan the last character + token, err := tk.Scan() + if err != io.EOF { + t.Fatalf("Expected EOF error, got %v", err) + } + if token != "" || err != io.EOF { + t.Fatalf("Expected empty token, got %q", token) + } +} + +func TestScanAll(t *testing.T) { + scenarios := []struct { + name string + content string + separators []rune + keepSeparator bool + keepEmptyTokens bool + ignoreParenthesis bool + expectError bool + expectTokens []string + }{ + { + name: "empty string", + content: "", + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: false, + expectTokens: nil, + }, + { + name: "unbalanced parenthesis", + content: `(a,b() c`, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: true, + expectTokens: []string{}, + }, + { + name: "unmatching quotes", + content: `'asd"`, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: true, + expectTokens: []string{}, + }, + { + name: "no separators", + content: `a, b, c, d, e 123, "abc"`, + separators: nil, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{`a, b, c, d, e 123, "abc"`}, + }, + { + name: "default separators", + content: `a, b , c , d e , "a,b, c " , ,, , (123, 456) + `, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{ + "a", + "b", + "c", + "d e", + `"a,b, c "`, + `(123, 456)`, + }, + }, + { + name: "keep separators", + content: `a, b, c, d e, "a,b, c ", (123, 456)`, + separators: []rune{',', ' '}, // the space should be removed from the cutset + keepSeparator: true, + keepEmptyTokens: true, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{ + "a,", + " ", + "b,", + " ", + "c,", + " ", + "d ", + " ", + "e,", + " ", + `"a,b, c ",`, + `(123, 456)`, + }, + }, + { + name: "custom separators", + content: `a | b c d &(e + f) & "g & h" & & &`, + separators: []rune{'|', '&'}, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{ + "a", + "b c d", + "(e + f)", + `"g & h"`, + }, + }, + { + name: "ignoring parenthesis", + content: `a, b, (c,d)`, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: true, + expectError: false, + expectTokens: []string{ + "a", + "b", + "(c", + "d)", + }, + }, + { + name: "keep empty tokens", + content: `a, b, (c, d), ,, , e, , f`, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: true, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{ + "a", + "b", + "(c, d)", + "", + "", + "", + "e", + "", + "f", + }, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + tk := NewFromString(s.content) + + tk.Separators(s.separators...) + tk.KeepSeparator(s.keepSeparator) + tk.KeepEmptyTokens(s.keepEmptyTokens) + tk.IgnoreParenthesis(s.ignoreParenthesis) + + tokens, err := tk.ScanAll() + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if len(tokens) != len(s.expectTokens) { + t.Fatalf("Expected \n%v (%d), \ngot \n%v (%d)", s.expectTokens, len(s.expectTokens), tokens, len(tokens)) + } + + for _, tok := range tokens { + exists := false + for _, def := range s.expectTokens { + if tok == def { + exists = true + break + } + } + if !exists { + t.Fatalf("Unexpected token %q", tok) + } + } + }) + } +} + +func TestTrimCutset(t *testing.T) { + scenarios := []struct { + name string + separators []rune + expectedCutset string + }{ + { + "default factory separators", + nil, + "\t\n\v\f\r \u0085\u00a0", + }, + { + "custom separators", + []rune{'\t', ' ', '\r', ','}, + "\n\v\f\u0085\u00a0", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + tk := NewFromString("") + + if len(s.separators) > 0 { + tk.Separators(s.separators...) + } + + if tk.trimCutset != s.expectedCutset { + t.Fatalf("Expected cutset %q, got %q", s.expectedCutset, tk.trimCutset) + } + }) + } +} diff --git a/server/core_backend-server/tools/types/datetime.go b/server/core_backend-server/tools/types/datetime.go new file mode 100644 index 0000000..04dcbf4 --- /dev/null +++ b/server/core_backend-server/tools/types/datetime.go @@ -0,0 +1,162 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/spf13/cast" +) + +// DefaultDateLayout specifies the default app date strings layout. +const DefaultDateLayout = "2006-01-02 15:04:05.000Z" + +// NowDateTime returns new DateTime instance with the current local time. +func NowDateTime() DateTime { + return DateTime{t: time.Now()} +} + +// ParseDateTime creates a new DateTime from the provided value +// (could be [cast.ToTime] supported string, [time.Time], etc.). +func ParseDateTime(value any) (DateTime, error) { + d := DateTime{} + err := d.Scan(value) + return d, err +} + +// DateTime represents a [time.Time] instance in UTC that is wrapped +// and serialized using the app default date layout. +type DateTime struct { + t time.Time +} + +// Time returns the internal [time.Time] instance. +func (d DateTime) Time() time.Time { + return d.t +} + +// Add returns a new DateTime based on the current DateTime + the specified duration. +func (d DateTime) Add(duration time.Duration) DateTime { + d.t = d.t.Add(duration) + return d +} + +// Sub returns a [time.Duration] by subtracting the specified DateTime from the current one. +// +// If the result exceeds the maximum (or minimum) value that can be stored in a [time.Duration], +// the maximum (or minimum) duration will be returned. +func (d DateTime) Sub(u DateTime) time.Duration { + return d.Time().Sub(u.Time()) +} + +// AddDate returns a new DateTime based on the current one + duration. +// +// It follows the same rules as [time.AddDate]. +func (d DateTime) AddDate(years, months, days int) DateTime { + d.t = d.t.AddDate(years, months, days) + return d +} + +// After reports whether the current DateTime instance is after u. +func (d DateTime) After(u DateTime) bool { + return d.Time().After(u.Time()) +} + +// Before reports whether the current DateTime instance is before u. +func (d DateTime) Before(u DateTime) bool { + return d.Time().Before(u.Time()) +} + +// Compare compares the current DateTime instance with u. +// If the current instance is before u, it returns -1. +// If the current instance is after u, it returns +1. +// If they're the same, it returns 0. +func (d DateTime) Compare(u DateTime) int { + return d.Time().Compare(u.Time()) +} + +// Equal reports whether the current DateTime and u represent the same time instant. +// Two DateTime can be equal even if they are in different locations. +// For example, 6:00 +0200 and 4:00 UTC are Equal. +func (d DateTime) Equal(u DateTime) bool { + return d.Time().Equal(u.Time()) +} + +// Unix returns the current DateTime as a Unix time, aka. +// the number of seconds elapsed since January 1, 1970 UTC. +func (d DateTime) Unix() int64 { + return d.Time().Unix() +} + +// IsZero checks whether the current DateTime instance has zero time value. +func (d DateTime) IsZero() bool { + return d.Time().IsZero() +} + +// String serializes the current DateTime instance into a formatted +// UTC date string. +// +// The zero value is serialized to an empty string. +func (d DateTime) String() string { + t := d.Time() + if t.IsZero() { + return "" + } + return t.UTC().Format(DefaultDateLayout) +} + +// MarshalJSON implements the [json.Marshaler] interface. +func (d DateTime) MarshalJSON() ([]byte, error) { + return []byte(`"` + d.String() + `"`), nil +} + +// UnmarshalJSON implements the [json.Unmarshaler] interface. +func (d *DateTime) UnmarshalJSON(b []byte) error { + var raw string + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + return d.Scan(raw) +} + +// Value implements the [driver.Valuer] interface. +func (d DateTime) Value() (driver.Value, error) { + if d.IsZero() { // PostgreSQL only + return nil, nil + } + + return d.String(), nil +} + +// Scan implements [sql.Scanner] interface to scan the provided value +// into the current DateTime instance. +func (d *DateTime) Scan(value any) error { + switch v := value.(type) { + case time.Time: + d.t = v + case DateTime: + d.t = v.Time() + case string: + if v == "" { + d.t = time.Time{} + } else { + t, err := time.Parse(DefaultDateLayout, v) + if err != nil { + // check for other common date layouts + t = cast.ToTime(v) + } + d.t = t + } + case int, int64, int32, uint, uint64, uint32: + d.t = cast.ToTime(v) + default: + str := cast.ToString(v) + if str == "" { + d.t = time.Time{} + } else { + d.t = cast.ToTime(str) + } + } + + return nil +} diff --git a/server/core_backend-server/tools/types/datetime_test.go b/server/core_backend-server/tools/types/datetime_test.go new file mode 100644 index 0000000..007c3e8 --- /dev/null +++ b/server/core_backend-server/tools/types/datetime_test.go @@ -0,0 +1,422 @@ +package types_test + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestNowDateTime(t *testing.T) { + now := time.Now().UTC().Format("2006-01-02 15:04:05") // without ms part for test consistency + dt := types.NowDateTime() + + if !strings.Contains(dt.String(), now) { + t.Fatalf("Expected %q, got %q", now, dt.String()) + } +} + +func TestParseDateTime(t *testing.T) { + nowTime := time.Now().UTC() + nowDateTime, _ := types.ParseDateTime(nowTime) + nowStr := nowTime.Format(types.DefaultDateLayout) + + scenarios := []struct { + value any + expected string + }{ + {nil, ""}, + {"", ""}, + {"invalid", ""}, + {nowDateTime, nowStr}, + {nowTime, nowStr}, + {1641024040, "2022-01-01 08:00:40.000Z"}, + {int32(1641024040), "2022-01-01 08:00:40.000Z"}, + {int64(1641024040), "2022-01-01 08:00:40.000Z"}, + {uint(1641024040), "2022-01-01 08:00:40.000Z"}, + {uint64(1641024040), "2022-01-01 08:00:40.000Z"}, + {uint32(1641024040), "2022-01-01 08:00:40.000Z"}, + {"2022-01-01 11:23:45.678", "2022-01-01 11:23:45.678Z"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + dt, err := types.ParseDateTime(s.value) + if err != nil { + t.Fatalf("Failed to parse %v: %v", s.value, err) + } + + if dt.String() != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, dt.String()) + } + }) + } +} + +func TestDateTimeTime(t *testing.T) { + str := "2022-01-01 11:23:45.678Z" + + expected, err := time.Parse(types.DefaultDateLayout, str) + if err != nil { + t.Fatal(err) + } + + dt, err := types.ParseDateTime(str) + if err != nil { + t.Fatal(err) + } + + result := dt.Time() + + if !expected.Equal(result) { + t.Fatalf("Expected time %v, got %v", expected, result) + } +} + +func TestDateTimeAdd(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + + d2 := d1.Add(1 * time.Hour) + + if d1.String() != "2024-01-01 10:00:00.123Z" { + t.Fatalf("Expected d1 to remain unchanged, got %s", d1.String()) + } + + expected := "2024-01-01 11:00:00.123Z" + if d2.String() != expected { + t.Fatalf("Expected d2 %s, got %s", expected, d2.String()) + } +} + +func TestDateTimeSub(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-01 10:30:00.123Z") + + result := d2.Sub(d1) + + if result.Minutes() != 30 { + t.Fatalf("Expected %v minutes diff, got %v", 30, result.Minutes()) + } +} + +func TestDateTimeAddDate(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + + d2 := d1.AddDate(1, 2, 3) + + if d1.String() != "2024-01-01 10:00:00.123Z" { + t.Fatalf("Expected d1 to remain unchanged, got %s", d1.String()) + } + + expected := "2025-03-04 10:00:00.123Z" + if d2.String() != expected { + t.Fatalf("Expected d2 %s, got %s", expected, d2.String()) + } +} + +func TestDateTimeAfter(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-02 10:00:00.123Z") + d3, _ := types.ParseDateTime("2024-01-03 10:00:00.123Z") + + scenarios := []struct { + a types.DateTime + b types.DateTime + expect bool + }{ + // d1 + {d1, d1, false}, + {d1, d2, false}, + {d1, d3, false}, + // d2 + {d2, d1, true}, + {d2, d2, false}, + {d2, d3, false}, + // d3 + {d3, d1, true}, + {d3, d2, true}, + {d3, d3, false}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("after_%d", i), func(t *testing.T) { + if v := s.a.After(s.b); v != s.expect { + t.Fatalf("Expected %v, got %v", s.expect, v) + } + }) + } +} + +func TestDateTimeBefore(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-02 10:00:00.123Z") + d3, _ := types.ParseDateTime("2024-01-03 10:00:00.123Z") + + scenarios := []struct { + a types.DateTime + b types.DateTime + expect bool + }{ + // d1 + {d1, d1, false}, + {d1, d2, true}, + {d1, d3, true}, + // d2 + {d2, d1, false}, + {d2, d2, false}, + {d2, d3, true}, + // d3 + {d3, d1, false}, + {d3, d2, false}, + {d3, d3, false}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("before_%d", i), func(t *testing.T) { + if v := s.a.Before(s.b); v != s.expect { + t.Fatalf("Expected %v, got %v", s.expect, v) + } + }) + } +} + +func TestDateTimeCompare(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-02 10:00:00.123Z") + d3, _ := types.ParseDateTime("2024-01-03 10:00:00.123Z") + + scenarios := []struct { + a types.DateTime + b types.DateTime + expect int + }{ + // d1 + {d1, d1, 0}, + {d1, d2, -1}, + {d1, d3, -1}, + // d2 + {d2, d1, 1}, + {d2, d2, 0}, + {d2, d3, -1}, + // d3 + {d3, d1, 1}, + {d3, d2, 1}, + {d3, d3, 0}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("compare_%d", i), func(t *testing.T) { + if v := s.a.Compare(s.b); v != s.expect { + t.Fatalf("Expected %v, got %v", s.expect, v) + } + }) + } +} + +func TestDateTimeEqual(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d3, _ := types.ParseDateTime("2024-01-01 10:00:00.124Z") + + scenarios := []struct { + a types.DateTime + b types.DateTime + expect bool + }{ + {d1, d1, true}, + {d1, d2, true}, + {d1, d3, false}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("equal_%d", i), func(t *testing.T) { + if v := s.a.Equal(s.b); v != s.expect { + t.Fatalf("Expected %v, got %v", s.expect, v) + } + }) + } +} + +func TestDateTimeUnix(t *testing.T) { + scenarios := []struct { + date string + expected int64 + }{ + {"", -62135596800}, + {"2022-01-01 11:23:45.678Z", 1641036225}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.date), func(t *testing.T) { + dt, err := types.ParseDateTime(s.date) + if err != nil { + t.Fatal(err) + } + + v := dt.Unix() + + if v != s.expected { + t.Fatalf("Expected %d, got %d", s.expected, v) + } + }) + } +} + +func TestDateTimeIsZero(t *testing.T) { + dt0 := types.DateTime{} + if !dt0.IsZero() { + t.Fatalf("Expected zero datatime, got %v", dt0) + } + + dt1 := types.NowDateTime() + if dt1.IsZero() { + t.Fatalf("Expected non-zero datatime, got %v", dt1) + } +} + +func TestDateTimeString(t *testing.T) { + dt0 := types.DateTime{} + if dt0.String() != "" { + t.Fatalf("Expected empty string for zer datetime, got %q", dt0.String()) + } + + expected := "2022-01-01 11:23:45.678Z" + dt1, _ := types.ParseDateTime(expected) + if dt1.String() != expected { + t.Fatalf("Expected %q, got %v", expected, dt1) + } +} + +func TestDateTimeMarshalJSON(t *testing.T) { + scenarios := []struct { + date string + expected string + }{ + {"", `""`}, + {"2022-01-01 11:23:45.678", `"2022-01-01 11:23:45.678Z"`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.date), func(t *testing.T) { + dt, err := types.ParseDateTime(s.date) + if err != nil { + t.Fatal(err) + } + + result, err := dt.MarshalJSON() + if err != nil { + t.Fatal(err) + } + + if string(result) != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, string(result)) + } + }) + } +} + +func TestDateTimeUnmarshalJSON(t *testing.T) { + scenarios := []struct { + date string + expected string + }{ + {"", ""}, + {"invalid_json", ""}, + {"'123'", ""}, + {"2022-01-01 11:23:45.678", ""}, + {`"2022-01-01 11:23:45.678"`, "2022-01-01 11:23:45.678Z"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.date), func(t *testing.T) { + dt := types.DateTime{} + dt.UnmarshalJSON([]byte(s.date)) + + if dt.String() != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, dt.String()) + } + }) + } +} + +func TestDateTimeValue(t *testing.T) { + scenarios := []struct { + value any + expected any + }{ + /* SQLite: + {"", ""}, + {"invalid", ""}, + */ + // PostgreSQL: + {"", nil}, + {"invalid", nil}, + {1641024040, "2022-01-01 08:00:40.000Z"}, + {"2022-01-01 11:23:45.678", "2022-01-01 11:23:45.678Z"}, + {types.NowDateTime(), types.NowDateTime().String()}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.value), func(t *testing.T) { + dt, _ := types.ParseDateTime(s.value) + + result, err := dt.Value() + if err != nil { + t.Fatal(err) + } + + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) + } +} + +func TestDateTimeScan(t *testing.T) { + now := time.Now().UTC().Format("2006-01-02 15:04:05") // without ms part for test consistency + + scenarios := []struct { + value any + expected string + }{ + {nil, ""}, + {"", ""}, + {"invalid", ""}, + {types.NowDateTime(), now}, + {time.Now(), now}, + {1.0, ""}, + {1641024040, "2022-01-01 08:00:40.000Z"}, + {"2022-01-01 11:23:45.678", "2022-01-01 11:23:45.678Z"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + dt := types.DateTime{} + + err := dt.Scan(s.value) + if err != nil { + t.Fatalf("Failed to parse %v: %v", s.value, err) + } + + if !strings.Contains(dt.String(), s.expected) { + t.Fatalf("Expected %q, got %q", s.expected, dt.String()) + } + }) + } +} diff --git a/server/core_backend-server/tools/types/geo_point.go b/server/core_backend-server/tools/types/geo_point.go new file mode 100644 index 0000000..286120d --- /dev/null +++ b/server/core_backend-server/tools/types/geo_point.go @@ -0,0 +1,84 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +// GeoPoint defines a struct for storing geo coordinates as serialized json object +// (e.g. {lon:0,lat:0}). +// +// Note: using object notation and not a plain array to avoid the confusion +// as there doesn't seem to be a fixed standard for the coordinates order. +type GeoPoint struct { + Lon float64 `form:"lon" json:"lon"` + Lat float64 `form:"lat" json:"lat"` +} + +// String returns the string representation of the current GeoPoint instance. +func (p GeoPoint) String() string { + raw, _ := json.Marshal(p) + return string(raw) +} + +// AsMap implements [core.mapExtractor] and returns a value suitable +// to be used in an API rule expression. +func (p GeoPoint) AsMap() map[string]any { + return map[string]any{ + "lon": p.Lon, + "lat": p.Lat, + } +} + +// Value implements the [driver.Valuer] interface. +func (p GeoPoint) Value() (driver.Value, error) { + data, err := json.Marshal(p) + return string(data), err +} + +// Scan implements [sql.Scanner] interface to scan the provided value +// into the current GeoPoint instance. +// +// The value argument could be nil (no-op), another GeoPoint instance, +// map or serialized json object with lat-lon props. +func (p *GeoPoint) Scan(value any) error { + var err error + + switch v := value.(type) { + case nil: + // no cast needed + case *GeoPoint: + p.Lon = v.Lon + p.Lat = v.Lat + case GeoPoint: + p.Lon = v.Lon + p.Lat = v.Lat + case JSONRaw: + if len(v) != 0 { + err = json.Unmarshal(v, p) + } + case []byte: + if len(v) != 0 { + err = json.Unmarshal(v, p) + } + case string: + if len(v) != 0 { + err = json.Unmarshal([]byte(v), p) + } + default: + var raw []byte + raw, err = json.Marshal(v) + if err != nil { + err = fmt.Errorf("unable to marshalize value for scanning: %w", err) + } else { + err = json.Unmarshal(raw, p) + } + } + + if err != nil { + return fmt.Errorf("[GeoPoint] unable to scan value %v: %w", value, err) + } + + return nil +} diff --git a/server/core_backend-server/tools/types/geo_point_test.go b/server/core_backend-server/tools/types/geo_point_test.go new file mode 100644 index 0000000..fa7c5d5 --- /dev/null +++ b/server/core_backend-server/tools/types/geo_point_test.go @@ -0,0 +1,116 @@ +package types_test + +import ( + "fmt" + "testing" + + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestGeoPointAsMap(t *testing.T) { + t.Parallel() + + scenarios := []struct { + name string + point types.GeoPoint + expected map[string]any + }{ + {"zero", types.GeoPoint{}, map[string]any{"lon": 0.0, "lat": 0.0}}, + {"non-zero", types.GeoPoint{Lon: -10, Lat: 20.123}, map[string]any{"lon": -10.0, "lat": 20.123}}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := s.point.AsMap() + + if len(result) != len(s.expected) { + t.Fatalf("Expected %d keys, got %d: %v", len(s.expected), len(result), result) + } + + for k, v := range s.expected { + found, ok := result[k] + if !ok { + t.Fatalf("Missing expected %q key: %v", k, result) + } + + if found != v { + t.Fatalf("Expected %q key value %v, got %v", k, v, found) + } + } + }) + } +} + +func TestGeoPointStringAndValue(t *testing.T) { + t.Parallel() + + scenarios := []struct { + name string + point types.GeoPoint + expected string + }{ + {"zero", types.GeoPoint{}, `{"lon":0,"lat":0}`}, + {"non-zero", types.GeoPoint{Lon: -10, Lat: 20.123}, `{"lon":-10,"lat":20.123}`}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + str := s.point.String() + + val, err := s.point.Value() + if err != nil { + t.Fatal(err) + } + + if str != val { + t.Fatalf("Expected String and Value to return the same value") + } + + if str != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, str) + } + }) + } +} + +func TestGeoPointScan(t *testing.T) { + t.Parallel() + + scenarios := []struct { + value any + expectErr bool + expectStr string + }{ + {nil, false, `{"lon":1,"lat":2}`}, + {"", false, `{"lon":1,"lat":2}`}, + {types.JSONRaw{}, false, `{"lon":1,"lat":2}`}, + {[]byte{}, false, `{"lon":1,"lat":2}`}, + {`{}`, false, `{"lon":1,"lat":2}`}, + {`[]`, true, `{"lon":1,"lat":2}`}, + {0, true, `{"lon":1,"lat":2}`}, + {`{"lon":"1.23","lat":"4.56"}`, true, `{"lon":1,"lat":2}`}, + {`{"lon":1.23,"lat":4.56}`, false, `{"lon":1.23,"lat":4.56}`}, + {[]byte(`{"lon":1.23,"lat":4.56}`), false, `{"lon":1.23,"lat":4.56}`}, + {types.JSONRaw(`{"lon":1.23,"lat":4.56}`), false, `{"lon":1.23,"lat":4.56}`}, + {types.GeoPoint{}, false, `{"lon":0,"lat":0}`}, + {types.GeoPoint{Lon: 1.23, Lat: 4.56}, false, `{"lon":1.23,"lat":4.56}`}, + {&types.GeoPoint{Lon: 1.23, Lat: 4.56}, false, `{"lon":1.23,"lat":4.56}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + point := types.GeoPoint{Lon: 1, Lat: 2} + + err := point.Scan(s.value) + + hasErr := err != nil + if hasErr != s.expectErr { + t.Errorf("Expected hasErr %v, got %v (%v)", s.expectErr, hasErr, err) + } + + if str := point.String(); str != s.expectStr { + t.Errorf("Expected\n%s\ngot\n%s", s.expectStr, str) + } + }) + } +} diff --git a/server/core_backend-server/tools/types/json_array.go b/server/core_backend-server/tools/types/json_array.go new file mode 100644 index 0000000..024fd7b --- /dev/null +++ b/server/core_backend-server/tools/types/json_array.go @@ -0,0 +1,58 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +// JSONArray defines a slice that is safe for json and db read/write. +type JSONArray[T any] []T + +// internal alias to prevent recursion during marshalization. +type jsonArrayAlias[T any] JSONArray[T] + +// MarshalJSON implements the [json.Marshaler] interface. +func (m JSONArray[T]) MarshalJSON() ([]byte, error) { + // initialize an empty map to ensure that `[]` is returned as json + if m == nil { + m = JSONArray[T]{} + } + + return json.Marshal(jsonArrayAlias[T](m)) +} + +// String returns the string representation of the current json array. +func (m JSONArray[T]) String() string { + v, _ := m.MarshalJSON() + return string(v) +} + +// Value implements the [driver.Valuer] interface. +func (m JSONArray[T]) Value() (driver.Value, error) { + data, err := json.Marshal(m) + + return string(data), err +} + +// Scan implements [sql.Scanner] interface to scan the provided value +// into the current JSONArray[T] instance. +func (m *JSONArray[T]) Scan(value any) error { + var data []byte + switch v := value.(type) { + case nil: + // no cast needed + case []byte: + data = v + case string: + data = []byte(v) + default: + return fmt.Errorf("failed to unmarshal JSONArray value: %q", value) + } + + if len(data) == 0 { + data = []byte("[]") + } + + return json.Unmarshal(data, m) +} diff --git a/server/core_backend-server/tools/types/json_array_test.go b/server/core_backend-server/tools/types/json_array_test.go new file mode 100644 index 0000000..27aa16f --- /dev/null +++ b/server/core_backend-server/tools/types/json_array_test.go @@ -0,0 +1,124 @@ +package types_test + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "testing" + + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestJSONArrayMarshalJSON(t *testing.T) { + scenarios := []struct { + json json.Marshaler + expected string + }{ + {new(types.JSONArray[any]), "[]"}, + {types.JSONArray[any]{}, `[]`}, + {types.JSONArray[int]{1, 2, 3}, `[1,2,3]`}, + {types.JSONArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, + {types.JSONArray[any]{1, "test"}, `[1,"test"]`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result, err := s.json.MarshalJSON() + if err != nil { + t.Fatal(err) + } + + if string(result) != s.expected { + t.Fatalf("Expected %s, got %s", s.expected, result) + } + }) + } +} + +func TestJSONArrayString(t *testing.T) { + scenarios := []struct { + json fmt.Stringer + expected string + }{ + {new(types.JSONArray[any]), "[]"}, + {types.JSONArray[any]{}, `[]`}, + {types.JSONArray[int]{1, 2, 3}, `[1,2,3]`}, + {types.JSONArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, + {types.JSONArray[any]{1, "test"}, `[1,"test"]`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result := s.json.String() + + if result != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result) + } + }) + } +} + +func TestJSONArrayValue(t *testing.T) { + scenarios := []struct { + json driver.Valuer + expected driver.Value + }{ + {new(types.JSONArray[any]), `[]`}, + {types.JSONArray[any]{}, `[]`}, + {types.JSONArray[int]{1, 2, 3}, `[1,2,3]`}, + {types.JSONArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, + {types.JSONArray[any]{1, "test"}, `[1,"test"]`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result, err := s.json.Value() + if err != nil { + t.Fatal(err) + } + + if result != s.expected { + t.Fatalf("Expected %s, got %#v", s.expected, result) + } + }) + } +} + +func TestJSONArrayScan(t *testing.T) { + scenarios := []struct { + value any + expectError bool + expectJSON string + }{ + {``, false, `[]`}, + {[]byte{}, false, `[]`}, + {nil, false, `[]`}, + {123, true, `[]`}, + {`""`, true, `[]`}, + {`invalid_json`, true, `[]`}, + {`"test"`, true, `[]`}, + {`1,2,3`, true, `[]`}, + {`[1, 2, 3`, true, `[]`}, + {`[1, 2, 3]`, false, `[1,2,3]`}, + {[]byte(`[1, 2, 3]`), false, `[1,2,3]`}, + {`[1, "test"]`, false, `[1,"test"]`}, + {`[]`, false, `[]`}, + } + + for i, s := range scenarios { + arr := types.JSONArray[any]{} + scanErr := arr.Scan(s.value) + + hasErr := scanErr != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, scanErr) + continue + } + + result, _ := arr.MarshalJSON() + + if string(result) != s.expectJSON { + t.Errorf("(%d) Expected %s, got %v", i, s.expectJSON, string(result)) + } + } +} diff --git a/server/core_backend-server/tools/types/json_map.go b/server/core_backend-server/tools/types/json_map.go new file mode 100644 index 0000000..87b0fc8 --- /dev/null +++ b/server/core_backend-server/tools/types/json_map.go @@ -0,0 +1,73 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +// JSONMap defines a map that is safe for json and db read/write. +type JSONMap[T any] map[string]T + +// MarshalJSON implements the [json.Marshaler] interface. +func (m JSONMap[T]) MarshalJSON() ([]byte, error) { + type alias JSONMap[T] // prevent recursion + + // initialize an empty map to ensure that `{}` is returned as json + if m == nil { + m = JSONMap[T]{} + } + + return json.Marshal(alias(m)) +} + +// String returns the string representation of the current json map. +func (m JSONMap[T]) String() string { + v, _ := m.MarshalJSON() + return string(v) +} + +// Get retrieves a single value from the current JSONMap[T]. +// +// This helper was added primarily to assist the goja integration since custom map types +// don't have direct access to the map keys (https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods). +func (m JSONMap[T]) Get(key string) T { + return m[key] +} + +// Set sets a single value in the current JSONMap[T]. +// +// This helper was added primarily to assist the goja integration since custom map types +// don't have direct access to the map keys (https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods). +func (m JSONMap[T]) Set(key string, value T) { + m[key] = value +} + +// Value implements the [driver.Valuer] interface. +func (m JSONMap[T]) Value() (driver.Value, error) { + data, err := json.Marshal(m) + + return string(data), err +} + +// Scan implements [sql.Scanner] interface to scan the provided value +// into the current JSONMap[T] instance. +func (m *JSONMap[T]) Scan(value any) error { + var data []byte + switch v := value.(type) { + case nil: + // no cast needed + case []byte: + data = v + case string: + data = []byte(v) + default: + return fmt.Errorf("failed to unmarshal JSONMap[T] value: %q", value) + } + + if len(data) == 0 { + data = []byte("{}") + } + + return json.Unmarshal(data, m) +} diff --git a/server/core_backend-server/tools/types/json_map_test.go b/server/core_backend-server/tools/types/json_map_test.go new file mode 100644 index 0000000..56ea95a --- /dev/null +++ b/server/core_backend-server/tools/types/json_map_test.go @@ -0,0 +1,164 @@ +package types_test + +import ( + "database/sql/driver" + "fmt" + "testing" + + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestJSONMapMarshalJSON(t *testing.T) { + scenarios := []struct { + json types.JSONMap[any] + expected string + }{ + {nil, "{}"}, + {types.JSONMap[any]{}, `{}`}, + {types.JSONMap[any]{"test1": 123, "test2": "lorem"}, `{"test1":123,"test2":"lorem"}`}, + {types.JSONMap[any]{"test": []int{1, 2, 3}}, `{"test":[1,2,3]}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result, err := s.json.MarshalJSON() + if err != nil { + t.Fatal(err) + } + + if string(result) != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result) + } + }) + } +} + +func TestJSONMapMarshalString(t *testing.T) { + scenarios := []struct { + json types.JSONMap[any] + expected string + }{ + {nil, "{}"}, + {types.JSONMap[any]{}, `{}`}, + {types.JSONMap[any]{"test1": 123, "test2": "lorem"}, `{"test1":123,"test2":"lorem"}`}, + {types.JSONMap[any]{"test": []int{1, 2, 3}}, `{"test":[1,2,3]}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result := s.json.String() + + if result != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result) + } + }) + } +} + +func TestJSONMapGet(t *testing.T) { + scenarios := []struct { + json types.JSONMap[any] + key string + expected any + }{ + {nil, "test", nil}, + {types.JSONMap[any]{"test": 123}, "test", 123}, + {types.JSONMap[any]{"test": 123}, "missing", nil}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) { + result := s.json.Get(s.key) + if result != s.expected { + t.Fatalf("Expected %s, got %#v", s.expected, result) + } + }) + } +} + +func TestJSONMapSet(t *testing.T) { + scenarios := []struct { + key string + value any + }{ + {"a", nil}, + {"a", 123}, + {"b", "test"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) { + j := types.JSONMap[any]{} + + j.Set(s.key, s.value) + + if v := j[s.key]; v != s.value { + t.Fatalf("Expected %s, got %#v", s.value, v) + } + }) + } +} + +func TestJSONMapValue(t *testing.T) { + scenarios := []struct { + json types.JSONMap[any] + expected driver.Value + }{ + {nil, `{}`}, + {types.JSONMap[any]{}, `{}`}, + {types.JSONMap[any]{"test1": 123, "test2": "lorem"}, `{"test1":123,"test2":"lorem"}`}, + {types.JSONMap[any]{"test": []int{1, 2, 3}}, `{"test":[1,2,3]}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result, err := s.json.Value() + if err != nil { + t.Fatal(err) + } + + if result != s.expected { + t.Fatalf("Expected %s, got %#v", s.expected, result) + } + }) + } +} + +func TestJSONArrayMapScan(t *testing.T) { + scenarios := []struct { + value any + expectError bool + expectJSON string + }{ + {``, false, `{}`}, + {nil, false, `{}`}, + {[]byte{}, false, `{}`}, + {`{}`, false, `{}`}, + {123, true, `{}`}, + {`""`, true, `{}`}, + {`invalid_json`, true, `{}`}, + {`"test"`, true, `{}`}, + {`1,2,3`, true, `{}`}, + {`{"test": 1`, true, `{}`}, + {`{"test": 1}`, false, `{"test":1}`}, + {[]byte(`{"test": 1}`), false, `{"test":1}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + arr := types.JSONMap[any]{} + scanErr := arr.Scan(s.value) + + hasErr := scanErr != nil + if hasErr != s.expectError { + t.Fatalf("Expected %v, got %v (%v)", s.expectError, hasErr, scanErr) + } + + result, _ := arr.MarshalJSON() + + if string(result) != s.expectJSON { + t.Fatalf("Expected %s, got %s", s.expectJSON, result) + } + }) + } +} diff --git a/server/core_backend-server/tools/types/json_raw.go b/server/core_backend-server/tools/types/json_raw.go new file mode 100644 index 0000000..8ed58c2 --- /dev/null +++ b/server/core_backend-server/tools/types/json_raw.go @@ -0,0 +1,84 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "errors" +) + +// JSONRaw defines a json value type that is safe for db read/write. +type JSONRaw []byte + +// ParseJSONRaw creates a new JSONRaw instance from the provided value +// (could be JSONRaw, int, float, string, []byte, etc.). +func ParseJSONRaw(value any) (JSONRaw, error) { + result := JSONRaw{} + err := result.Scan(value) + return result, err +} + +// String returns the current JSONRaw instance as a json encoded string. +func (j JSONRaw) String() string { + raw, _ := j.MarshalJSON() + return string(raw) +} + +// MarshalJSON implements the [json.Marshaler] interface. +func (j JSONRaw) MarshalJSON() ([]byte, error) { + if len(j) == 0 { + return []byte("null"), nil + } + + return j, nil +} + +// UnmarshalJSON implements the [json.Unmarshaler] interface. +func (j *JSONRaw) UnmarshalJSON(b []byte) error { + if j == nil { + return errors.New("JSONRaw: UnmarshalJSON on nil pointer") + } + + *j = append((*j)[0:0], b...) + + return nil +} + +// Value implements the [driver.Valuer] interface. +func (j JSONRaw) Value() (driver.Value, error) { + if len(j) == 0 { + return nil, nil + } + + return j.String(), nil +} + +// Scan implements [sql.Scanner] interface to scan the provided value +// into the current JSONRaw instance. +func (j *JSONRaw) Scan(value any) error { + var data []byte + + switch v := value.(type) { + case nil: + // no cast is needed + case []byte: + if len(v) != 0 { + data = v + } + case string: + if v != "" { + data = []byte(v) + } + case JSONRaw: + if len(v) != 0 { + data = []byte(v) + } + default: + bytes, err := json.Marshal(v) + if err != nil { + return err + } + data = bytes + } + + return j.UnmarshalJSON(data) +} diff --git a/server/core_backend-server/tools/types/json_raw_test.go b/server/core_backend-server/tools/types/json_raw_test.go new file mode 100644 index 0000000..31c8eb2 --- /dev/null +++ b/server/core_backend-server/tools/types/json_raw_test.go @@ -0,0 +1,189 @@ +package types_test + +import ( + "database/sql/driver" + "fmt" + "testing" + + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestParseJSONRaw(t *testing.T) { + scenarios := []struct { + value any + expectError bool + expectJSON string + }{ + {nil, false, `null`}, + {``, false, `null`}, + {[]byte{}, false, `null`}, + {types.JSONRaw{}, false, `null`}, + {`{}`, false, `{}`}, + {`[]`, false, `[]`}, + {123, false, `123`}, + {`""`, false, `""`}, + {`test`, false, `test`}, + {`{"invalid"`, false, `{"invalid"`}, // treated as a byte casted string + {`{"test":1}`, false, `{"test":1}`}, + {[]byte(`[1,2,3]`), false, `[1,2,3]`}, + {[]int{1, 2, 3}, false, `[1,2,3]`}, + {map[string]int{"test": 1}, false, `{"test":1}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + raw, parseErr := types.ParseJSONRaw(s.value) + + hasErr := parseErr != nil + if hasErr != s.expectError { + t.Fatalf("Expected %v, got %v (%v)", s.expectError, hasErr, parseErr) + } + + result, _ := raw.MarshalJSON() + + if string(result) != s.expectJSON { + t.Fatalf("Expected %s, got %s", s.expectJSON, string(result)) + } + }) + } +} + +func TestJSONRawString(t *testing.T) { + scenarios := []struct { + json types.JSONRaw + expected string + }{ + {nil, `null`}, + {types.JSONRaw{}, `null`}, + {types.JSONRaw([]byte(`123`)), `123`}, + {types.JSONRaw(`{"demo":123}`), `{"demo":123}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.expected), func(t *testing.T) { + result := s.json.String() + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) + } +} + +func TestJSONRawMarshalJSON(t *testing.T) { + scenarios := []struct { + json types.JSONRaw + expected string + }{ + {nil, `null`}, + {types.JSONRaw{}, `null`}, + {types.JSONRaw([]byte(`123`)), `123`}, + {types.JSONRaw(`{"demo":123}`), `{"demo":123}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.expected), func(t *testing.T) { + result, err := s.json.MarshalJSON() + if err != nil { + t.Fatal(err) + } + + if string(result) != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, string(result)) + } + }) + } +} + +func TestJSONRawUnmarshalJSON(t *testing.T) { + scenarios := []struct { + json []byte + expectString string + }{ + {nil, `null`}, + {[]byte{0, 1, 2}, "\x00\x01\x02"}, + {[]byte("123"), "123"}, + {[]byte("test"), "test"}, + {[]byte(`{"test":123}`), `{"test":123}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.expectString), func(t *testing.T) { + raw := types.JSONRaw{} + + err := raw.UnmarshalJSON(s.json) + if err != nil { + t.Fatal(err) + } + + if raw.String() != s.expectString { + t.Fatalf("Expected %q, got %q", s.expectString, raw.String()) + } + }) + } +} + +func TestJSONRawValue(t *testing.T) { + scenarios := []struct { + json types.JSONRaw + expected driver.Value + }{ + {nil, nil}, + {types.JSONRaw{}, nil}, + {types.JSONRaw(``), nil}, + {types.JSONRaw(`test`), `test`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.json), func(t *testing.T) { + result, err := s.json.Value() + if err != nil { + t.Fatal(err) + } + + if result != s.expected { + t.Fatalf("Expected %s, got %v", s.expected, result) + } + }) + } +} + +func TestJSONRawScan(t *testing.T) { + scenarios := []struct { + value any + expectError bool + expectJSON string + }{ + {nil, false, `null`}, + {``, false, `null`}, + {[]byte{}, false, `null`}, + {types.JSONRaw{}, false, `null`}, + {types.JSONRaw(`test`), false, `test`}, + {`{}`, false, `{}`}, + {`[]`, false, `[]`}, + {123, false, `123`}, + {`""`, false, `""`}, + {`test`, false, `test`}, + {`{"invalid"`, false, `{"invalid"`}, // treated as a byte casted string + {`{"test":1}`, false, `{"test":1}`}, + {[]byte(`[1,2,3]`), false, `[1,2,3]`}, + {[]int{1, 2, 3}, false, `[1,2,3]`}, + {map[string]int{"test": 1}, false, `{"test":1}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + raw := types.JSONRaw{} + scanErr := raw.Scan(s.value) + hasErr := scanErr != nil + if hasErr != s.expectError { + t.Fatalf("Expected %v, got %v (%v)", s.expectError, hasErr, scanErr) + } + + result, _ := raw.MarshalJSON() + + if string(result) != s.expectJSON { + t.Fatalf("Expected %s, got %v", s.expectJSON, string(result)) + } + }) + } +} diff --git a/server/core_backend-server/tools/types/types.go b/server/core_backend-server/tools/types/types.go new file mode 100644 index 0000000..c07d805 --- /dev/null +++ b/server/core_backend-server/tools/types/types.go @@ -0,0 +1,8 @@ +// Package types implements some commonly used db serializable types +// like datetime, json, etc. +package types + +// Pointer is a generic helper that returns val as *T. +func Pointer[T any](val T) *T { + return &val +} diff --git a/server/core_backend-server/tools/types/types_test.go b/server/core_backend-server/tools/types/types_test.go new file mode 100644 index 0000000..615ac4c --- /dev/null +++ b/server/core_backend-server/tools/types/types_test.go @@ -0,0 +1,24 @@ +package types_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestPointer(t *testing.T) { + s1 := types.Pointer("") + if s1 == nil || *s1 != "" { + t.Fatalf("Expected empty string pointer, got %#v", s1) + } + + s2 := types.Pointer("test") + if s2 == nil || *s2 != "test" { + t.Fatalf("Expected 'test' string pointer, got %#v", s2) + } + + s3 := types.Pointer(123) + if s3 == nil || *s3 != 123 { + t.Fatalf("Expected 123 string pointer, got %#v", s3) + } +} diff --git a/server/pb_migrations/1746786764_updated_services.js b/server/pb_migrations/1746786764_updated_services.js new file mode 100644 index 0000000..93d25d2 --- /dev/null +++ b/server/pb_migrations/1746786764_updated_services.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": null, + "listRule": null, + "updateRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1746786820_updated_services.js b/server/pb_migrations/1746786820_updated_services.js new file mode 100644 index 0000000..966c93f --- /dev/null +++ b/server/pb_migrations/1746786820_updated_services.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": "id = @request.auth.id", + "deleteRule": "id = @request.auth.id", + "listRule": "id = @request.auth.id", + "updateRule": "id = @request.auth.id", + "viewRule": "id = @request.auth.id" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": null, + "listRule": null, + "updateRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1746786864_updated_services.js b/server/pb_migrations/1746786864_updated_services.js new file mode 100644 index 0000000..187b077 --- /dev/null +++ b/server/pb_migrations/1746786864_updated_services.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "listRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "listRule": "id = @request.auth.id" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1746787173_updated_services.js b/server/pb_migrations/1746787173_updated_services.js new file mode 100644 index 0000000..abdef5b --- /dev/null +++ b/server/pb_migrations/1746787173_updated_services.js @@ -0,0 +1,26 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": "id = @request.auth.id", + "deleteRule": "id = @request.auth.id", + "updateRule": "id = @request.auth.id", + "viewRule": "id = @request.auth.id" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1746787500_updated_services.js b/server/pb_migrations/1746787500_updated_services.js new file mode 100644 index 0000000..19ab1c4 --- /dev/null +++ b/server/pb_migrations/1746787500_updated_services.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "listRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "listRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1746787517_updated_services.js b/server/pb_migrations/1746787517_updated_services.js new file mode 100644 index 0000000..2679b11 --- /dev/null +++ b/server/pb_migrations/1746787517_updated_services.js @@ -0,0 +1,26 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1746787595_updated_uptime_data.js b/server/pb_migrations/1746787595_updated_uptime_data.js new file mode 100644 index 0000000..882c962 --- /dev/null +++ b/server/pb_migrations/1746787595_updated_uptime_data.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_67962705") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_67962705") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1746787681_updated_alert_configurations.js b/server/pb_migrations/1746787681_updated_alert_configurations.js new file mode 100644 index 0000000..a6f9db0 --- /dev/null +++ b/server/pb_migrations/1746787681_updated_alert_configurations.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1748029497_updated__superusers.js b/server/pb_migrations/1748029497_updated__superusers.js new file mode 100644 index 0000000..03d617f --- /dev/null +++ b/server/pb_migrations/1748029497_updated__superusers.js @@ -0,0 +1,67 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3142635823") + + // add field + collection.fields.addAt(6, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3687080900", + "max": 0, + "min": 0, + "name": "full_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add field + collection.fields.addAt(7, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text4166911607", + "max": 0, + "min": 0, + "name": "username", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add field + collection.fields.addAt(8, new Field({ + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "active", + "inactive" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3142635823") + + // remove field + collection.fields.removeById("text3687080900") + + // remove field + collection.fields.removeById("text4166911607") + + // remove field + collection.fields.removeById("select2063623452") + + return app.save(collection) +}) diff --git a/server/pb_migrations/1750241370_updated_ping_data.js b/server/pb_migrations/1750241370_updated_ping_data.js new file mode 100644 index 0000000..f197b25 --- /dev/null +++ b/server/pb_migrations/1750241370_updated_ping_data.js @@ -0,0 +1,61 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1168766540") + + // remove field + collection.fields.removeById("text1404634243") + + // update field + collection.fields.addAt(15, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1168766540") + + // add field + collection.fields.addAt(17, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1404634243", + "max": 0, + "min": 0, + "name": "agent_ip_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // update field + collection.fields.addAt(15, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1750241407_updated_regional_service.js b/server/pb_migrations/1750241407_updated_regional_service.js new file mode 100644 index 0000000..84a577c --- /dev/null +++ b/server/pb_migrations/1750241407_updated_regional_service.js @@ -0,0 +1,42 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_171698555") + + // update field + collection.fields.addAt(1, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_171698555") + + // update field + collection.fields.addAt(1, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1750414552_deleted_server_processes.js b/server/pb_migrations/1750414552_deleted_server_processes.js new file mode 100644 index 0000000..79af47c --- /dev/null +++ b/server/pb_migrations/1750414552_deleted_server_processes.js @@ -0,0 +1,144 @@ +/// +migrate((app) => { + try { + const collection = app.findCollectionByNameOrId("pbc_2018671343"); + return app.delete(collection); + } catch (e) { + console.warn("Skip delete (server_processes):", e?.message); + } +}, (app) => { + try { + const collection = new Collection({ + "createRule": "", + "deleteRule": "", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1431356653", + "max": 0, + "min": 0, + "name": "pid", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1391558374", + "max": null, + "min": null, + "name": "memory_percent", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2651913466", + "max": null, + "min": null, + "name": "cpu_percent", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_2018671343", + "indexes": [], + "listRule": "", + "name": "server_processes", + "system": false, + "type": "base", + "updateRule": "", + "viewRule": "" + }); + + return app.save(collection); + } catch (e) { + console.warn("Skip rollback (server_processes):", e?.message); + } +}); diff --git a/server/pb_migrations/1750414561_deleted_server_notifications.js b/server/pb_migrations/1750414561_deleted_server_notifications.js new file mode 100644 index 0000000..aa7c6c9 --- /dev/null +++ b/server/pb_migrations/1750414561_deleted_server_notifications.js @@ -0,0 +1,178 @@ +/// +migrate((app) => { + + try { + const collection = app.findCollectionByNameOrId("pbc_3414192583"); + return app.delete(collection); + } catch (e) { + console.warn("Skip delete (server_notifications):", e?.message); + } +}, (app) => { + try { + const collection = new Collection({ + "createRule": "", + "deleteRule": "", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3065852031", + "max": 0, + "min": 0, + "name": "message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text887233555", + "max": 0, + "min": 0, + "name": "notification_type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date3805952114", + "max": "", + "min": "", + "name": "read_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number1377725610", + "max": null, + "min": null, + "name": "cpu_threshold", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3087505384", + "max": null, + "min": null, + "name": "ram_threshold", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2833320134", + "max": null, + "min": null, + "name": "disk_threshold", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1826572927", + "max": null, + "min": null, + "name": "network_threshold", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2184331740", + "max": null, + "min": null, + "name": "notification_config_id", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3414192583", + "indexes": [], + "listRule": "", + "name": "server_notifications", + "system": false, + "type": "base", + "updateRule": "", + "viewRule": "" + }); + + return app.save(collection); + } catch (e) { + console.warn("Skip rollback (server_notifications):", e?.message); + } +}); diff --git a/server/pb_migrations/1750502263_updated_regional_service.js b/server/pb_migrations/1750502263_updated_regional_service.js new file mode 100644 index 0000000..230a6ea --- /dev/null +++ b/server/pb_migrations/1750502263_updated_regional_service.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_171698555") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_171698555") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": null, + "listRule": null, + "updateRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1752916282_updated_webhook_configs.js b/server/pb_migrations/1752916282_updated_webhook_configs.js new file mode 100644 index 0000000..79f94a7 --- /dev/null +++ b/server/pb_migrations/1752916282_updated_webhook_configs.js @@ -0,0 +1,125 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_45665081") + + // remove field + collection.fields.removeById("select4246785570") + + // add field + collection.fields.addAt(9, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add field + collection.fields.addAt(10, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1582905952", + "max": 0, + "min": 0, + "name": "method", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add field + collection.fields.addAt(11, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1414600993", + "max": 0, + "min": 0, + "name": "payload_template", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // update field + collection.fields.addAt(7, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text2168550802", + "max": 0, + "min": 0, + "name": "trigger_events", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_45665081") + + // add field + collection.fields.addAt(4, new Field({ + "hidden": false, + "id": "select4246785570", + "maxSelect": 1, + "name": "event_filters", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "down", + "up", + "ssl_expired", + "warning", + "ssl_ok", + "high_cpu", + "high_memory", + "agent_offline", + "custom_alert" + ] + })) + + // remove field + collection.fields.removeById("text1579384326") + + // remove field + collection.fields.removeById("text1582905952") + + // remove field + collection.fields.removeById("text1414600993") + + // update field + collection.fields.addAt(8, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text2168550802", + "max": 0, + "min": 0, + "name": "timeout", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1752921327_updated_alert_configurations.js b/server/pb_migrations/1752921327_updated_alert_configurations.js new file mode 100644 index 0000000..728ff90 --- /dev/null +++ b/server/pb_migrations/1752921327_updated_alert_configurations.js @@ -0,0 +1,42 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update field + collection.fields.addAt(10, new Field({ + "hidden": false, + "id": "select1358543748", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "disabled", + "enabled" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update field + collection.fields.addAt(10, new Field({ + "hidden": false, + "id": "select1358543748", + "maxSelect": 1, + "name": "enabled", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "true", + "false" + ] + })) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1752921397_updated_alert_configurations.js b/server/pb_migrations/1752921397_updated_alert_configurations.js new file mode 100644 index 0000000..fad5689 --- /dev/null +++ b/server/pb_migrations/1752921397_updated_alert_configurations.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // add field + collection.fields.addAt(14, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1553704459", + "max": 0, + "min": 0, + "name": "webhook_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // remove field + collection.fields.removeById("text1553704459") + + return app.save(collection) +}) diff --git a/server/pb_migrations/1753085447_updated_server_metrics.js b/server/pb_migrations/1753085447_updated_server_metrics.js new file mode 100644 index 0000000..c0ecf6c --- /dev/null +++ b/server/pb_migrations/1753085447_updated_server_metrics.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1998570700") + + // add field + collection.fields.addAt(19, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3065852031", + "max": 0, + "min": 0, + "name": "message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1998570700") + + // remove field + collection.fields.removeById("text3065852031") + + return app.save(collection) +}) diff --git a/server/pb_migrations/1753102275_updated_server_metrics.js b/server/pb_migrations/1753102275_updated_server_metrics.js new file mode 100644 index 0000000..acce145 --- /dev/null +++ b/server/pb_migrations/1753102275_updated_server_metrics.js @@ -0,0 +1,61 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1998570700") + + // add field + collection.fields.addAt(20, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text772177811", + "max": 0, + "min": 0, + "name": "detail", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // update field + collection.fields.addAt(19, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3065852031", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1998570700") + + // remove field + collection.fields.removeById("text772177811") + + // update field + collection.fields.addAt(19, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3065852031", + "max": 0, + "min": 0, + "name": "message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1753102296_updated_server_metrics.js b/server/pb_migrations/1753102296_updated_server_metrics.js new file mode 100644 index 0000000..262830d --- /dev/null +++ b/server/pb_migrations/1753102296_updated_server_metrics.js @@ -0,0 +1,42 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1998570700") + + // update field + collection.fields.addAt(20, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text772177811", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1998570700") + + // update field + collection.fields.addAt(20, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text772177811", + "max": 0, + "min": 0, + "name": "detail", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1755250344_updated_alert_configurations.js b/server/pb_migrations/1755250344_updated_alert_configurations.js new file mode 100644 index 0000000..9f38333 --- /dev/null +++ b/server/pb_migrations/1755250344_updated_alert_configurations.js @@ -0,0 +1,72 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // add field + collection.fields.addAt(20, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1654359675", + "max": 0, + "min": 0, + "name": "ntfy_endpoint", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // update field + collection.fields.addAt(9, new Field({ + "hidden": false, + "id": "select887233555", + "maxSelect": 1, + "name": "notification_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "telegram", + "signal", + "discord", + "slack", + "webhook", + "google_chat", + "email", + "ntfy" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // remove field + collection.fields.removeById("text1654359675") + + // update field + collection.fields.addAt(9, new Field({ + "hidden": false, + "id": "select887233555", + "maxSelect": 1, + "name": "notification_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "telegram", + "signal", + "discord", + "slack", + "webhook", + "google_chat", + "email" + ] + })) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1756994399_updated__superusers.js b/server/pb_migrations/1756994399_updated__superusers.js new file mode 100644 index 0000000..c6f5aa1 --- /dev/null +++ b/server/pb_migrations/1756994399_updated__superusers.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3142635823") + + // add field + collection.fields.addAt(9, new Field({ + "hidden": false, + "id": "file376926767", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [], + "name": "avatar", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3142635823") + + // remove field + collection.fields.removeById("file376926767") + + return app.save(collection) +}) diff --git a/server/pb_migrations/1757161629_updated_alert_configurations.js b/server/pb_migrations/1757161629_updated_alert_configurations.js new file mode 100644 index 0000000..092fd4b --- /dev/null +++ b/server/pb_migrations/1757161629_updated_alert_configurations.js @@ -0,0 +1,78 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // add field + collection.fields.addAt(23, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3401535476", + "max": 0, + "min": 0, + "name": "server_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // update field + collection.fields.addAt(9, new Field({ + "hidden": false, + "id": "select887233555", + "maxSelect": 1, + "name": "notification_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "telegram", + "signal", + "discord", + "slack", + "webhook", + "google_chat", + "email", + "ntfy", + "pushover", + "notifiarr", + "gotify" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // remove field + collection.fields.removeById("text3401535476") + + // update field + collection.fields.addAt(9, new Field({ + "hidden": false, + "id": "select887233555", + "maxSelect": 1, + "name": "notification_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "telegram", + "signal", + "discord", + "slack", + "webhook", + "google_chat", + "email", + "ntfy", + "pushover", + "notifiarr" + ] + })) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1757233868_updated_data_settings.js b/server/pb_migrations/1757233868_updated_data_settings.js new file mode 100644 index 0000000..c23f163 --- /dev/null +++ b/server/pb_migrations/1757233868_updated_data_settings.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2889059272") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2889059272") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1757233934_updated_incidents.js b/server/pb_migrations/1757233934_updated_incidents.js new file mode 100644 index 0000000..18f6efa --- /dev/null +++ b/server/pb_migrations/1757233934_updated_incidents.js @@ -0,0 +1,26 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2189087560") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2189087560") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1757233957_updated_maintenance.js b/server/pb_migrations/1757233957_updated_maintenance.js new file mode 100644 index 0000000..628d82b --- /dev/null +++ b/server/pb_migrations/1757233957_updated_maintenance.js @@ -0,0 +1,24 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1738231158") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1738231158") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "updateRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1757233963_updated_incidents.js b/server/pb_migrations/1757233963_updated_incidents.js new file mode 100644 index 0000000..2289889 --- /dev/null +++ b/server/pb_migrations/1757233963_updated_incidents.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2189087560") + + // update collection data + unmarshal({ + "listRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2189087560") + + // update collection data + unmarshal({ + "listRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1757233983_updated_operational_page.js b/server/pb_migrations/1757233983_updated_operational_page.js new file mode 100644 index 0000000..cad9695 --- /dev/null +++ b/server/pb_migrations/1757233983_updated_operational_page.js @@ -0,0 +1,24 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3662317132") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3662317132") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "updateRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1757234002_updated_ping_data.js b/server/pb_migrations/1757234002_updated_ping_data.js new file mode 100644 index 0000000..c12c81f --- /dev/null +++ b/server/pb_migrations/1757234002_updated_ping_data.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1168766540") + + // update collection data + unmarshal({ + "deleteRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1168766540") + + // update collection data + unmarshal({ + "deleteRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1757234055_updated_alert_configurations.js b/server/pb_migrations/1757234055_updated_alert_configurations.js new file mode 100644 index 0000000..a6f9db0 --- /dev/null +++ b/server/pb_migrations/1757234055_updated_alert_configurations.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1757769533_updated_alert_configurations.js b/server/pb_migrations/1757769533_updated_alert_configurations.js new file mode 100644 index 0000000..24a5dea --- /dev/null +++ b/server/pb_migrations/1757769533_updated_alert_configurations.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1760189594_deleted_webhook.js b/server/pb_migrations/1760189594_deleted_webhook.js new file mode 100644 index 0000000..c85a557 --- /dev/null +++ b/server/pb_migrations/1760189594_deleted_webhook.js @@ -0,0 +1,211 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_45665081"); + + return app.delete(collection); +}, (app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2809058197", + "max": 0, + "min": 0, + "name": "user_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1358543748", + "maxSelect": 1, + "name": "enabled", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "on", + "off" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1554180325", + "max": 0, + "min": 0, + "name": "secret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4144189317", + "max": 0, + "min": 0, + "name": "headers", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2129530235", + "max": 0, + "min": 0, + "name": "retry_count", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2168550802", + "max": 0, + "min": 0, + "name": "trigger_events", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1582905952", + "max": 0, + "min": 0, + "name": "method", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1414600993", + "max": 0, + "min": 0, + "name": "payload_template", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_45665081", + "indexes": [], + "listRule": null, + "name": "webhook", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}) diff --git a/server/pb_migrations/1760190053_updated_data_settings.js b/server/pb_migrations/1760190053_updated_data_settings.js new file mode 100644 index 0000000..e65160f --- /dev/null +++ b/server/pb_migrations/1760190053_updated_data_settings.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2889059272") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2889059272") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1760190072_updated_incidents.js b/server/pb_migrations/1760190072_updated_incidents.js new file mode 100644 index 0000000..955eeff --- /dev/null +++ b/server/pb_migrations/1760190072_updated_incidents.js @@ -0,0 +1,24 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2189087560") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "updateRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2189087560") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1760190080_updated_maintenance.js b/server/pb_migrations/1760190080_updated_maintenance.js new file mode 100644 index 0000000..8fc6f83 --- /dev/null +++ b/server/pb_migrations/1760190080_updated_maintenance.js @@ -0,0 +1,24 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1738231158") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "updateRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1738231158") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1760190087_updated_operational_page.js b/server/pb_migrations/1760190087_updated_operational_page.js new file mode 100644 index 0000000..e88b76f --- /dev/null +++ b/server/pb_migrations/1760190087_updated_operational_page.js @@ -0,0 +1,24 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3662317132") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "updateRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3662317132") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1760190091_updated_ping_data.js b/server/pb_migrations/1760190091_updated_ping_data.js new file mode 100644 index 0000000..cb4d63f --- /dev/null +++ b/server/pb_migrations/1760190091_updated_ping_data.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1168766540") + + // update collection data + unmarshal({ + "deleteRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1168766540") + + // update collection data + unmarshal({ + "deleteRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125544_updated_alert_configurations.js b/server/pb_migrations/1772125544_updated_alert_configurations.js new file mode 100644 index 0000000..a6f9db0 --- /dev/null +++ b/server/pb_migrations/1772125544_updated_alert_configurations.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125552_updated_data_settings.js b/server/pb_migrations/1772125552_updated_data_settings.js new file mode 100644 index 0000000..c23f163 --- /dev/null +++ b/server/pb_migrations/1772125552_updated_data_settings.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2889059272") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2889059272") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125560_updated_dns_data.js b/server/pb_migrations/1772125560_updated_dns_data.js new file mode 100644 index 0000000..51ee7d9 --- /dev/null +++ b/server/pb_migrations/1772125560_updated_dns_data.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_285611395") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_285611395") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125568_updated_docker_metrics.js b/server/pb_migrations/1772125568_updated_docker_metrics.js new file mode 100644 index 0000000..7316271 --- /dev/null +++ b/server/pb_migrations/1772125568_updated_docker_metrics.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3168803865") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3168803865") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125575_updated_dockers.js b/server/pb_migrations/1772125575_updated_dockers.js new file mode 100644 index 0000000..138e682 --- /dev/null +++ b/server/pb_migrations/1772125575_updated_dockers.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_583547467") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_583547467") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125583_updated_incidents.js b/server/pb_migrations/1772125583_updated_incidents.js new file mode 100644 index 0000000..9cf2d7d --- /dev/null +++ b/server/pb_migrations/1772125583_updated_incidents.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2189087560") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2189087560") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125590_updated_maintenance.js b/server/pb_migrations/1772125590_updated_maintenance.js new file mode 100644 index 0000000..effd597 --- /dev/null +++ b/server/pb_migrations/1772125590_updated_maintenance.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1738231158") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1738231158") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125598_updated_operational_page.js b/server/pb_migrations/1772125598_updated_operational_page.js new file mode 100644 index 0000000..2c3afa3 --- /dev/null +++ b/server/pb_migrations/1772125598_updated_operational_page.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3662317132") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3662317132") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125605_updated_ping_data.js b/server/pb_migrations/1772125605_updated_ping_data.js new file mode 100644 index 0000000..4eb4fd3 --- /dev/null +++ b/server/pb_migrations/1772125605_updated_ping_data.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1168766540") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1168766540") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125613_updated_regional_service.js b/server/pb_migrations/1772125613_updated_regional_service.js new file mode 100644 index 0000000..c491bdf --- /dev/null +++ b/server/pb_migrations/1772125613_updated_regional_service.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_171698555") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_171698555") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125622_updated_server_metrics.js b/server/pb_migrations/1772125622_updated_server_metrics.js new file mode 100644 index 0000000..5679b2d --- /dev/null +++ b/server/pb_migrations/1772125622_updated_server_metrics.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1998570700") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1998570700") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125668_updated_server_notification_templates.js b/server/pb_migrations/1772125668_updated_server_notification_templates.js new file mode 100644 index 0000000..677a1eb --- /dev/null +++ b/server/pb_migrations/1772125668_updated_server_notification_templates.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1965843930") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1965843930") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125679_updated_server_threshold_templates.js b/server/pb_migrations/1772125679_updated_server_threshold_templates.js new file mode 100644 index 0000000..f5f4005 --- /dev/null +++ b/server/pb_migrations/1772125679_updated_server_threshold_templates.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1464856393") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1464856393") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125685_updated_servers.js b/server/pb_migrations/1772125685_updated_servers.js new file mode 100644 index 0000000..fdfe4cc --- /dev/null +++ b/server/pb_migrations/1772125685_updated_servers.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3738798621") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3738798621") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125695_updated_service_group.js b/server/pb_migrations/1772125695_updated_service_group.js new file mode 100644 index 0000000..dcee93f --- /dev/null +++ b/server/pb_migrations/1772125695_updated_service_group.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_763794515") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_763794515") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": null, + "listRule": null, + "updateRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125706_updated_service_notification_templates.js b/server/pb_migrations/1772125706_updated_service_notification_templates.js new file mode 100644 index 0000000..252ff78 --- /dev/null +++ b/server/pb_migrations/1772125706_updated_service_notification_templates.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1572233440") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1572233440") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125715_updated_services.js b/server/pb_migrations/1772125715_updated_services.js new file mode 100644 index 0000000..64b7ef1 --- /dev/null +++ b/server/pb_migrations/1772125715_updated_services.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_863811952") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125722_updated_services_metrics.js b/server/pb_migrations/1772125722_updated_services_metrics.js new file mode 100644 index 0000000..9a40537 --- /dev/null +++ b/server/pb_migrations/1772125722_updated_services_metrics.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3575570325") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3575570325") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125729_updated_ssl_certificates.js b/server/pb_migrations/1772125729_updated_ssl_certificates.js new file mode 100644 index 0000000..7ddafe2 --- /dev/null +++ b/server/pb_migrations/1772125729_updated_ssl_certificates.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1836745630") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1836745630") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125736_updated_ssl_history.js b/server/pb_migrations/1772125736_updated_ssl_history.js new file mode 100644 index 0000000..6cb321f --- /dev/null +++ b/server/pb_migrations/1772125736_updated_ssl_history.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1753185970") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1753185970") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125744_updated_ssl_notification_templates.js b/server/pb_migrations/1772125744_updated_ssl_notification_templates.js new file mode 100644 index 0000000..1d1c15a --- /dev/null +++ b/server/pb_migrations/1772125744_updated_ssl_notification_templates.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_351174173") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_351174173") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125752_updated_status_page_components.js b/server/pb_migrations/1772125752_updated_status_page_components.js new file mode 100644 index 0000000..982cdf7 --- /dev/null +++ b/server/pb_migrations/1772125752_updated_status_page_components.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1636150439") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1636150439") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125758_updated_tcp_data.js b/server/pb_migrations/1772125758_updated_tcp_data.js new file mode 100644 index 0000000..e1c471d --- /dev/null +++ b/server/pb_migrations/1772125758_updated_tcp_data.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2331916230") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2331916230") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772125766_updated_uptime_data.js b/server/pb_migrations/1772125766_updated_uptime_data.js new file mode 100644 index 0000000..882c962 --- /dev/null +++ b/server/pb_migrations/1772125766_updated_uptime_data.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_67962705") + + // update collection data + unmarshal({ + "createRule": "@request.auth.id != \"\"", + "deleteRule": "@request.auth.id != \"\"", + "listRule": "@request.auth.id != \"\"", + "updateRule": "@request.auth.id != \"\"", + "viewRule": "@request.auth.id != \"\"" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_67962705") + + // update collection data + unmarshal({ + "createRule": "", + "deleteRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/server/pb_migrations/1772200000_updated_alert_configurations_matrix.js b/server/pb_migrations/1772200000_updated_alert_configurations_matrix.js new file mode 100644 index 0000000..02cc1a2 --- /dev/null +++ b/server/pb_migrations/1772200000_updated_alert_configurations_matrix.js @@ -0,0 +1,73 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + // add matrix_homeserver field + collection.fields.addAt(collection.fields.length, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text_matrix_homeserver", + "max": 0, + "min": 0, + "name": "matrix_homeserver", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add matrix_room_id field + collection.fields.addAt(collection.fields.length, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text_matrix_room_id", + "max": 0, + "min": 0, + "name": "matrix_room_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add matrix_access_token field + collection.fields.addAt(collection.fields.length, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text_matrix_access_token", + "max": 0, + "min": 0, + "name": "matrix_access_token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + // add "matrix" to the notification_type select field values + const notifTypeField = collection.fields.getByName("notification_type") + if (notifTypeField && notifTypeField.values) { + notifTypeField.values.push("matrix") + } + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1938176441") + + collection.fields.removeById("text_matrix_homeserver") + collection.fields.removeById("text_matrix_room_id") + collection.fields.removeById("text_matrix_access_token") + + const notifTypeField = collection.fields.getByName("notification_type") + if (notifTypeField && notifTypeField.values) { + notifTypeField.values = notifTypeField.values.filter(v => v !== "matrix") + } + + return app.save(collection) +}) diff --git a/server/pb_migrations/pb_schema.json b/server/pb_migrations/pb_schema.json new file mode 100644 index 0000000..bc77385 --- /dev/null +++ b/server/pb_migrations/pb_schema.json @@ -0,0 +1,6678 @@ +[ + { + "id": "pbc_3142635823", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "_superusers", + "type": "auth", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "hidden": true, + "id": "text2504183744", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": null, + "hidden": false, + "id": "email3885137012", + "name": "email", + "onlyDomains": null, + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3687080900", + "max": 0, + "min": 0, + "name": "full_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4166911607", + "max": 0, + "min": 0, + "name": "username", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "active", + "inactive" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_tokenKey_pbc_3142635823` ON `_superusers` (`tokenKey`)", + "CREATE UNIQUE INDEX `idx_email_pbc_3142635823` ON `_superusers` (`email`) WHERE `email` != ''" + ], + "system": true, + "authRule": "", + "manageRule": null, + "authAlert": { + "enabled": true, + "emailTemplate": { + "subject": "Login from a new location", + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "oauth2": { + "mappedFields": { + "id": "", + "name": "", + "username": "", + "avatarURL": "" + }, + "enabled": false + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "mfa": { + "enabled": false, + "duration": 1800, + "rule": "" + }, + "otp": { + "enabled": false, + "duration": 180, + "length": 8, + "emailTemplate": { + "subject": "OTP for {APP_NAME}", + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "authToken": { + "duration": 86400 + }, + "passwordResetToken": { + "duration": 1800 + }, + "emailChangeToken": { + "duration": 1800 + }, + "verificationToken": { + "duration": 259200 + }, + "fileToken": { + "duration": 180 + }, + "verificationTemplate": { + "subject": "Verify your {APP_NAME} email", + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "resetPasswordTemplate": { + "subject": "Reset your {APP_NAME} password", + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "confirmEmailChangeTemplate": { + "subject": "Confirm your {APP_NAME} new email address", + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + { + "id": "_pb_users_auth_", + "listRule": "id = @request.auth.id", + "viewRule": "id = @request.auth.id", + "createRule": "", + "updateRule": "id = @request.auth.id", + "deleteRule": "id = @request.auth.id", + "name": "users", + "type": "auth", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "hidden": true, + "id": "text2504183744", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": null, + "hidden": false, + "id": "email3885137012", + "name": "email", + "onlyDomains": null, + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 255, + "min": 0, + "name": "full_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "file376926767", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [ + "image/jpeg", + "image/png", + "image/svg+xml", + "image/gif", + "image/webp" + ], + "name": "avatar", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": null, + "type": "file" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4166911607", + "max": 0, + "min": 0, + "name": "username", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_tokenKey__pb_users_auth_` ON `users` (`tokenKey`)", + "CREATE UNIQUE INDEX `idx_email__pb_users_auth_` ON `users` (`email`) WHERE `email` != ''" + ], + "system": false, + "authRule": "", + "manageRule": null, + "authAlert": { + "enabled": true, + "emailTemplate": { + "subject": "Login from a new location", + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "oauth2": { + "mappedFields": { + "id": "", + "name": "", + "username": "", + "avatarURL": "avatar" + }, + "enabled": false + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "mfa": { + "enabled": false, + "duration": 1800, + "rule": "" + }, + "otp": { + "enabled": false, + "duration": 180, + "length": 8, + "emailTemplate": { + "subject": "OTP for {APP_NAME}", + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + "authToken": { + "duration": 604800 + }, + "passwordResetToken": { + "duration": 1800 + }, + "emailChangeToken": { + "duration": 1800 + }, + "verificationToken": { + "duration": 259200 + }, + "fileToken": { + "duration": 180 + }, + "verificationTemplate": { + "subject": "Verify your {APP_NAME} email", + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "resetPasswordTemplate": { + "subject": "Reset your {APP_NAME} password", + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + }, + "confirmEmailChangeTemplate": { + "subject": "Confirm your {APP_NAME} new email address", + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

" + } + }, + { + "id": "pbc_4275539003", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "name": "_authOrigins", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4228609354", + "max": 0, + "min": 0, + "name": "fingerprint", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_authOrigins_unique_pairs` ON `_authOrigins` (collectionRef, recordRef, fingerprint)" + ], + "system": true + }, + { + "id": "pbc_2281828961", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "name": "_externalAuths", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2462348188", + "max": 0, + "min": 0, + "name": "provider", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1044722854", + "max": 0, + "min": 0, + "name": "providerId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_externalAuths_record_provider` ON `_externalAuths` (collectionRef, recordRef, provider)", + "CREATE UNIQUE INDEX `idx_externalAuths_collection_provider` ON `_externalAuths` (collectionRef, provider, providerId)" + ], + "system": true + }, + { + "id": "pbc_2279338944", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "_mfas", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1582905952", + "max": 0, + "min": 0, + "name": "method", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX `idx_mfas_collectionRef_recordRef` ON `_mfas` (collectionRef,recordRef)" + ], + "system": true + }, + { + "id": "pbc_1638494021", + "listRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "viewRule": "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "_otps", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text455797646", + "max": 0, + "min": 0, + "name": "collectionRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text127846527", + "max": 0, + "min": 0, + "name": "recordRef", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 8, + "hidden": true, + "id": "password901924565", + "max": 0, + "min": 0, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "", + "hidden": true, + "id": "text3866985172", + "max": 0, + "min": 0, + "name": "sentTo", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX `idx_otps_collectionRef_recordRef` ON `_otps` (collectionRef, recordRef)" + ], + "system": true + }, + { + "id": "pbc_1938176441", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "alert_configurations", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "channel_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1104941267", + "max": 0, + "min": 0, + "name": "telegram_chat_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2654632018", + "max": 0, + "min": 0, + "name": "discord_webhook_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text904114323", + "max": 0, + "min": 0, + "name": "signal_number", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text709732016", + "max": 0, + "min": 0, + "name": "notify_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3787265959", + "max": 0, + "min": 0, + "name": "bot_token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2946855043", + "max": 0, + "min": 0, + "name": "slack_webhook_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select887233555", + "maxSelect": 1, + "name": "notification_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "telegram", + "signal", + "discord", + "slack", + "webhook", + "google_chat", + "email", + "ntfy", + "pushover", + "notifiarr" + ] + }, + { + "hidden": false, + "id": "select1358543748", + "maxSelect": 1, + "name": "enabled", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "true", + "false" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2962097998", + "max": 0, + "min": 0, + "name": "email_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3070882914", + "max": 0, + "min": 0, + "name": "email_sender_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2941943271", + "max": 0, + "min": 0, + "name": "smtp_port", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text393471132", + "max": 0, + "min": 0, + "name": "smtp_server", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2735050465", + "max": 0, + "min": 0, + "name": "google_chat_webhook_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1263996916", + "max": 0, + "min": 0, + "name": "signal_api_endpoint", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4212033756", + "max": 0, + "min": 0, + "name": "smtp_password", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text137129747", + "max": 0, + "min": 0, + "name": "webhook_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3590941513", + "max": 0, + "min": 0, + "name": "webhook_payload_template", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1654359675", + "max": 0, + "min": 0, + "name": "ntfy_endpoint", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2074277355", + "max": 0, + "min": 0, + "name": "api_token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1636223522", + "max": 0, + "min": 0, + "name": "user_key", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2889059272", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "data_settings", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "number1569706847", + "max": null, + "min": null, + "name": "retention_days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1419719274", + "max": null, + "min": null, + "name": "server_retention_days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3508549248", + "max": null, + "min": null, + "name": "uptime_retention_days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2800013036", + "max": 0, + "min": 0, + "name": "backup", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_285611395", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "dns_data", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2554192817", + "max": 0, + "min": 0, + "name": "query_type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3731179111", + "max": 0, + "min": 0, + "name": "resolve_ip", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2323266619", + "max": 0, + "min": 0, + "name": "msg_size", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3069659470", + "max": 0, + "min": 0, + "name": "question", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3671935525", + "max": 0, + "min": 0, + "name": "answer", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1257859836", + "max": 0, + "min": 0, + "name": "authority", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2273667377", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3168803865", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "docker_metrics", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "docker_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text933879452", + "max": 0, + "min": 0, + "name": "ram_total", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4038915663", + "max": 0, + "min": 0, + "name": "ram_used", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3302315167", + "max": 0, + "min": 0, + "name": "ram_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1461425251", + "max": 0, + "min": 0, + "name": "cpu_cores", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2062996636", + "max": 0, + "min": 0, + "name": "cpu_usage", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1525183142", + "max": 0, + "min": 0, + "name": "cpu_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2583550350", + "max": 0, + "min": 0, + "name": "disk_total", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1976676007", + "max": 0, + "min": 0, + "name": "disk_used", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1102580855", + "max": 0, + "min": 0, + "name": "disk_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1936777896", + "max": null, + "min": null, + "name": "network_rx_bytes", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3038767919", + "max": null, + "min": null, + "name": "network_tx_bytes", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3452919696", + "max": null, + "min": null, + "name": "network_rx_speed", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number195057175", + "max": null, + "min": null, + "name": "network_tx_speed", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_583547467", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "dockers", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1069831160", + "max": 0, + "min": 0, + "name": "docker_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3847340049", + "max": 0, + "min": 0, + "name": "hostname", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text587191692", + "max": 0, + "min": 0, + "name": "ip_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063420466", + "max": 0, + "min": 0, + "name": "os_template", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1563400775", + "max": 0, + "min": 0, + "name": "uptime", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number933879452", + "max": null, + "min": null, + "name": "ram_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number4038915663", + "max": null, + "min": null, + "name": "ram_used", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1461425251", + "max": null, + "min": null, + "name": "cpu_cores", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2062996636", + "max": null, + "min": null, + "name": "cpu_usage", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2583550350", + "max": null, + "min": null, + "name": "disk_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1976676007", + "max": null, + "min": null, + "name": "disk_used", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date3415181003", + "max": "", + "min": "", + "name": "last_checked", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2782324286", + "max": 0, + "min": 0, + "name": "timestamp", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2189087560", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "incidents", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2314121105", + "max": 0, + "min": 0, + "name": "assigned_users", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3210298530", + "max": 0, + "min": 0, + "name": "resolution_time", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text723782963", + "max": 0, + "min": 0, + "name": "affected_systems", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1210058505", + "max": 0, + "min": 0, + "name": "root_cause", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text178040949", + "max": 0, + "min": 0, + "name": "resolution_steps", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2767280054", + "max": 0, + "min": 0, + "name": "lessons_learned", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4053573849", + "max": 0, + "min": 0, + "name": "operational_status_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select2722347397", + "maxSelect": 1, + "name": "impact_status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "investigating", + "found_root_cause", + "identified", + "monitoring", + "resolved" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1655102503", + "maxSelect": 1, + "name": "priority", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + { + "hidden": false, + "id": "select3288973319", + "maxSelect": 1, + "name": "impact", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "minor", + "major", + "critical", + "none" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1738231158", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "maintenance", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date1345189255", + "max": "", + "min": "", + "name": "start_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date1096160257", + "max": "", + "min": "", + "name": "end_time", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3922189066", + "max": 0, + "min": 0, + "name": "affected", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3725765462", + "max": 0, + "min": 0, + "name": "created_by", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1348078739", + "max": 0, + "min": 0, + "name": "notify_subscribers", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1655102503", + "maxSelect": 1, + "name": "priority", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "scheduled", + "completed", + "cancelled", + "in_progress" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4053573849", + "max": 0, + "min": 0, + "name": "operational_status_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1542800728", + "maxSelect": 1, + "name": "field", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "minor", + "major", + "critical", + "none" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3490420651", + "max": 0, + "min": 0, + "name": "assigned_users", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3662317132", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "operational_page", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1811784642", + "maxSelect": 1, + "name": "is_public", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "true", + "false" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2560465762", + "max": 0, + "min": 0, + "name": "slug", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2541086472", + "max": 0, + "min": 0, + "name": "theme", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text156371623", + "max": 0, + "min": 0, + "name": "logo_url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2466564623", + "max": 0, + "min": 0, + "name": "custom_domain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3581369753", + "max": 0, + "min": 0, + "name": "custom_css", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1430013884", + "max": 0, + "min": 0, + "name": "page_style", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "operational", + "degraded", + "partial_outage", + "major_outage", + "maintenance" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1168766540", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "ping_data", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1341404999", + "max": 0, + "min": 0, + "name": "packet_loss", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text926446584", + "max": 0, + "min": 0, + "name": "latency", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2750318623", + "max": 0, + "min": 0, + "name": "max_rtt", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2645286934", + "max": 0, + "min": 0, + "name": "min_rtt", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text108668494", + "max": 0, + "min": 0, + "name": "packets_sent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3848691274", + "max": 0, + "min": 0, + "name": "packets_recv", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2665772979", + "max": 0, + "min": 0, + "name": "avg_rtt", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text836103155", + "max": 0, + "min": 0, + "name": "rtts", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_171698555", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "regional_service", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1404634243", + "max": 0, + "min": 0, + "name": "agent_ip_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text704082790", + "max": 0, + "min": 0, + "name": "connection", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1597481275", + "max": 0, + "min": 0, + "name": "token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1998570700", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "server_metrics", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text933879452", + "max": 0, + "min": 0, + "name": "ram_total", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4038915663", + "max": 0, + "min": 0, + "name": "ram_used", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3302315167", + "max": 0, + "min": 0, + "name": "ram_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1461425251", + "max": 0, + "min": 0, + "name": "cpu_cores", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2062996636", + "max": 0, + "min": 0, + "name": "cpu_usage", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1525183142", + "max": 0, + "min": 0, + "name": "cpu_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2583550350", + "max": 0, + "min": 0, + "name": "disk_total", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1976676007", + "max": 0, + "min": 0, + "name": "disk_used", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1102580855", + "max": 0, + "min": 0, + "name": "disk_free", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1936777896", + "max": null, + "min": null, + "name": "network_rx_bytes", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3038767919", + "max": null, + "min": null, + "name": "network_tx_bytes", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3452919696", + "max": null, + "min": null, + "name": "network_rx_speed", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number195057175", + "max": null, + "min": null, + "name": "network_tx_speed", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text635908666", + "max": 0, + "min": 0, + "name": "agent_status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1907735298", + "max": 0, + "min": 0, + "name": "max_retries", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3065852031", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text772177811", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1965843930", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "server_notification_templates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2233324553", + "max": 0, + "min": 0, + "name": "ram_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3462525847", + "max": 0, + "min": 0, + "name": "cpu_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3499937189", + "max": 0, + "min": 0, + "name": "disk_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3897838783", + "max": 0, + "min": 0, + "name": "network_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2761878292", + "max": 0, + "min": 0, + "name": "up_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3025025370", + "max": 0, + "min": 0, + "name": "down_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2718833336", + "max": 0, + "min": 0, + "name": "paused_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3489289583", + "max": 0, + "min": 0, + "name": "cpu_temp_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2170408196", + "max": 0, + "min": 0, + "name": "disk_io_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4125531906", + "max": 0, + "min": 0, + "name": "placeholder", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1005957295", + "max": 0, + "min": 0, + "name": "warning_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text992877620", + "max": 0, + "min": 0, + "name": "restore_ram_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2334264704", + "max": 0, + "min": 0, + "name": "restore_cpu_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text808080362", + "max": 0, + "min": 0, + "name": "restore_disk_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2722458077", + "max": 0, + "min": 0, + "name": "restore_network_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text823542732", + "max": 0, + "min": 0, + "name": "restore_disk_io_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1526027363", + "max": 0, + "min": 0, + "name": "restore_cpu_temp_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1464856393", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "server_threshold_templates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2233324553", + "max": 0, + "min": 0, + "name": "ram_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3462525847", + "max": 0, + "min": 0, + "name": "cpu_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3499937189", + "max": 0, + "min": 0, + "name": "disk_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3897838783", + "max": 0, + "min": 0, + "name": "network_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3837604842", + "max": 0, + "min": 0, + "name": "disk_io_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2623591511", + "max": 0, + "min": 0, + "name": "cpu_temp_threshold", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3738798621", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "servers", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3847340049", + "max": 0, + "min": 0, + "name": "hostname", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text587191692", + "max": 0, + "min": 0, + "name": "ip_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063420466", + "max": 0, + "min": 0, + "name": "os_type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "up", + "down", + "warning", + "paused" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1563400775", + "max": 0, + "min": 0, + "name": "uptime", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number933879452", + "max": null, + "min": null, + "name": "ram_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number4038915663", + "max": null, + "min": null, + "name": "ram_used", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1461425251", + "max": null, + "min": null, + "name": "cpu_cores", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2062996636", + "max": null, + "min": null, + "name": "cpu_usage", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number2583550350", + "max": null, + "min": null, + "name": "disk_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1976676007", + "max": null, + "min": null, + "name": "disk_used", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date3415181003", + "max": "", + "min": "", + "name": "last_checked", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4290167602", + "max": 0, + "min": 0, + "name": "server_token", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2782324286", + "max": 0, + "min": 0, + "name": "timestamp", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text704082790", + "max": 0, + "min": 0, + "name": "connection", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text635908666", + "max": 0, + "min": 0, + "name": "agent_status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3661988513", + "max": 0, + "min": 0, + "name": "system_info", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1936777896", + "max": 0, + "min": 0, + "name": "network_rx_bytes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3038767919", + "max": 0, + "min": 0, + "name": "network_tx_bytes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3452919696", + "max": 0, + "min": 0, + "name": "network_rx_speed", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text195057175", + "max": 0, + "min": 0, + "name": "network_tx_speed", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number4097339557", + "max": null, + "min": null, + "name": "check_interval", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "select4065722285", + "maxSelect": 1, + "name": "docker", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "true", + "false" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1907735298", + "max": 0, + "min": 0, + "name": "max_retries", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1080317331", + "max": 0, + "min": 0, + "name": "threshold_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1553704459", + "max": 0, + "min": 0, + "name": "webhook_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool3215432513", + "name": "notification_status", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_763794515", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "service_group", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1572233440", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "service_notification_templates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2761878292", + "max": 0, + "min": 0, + "name": "up_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3025025370", + "max": 0, + "min": 0, + "name": "down_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3477829101", + "max": 0, + "min": 0, + "name": "maintenance_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text293022370", + "max": 0, + "min": 0, + "name": "incident_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3490715516", + "max": 0, + "min": 0, + "name": "resolved_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1005957295", + "max": 0, + "min": 0, + "name": "warning_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4125531906", + "max": 0, + "min": 0, + "name": "placeholder", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_863811952", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "services", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3475444733", + "max": 0, + "min": 0, + "name": "host", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1563400775", + "max": null, + "min": null, + "name": "uptime", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date3415181003", + "max": "", + "min": "", + "name": "last_checked", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number1133600204", + "max": null, + "min": null, + "name": "port", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2812878347", + "max": 0, + "min": 0, + "name": "domain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number3142844404", + "max": null, + "min": null, + "name": "heartbeat_interval", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1907735298", + "max": null, + "min": null, + "name": "max_retries", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1117643717", + "maxSelect": 1, + "name": "service_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "http", + "tcp", + "ping", + "dns" + ] + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "up", + "down", + "warning", + "paused" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select3434705756", + "maxSelect": 1, + "name": "alerts", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "muted", + "unmuted" + ] + }, + { + "hidden": false, + "id": "bool3215432513", + "name": "notification_status", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1745692878", + "max": 0, + "min": 0, + "name": "regional_status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text318214333", + "max": 0, + "min": 0, + "name": "status_codes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2502838439", + "max": 0, + "min": 0, + "name": "keyword", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1841317061", + "max": 0, + "min": 0, + "name": "group_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1553704459", + "max": 0, + "min": 0, + "name": "webhook_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3575570325", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "services_metrics", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2422262506", + "max": 0, + "min": 0, + "name": "service_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3475444733", + "max": 0, + "min": 0, + "name": "host", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number1563400775", + "max": null, + "min": null, + "name": "uptime", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "date3415181003", + "max": "", + "min": "", + "name": "last_checked", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number1133600204", + "max": null, + "min": null, + "name": "port", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2812878347", + "max": 0, + "min": 0, + "name": "domain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number3142844404", + "max": null, + "min": null, + "name": "heartbeat_interval", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number1907735298", + "max": null, + "min": null, + "name": "max_retries", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1117643717", + "maxSelect": 1, + "name": "service_type", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "http", + "tcp", + "ping", + "dns" + ] + }, + { + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "up", + "down", + "warning", + "paused" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select4152017003", + "maxSelect": 1, + "name": "alerts", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "muted", + "unmuted" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text318214333", + "max": 0, + "min": 0, + "name": "status_codes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1519612219", + "max": 0, + "min": 0, + "name": "keyword", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "region_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1404634243", + "max": 0, + "min": 0, + "name": "agent_ip_address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1836745630", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "ssl_certificates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2812878347", + "max": 0, + "min": 0, + "name": "domain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2910474005", + "max": 0, + "min": 0, + "name": "issuer_o", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2857577546", + "max": "", + "min": "", + "name": "last_notified", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number2281365985", + "max": null, + "min": null, + "name": "warning_threshold", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number219021052", + "max": null, + "min": null, + "name": "expiry_threshold", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3085370608", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2077745078", + "max": "", + "min": "", + "name": "valid_from", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number227839714", + "max": null, + "min": null, + "name": "serial_number", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2790380089", + "max": 0, + "min": 0, + "name": "issued_to", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date3449712486", + "max": "", + "min": "", + "name": "valid_till", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number1533826704", + "max": null, + "min": null, + "name": "validity_days", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number4007784154", + "max": null, + "min": null, + "name": "days_left", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "number437829758", + "max": null, + "min": null, + "name": "valid_days_to_expire", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3452125334", + "max": 0, + "min": 0, + "name": "resolved_ip", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2712104570", + "max": 0, + "min": 0, + "name": "issuer_cn", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1754636037", + "max": 0, + "min": 0, + "name": "cert_alg", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1208359189", + "max": 0, + "min": 0, + "name": "cert_sans", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4097339557", + "max": 0, + "min": 0, + "name": "check_interval", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2784829323", + "max": "", + "min": "", + "name": "check_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1553704459", + "max": 0, + "min": 0, + "name": "webhook_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1753185970", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "ssl_history", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2812878347", + "max": 0, + "min": 0, + "name": "cert_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1881225410", + "max": 0, + "min": 0, + "name": "days_before_expiry", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3062427817", + "max": 0, + "min": 0, + "name": "days_before_warning", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text98176952", + "max": 0, + "min": 0, + "name": "template_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "notification_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select3839288252", + "maxSelect": 1, + "name": "warning_status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "sent", + "pending", + "failed" + ] + }, + { + "hidden": false, + "id": "select2670434693", + "maxSelect": 1, + "name": "expiry_status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "sent", + "pending", + "failed" + ] + }, + { + "hidden": false, + "id": "select3824245652", + "maxSelect": 1, + "name": "critical_status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "sent", + "pending", + "failed" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_351174173", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "ssl_notification_templates", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2761878292", + "max": 0, + "min": 0, + "name": "expired", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3025025370", + "max": 0, + "min": 0, + "name": "exiring_soon", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1078893766", + "max": 0, + "min": 0, + "name": "warning", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4011498884", + "max": 0, + "min": 0, + "name": "placeholder", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_1636150439", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "status_page_components", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4053573849", + "max": 0, + "min": 0, + "name": "operational_status_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text407168695", + "max": 0, + "min": 0, + "name": "server_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number4101605491", + "max": null, + "min": null, + "name": "display_order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2331916230", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "tcp_data", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1341404999", + "max": 0, + "min": 0, + "name": "connection", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text926446584", + "max": 0, + "min": 0, + "name": "latency", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2770808759", + "max": 0, + "min": 0, + "name": "port", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2273667377", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text873754891", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_67962705", + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "", + "name": "uptime_data", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3982272998", + "max": 0, + "min": 0, + "name": "service_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2782324286", + "max": "", + "min": "", + "name": "timestamp", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "number3275068127", + "max": null, + "min": null, + "name": "response_time", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2063623452", + "max": 0, + "min": 0, + "name": "status", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1280981898", + "max": 0, + "min": 0, + "name": "packets", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text926446584", + "max": 0, + "min": 0, + "name": "latency", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text318214333", + "max": 0, + "min": 0, + "name": "status_codes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1519612219", + "max": 0, + "min": 0, + "name": "keyword", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text737763667", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1915095946", + "max": 0, + "min": 0, + "name": "details", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text258142582", + "max": 0, + "min": 0, + "name": "region_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2552627541", + "max": 0, + "min": 0, + "name": "agent_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_45665081", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "webhook", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2809058197", + "max": 0, + "min": 0, + "name": "user_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4101391790", + "max": 0, + "min": 0, + "name": "url", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select1358543748", + "maxSelect": 1, + "name": "enabled", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "on", + "off" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1554180325", + "max": 0, + "min": 0, + "name": "secret", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4144189317", + "max": 0, + "min": 0, + "name": "headers", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2129530235", + "max": 0, + "min": 0, + "name": "retry_count", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2168550802", + "max": 0, + "min": 0, + "name": "trigger_events", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1582905952", + "max": 0, + "min": 0, + "name": "method", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1414600993", + "max": 0, + "min": 0, + "name": "payload_template", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + } +] diff --git a/server/pocketbase b/server/pocketbase new file mode 100755 index 0000000..db9958f Binary files /dev/null and b/server/pocketbase differ diff --git a/server/service-operation/.env b/server/service-operation/.env new file mode 100644 index 0000000..acfcbf0 --- /dev/null +++ b/server/service-operation/.env @@ -0,0 +1,16 @@ + +# Port configuration +PORT=8091 + +# Operation defaults +DEFAULT_COUNT=4 +DEFAULT_TIMEOUT=15s +MAX_COUNT=20 +MAX_TIMEOUT=30s + +# Logging +ENABLE_LOGGING=true + +# PocketBase integration (no authentication required) +POCKETBASE_ENABLED=true +POCKETBASE_URL=http://localhost:8090 \ No newline at end of file diff --git a/server/service-operation/.env.example b/server/service-operation/.env.example new file mode 100644 index 0000000..17ab128 --- /dev/null +++ b/server/service-operation/.env.example @@ -0,0 +1,16 @@ + +# Port configuration +PORT=8091 + +# Operation defaults +DEFAULT_COUNT=4 +DEFAULT_TIMEOUT=10s +MAX_COUNT=20 +MAX_TIMEOUT=30s + +# Logging +ENABLE_LOGGING=true + +# PocketBase integration (no authentication required) +POCKETBASE_ENABLED=true +POCKETBASE_URL=http://localhost:8090 \ No newline at end of file diff --git a/server/service-operation/README.md b/server/service-operation/README.md new file mode 100644 index 0000000..16ad691 --- /dev/null +++ b/server/service-operation/README.md @@ -0,0 +1,131 @@ + +# CheckCle Microservice Operation + +A Go-based microservice for service operations including ICMP ping, DNS resolution, and TCP connectivity testing. + +## Features + +- **ICMP Ping**: Full ping functionality with packet statistics +- **DNS Resolution**: A, AAAA, MX, and TXT record lookups +- **TCP Connectivity**: Port connectivity testing +- **SSL Certificate**: SSL Certificate Check +- REST API endpoints +- Health check endpoint +- Configurable via environment variables +- Comprehensive operation statistics + +## API Endpoints + +### POST /operation +Perform various network operations (ping, dns, tcp). + +**Ping Request:** +```json +{ + "type": "ping", + "host": "google.com", + "count": 4, + "timeout": 3 +} +``` + +**DNS Request:** +```json +{ + "type": "dns", + "host": "google.com", + "query": "A", + "timeout": 3 +} +``` + +**TCP Request:** +```json +{ + "type": "tcp", + "host": "google.com", + "port": 443, + "timeout": 3 +} +``` + +**Response:** +```json +{ + "type": "ping", + "host": "google.com", + "success": true, + "response_time": "20ms", + "packets_sent": 4, + "packets_recv": 4, + "packet_loss": 0, + "min_rtt": "15ms", + "max_rtt": "25ms", + "avg_rtt": "20ms", + "rtts": ["15ms", "20ms", "25ms", "18ms"], + "start_time": "2023-12-01T10:00:00Z", + "end_time": "2023-12-01T10:00:03Z" +} +``` + +### GET /operation/quick +Quick operation test with query parameters. + +**Examples:** +- `/operation/quick?type=ping&host=google.com&count=1` +- `/operation/quick?type=dns&host=google.com&query=A` +- `/operation/quick?type=tcp&host=google.com&port=443` + +### GET /health +Health check endpoint. + +### Legacy Endpoints +- `POST /ping` - Legacy ping endpoint (backward compatibility) +- `GET /ping/quick` - Legacy quick ping endpoint + +## Operation Types + +### Ping (ICMP) +- **Type**: `ping` +- **Parameters**: `host`, `count`, `timeout` +- **Features**: Packet loss calculation, RTT statistics, multiple packets + +### DNS Resolution +- **Type**: `dns` +- **Parameters**: `host`, `query` (A, AAAA, MX, TXT), `timeout` +- **Features**: Multiple record types, resolution time tracking + +### TCP Connectivity +- **Type**: `tcp` +- **Parameters**: `host`, `port`, `timeout` +- **Features**: Connection testing, response time measurement + +## Configuration + +Environment variables: + +- `PORT` - Service port (default: 8080) +- `DEFAULT_COUNT` - Default ping count (default: 4) +- `DEFAULT_TIMEOUT` - Default timeout (default: 3s) +- `MAX_COUNT` - Maximum ping count (default: 20) +- `MAX_TIMEOUT` - Maximum timeout (default: 30s) +- `ENABLE_LOGGING` - Enable logging (default: true) + +## Running + +### Local Development +```bash +go run main.go +``` +## Requirements + +- Go 1.21+ +- Root privileges for ICMP (on Linux) + +## Note + +This service requires elevated privileges to send ICMP packets. Run with sudo on Linux systems or use capabilities: + +```bash +sudo setcap cap_net_raw=+ep ./service-operation +``` diff --git a/server/service-operation/config/config.go b/server/service-operation/config/config.go new file mode 100644 index 0000000..452952d --- /dev/null +++ b/server/service-operation/config/config.go @@ -0,0 +1,71 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +type Config struct { + Port string + DefaultCount int + DefaultTimeout time.Duration + MaxCount int + MaxTimeout time.Duration + EnableLogging bool + + // PocketBase configuration (no auth required) + PocketBaseEnabled bool + PocketBaseURL string +} + +func Load() *Config { + cfg := &Config{ + Port: getEnv("PORT", "8091"), + DefaultCount: getEnvInt("DEFAULT_COUNT", 4), + DefaultTimeout: getEnvDuration("DEFAULT_TIMEOUT", 15*time.Second), + MaxCount: getEnvInt("MAX_COUNT", 20), + MaxTimeout: getEnvDuration("MAX_TIMEOUT", 30*time.Second), + EnableLogging: getEnvBool("ENABLE_LOGGING", true), + + // PocketBase settings (no credentials needed) + PocketBaseEnabled: getEnvBool("POCKETBASE_ENABLED", true), + PocketBaseURL: getEnv("POCKETBASE_URL", ""), + } + + return cfg +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + +func getEnvDuration(key string, defaultValue time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + if boolValue, err := strconv.ParseBool(value); err == nil { + return boolValue + } + } + return defaultValue +} \ No newline at end of file diff --git a/server/service-operation/data-retention/handler.go b/server/service-operation/data-retention/handler.go new file mode 100644 index 0000000..514db3d --- /dev/null +++ b/server/service-operation/data-retention/handler.go @@ -0,0 +1,59 @@ +package dataretention + +import ( + "encoding/json" + "net/http" + "service-operation/pocketbase" +) + +// Handler handles HTTP requests for data retention operations +type Handler struct { + service *Service +} + +// NewHandler creates a new data retention handler +func NewHandler(pbClient *pocketbase.PocketBaseClient) *Handler { + return &Handler{ + service: NewService(pbClient), + } +} + +// GetSettings handles GET requests for data retention settings +func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + settings, err := h.service.GetDataSettings() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(settings) +} + +// TriggerCleanup handles POST requests to trigger manual cleanup +func (h *Handler) TriggerCleanup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + summary, err := h.service.CleanupOldRecords() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(summary) +} + +// SetupRoutes sets up the HTTP routes for data retention +func (h *Handler) SetupRoutes(mux *http.ServeMux) { + mux.HandleFunc("/data-retention/settings", h.GetSettings) + mux.HandleFunc("/data-retention/cleanup", h.TriggerCleanup) +} \ No newline at end of file diff --git a/server/service-operation/data-retention/scheduler.go b/server/service-operation/data-retention/scheduler.go new file mode 100644 index 0000000..a064cee --- /dev/null +++ b/server/service-operation/data-retention/scheduler.go @@ -0,0 +1,63 @@ +package dataretention + +import ( + "service-operation/pocketbase" + "time" +) + +// Scheduler handles periodic data retention cleanup +type Scheduler struct { + service *Service + interval time.Duration + stopChan chan bool +} + +// NewScheduler creates a new cleanup scheduler +func NewScheduler(pbClient *pocketbase.PocketBaseClient, interval time.Duration) *Scheduler { + return &Scheduler{ + service: NewService(pbClient), + interval: interval, + stopChan: make(chan bool), + } +} + +// Start begins the periodic cleanup process +func (s *Scheduler) Start() { + // log.Printf("Starting data retention scheduler with interval: %v", s.interval) + + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + // Run initial cleanup + go func() { + if err := s.service.ScheduleCleanup(); err != nil { + // log.Printf("Initial cleanup failed: %v", err) + } + }() + + for { + select { + case <-ticker.C: + go func() { + if err := s.service.ScheduleCleanup(); err != nil { + // log.Printf("Scheduled cleanup failed: %v", err) + } + }() + case <-s.stopChan: + // log.Println("Data retention scheduler stopped") + return + } + } +} + +// Stop stops the scheduler +func (s *Scheduler) Stop() { + // log.Println("Stopping data retention scheduler...") + s.stopChan <- true +} + +// RunOnce executes cleanup immediately (for manual triggers) +func (s *Scheduler) RunOnce() (*CleanupSummary, error) { + // log.Println("Running manual data retention cleanup...") + return s.service.CleanupOldRecords() +} \ No newline at end of file diff --git a/server/service-operation/data-retention/service.go b/server/service-operation/data-retention/service.go new file mode 100644 index 0000000..75e224c --- /dev/null +++ b/server/service-operation/data-retention/service.go @@ -0,0 +1,398 @@ +package dataretention + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "service-operation/pocketbase" + "sync" + "time" +) + +// Service handles data retention operations +type Service struct { + pbClient *pocketbase.PocketBaseClient +} + +// NewService creates a new data retention service +func NewService(pbClient *pocketbase.PocketBaseClient) *Service { + return &Service{ + pbClient: pbClient, + } +} + +// GetDataSettings fetches the current data retention settings +func (s *Service) GetDataSettings() (*DataSettings, error) { + resp, err := s.pbClient.GetHTTPClient().Get( + fmt.Sprintf("%s/api/collections/data_settings/records", s.pbClient.GetBaseURL()), + ) + if err != nil { + return nil, fmt.Errorf("failed to fetch data settings: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch data settings, status: %d", resp.StatusCode) + } + + var response DataSettingsResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode data settings response: %w", err) + } + + if len(response.Items) == 0 { + return nil, fmt.Errorf("no data settings found") + } + + return &response.Items[0], nil +} + +// CleanupOldRecords performs cleanup of old records based on retention settings +func (s *Service) CleanupOldRecords() (*CleanupSummary, error) { + startTime := time.Now() + + settings, err := s.GetDataSettings() + if err != nil { + return nil, fmt.Errorf("failed to get data settings: %w", err) + } + + // Check if cleanup should run (only once per day) + if !s.shouldRunCleanup(settings) { + // log.Println("Skipping cleanup - less than 24 hours since last run") + return &CleanupSummary{ + StartTime: startTime, + EndTime: time.Now(), + Duration: 0, + Results: make([]CleanupResult, 0), + }, nil + } + + summary := &CleanupSummary{ + StartTime: startTime, + Results: make([]CleanupResult, 0), + } + + // Define collections and their corresponding retention days + uptimeCollections := []string{"dns_data", "ping_data", "tcp_data", "uptime_data", "services_metrics"} + serverCollections := []string{"server_metrics", "docker_metrics"} + + // Process collections in parallel for better performance + var wg sync.WaitGroup + resultsChan := make(chan CleanupResult, len(uptimeCollections)+len(serverCollections)) + + // Cleanup uptime-related collections in parallel + if settings.UptimeRetentionDays > 0 { + cutoffDate := time.Now().AddDate(0, 0, -settings.UptimeRetentionDays) + // log.Printf("Cleaning uptime collections with %d days retention (cutoff: %s)", + // settings.UptimeRetentionDays, cutoffDate.Format("2006-01-02 15:04:05")) + for _, collection := range uptimeCollections { + wg.Add(1) + go func(col string) { + defer wg.Done() + result := s.cleanupCollection(col, cutoffDate) + resultsChan <- result + }(collection) + } + } else { + // log.Println("Skipping uptime collections cleanup - retention days is 0") + } + + // Cleanup server-related collections in parallel + if settings.ServerRetentionDays > 0 { + cutoffDate := time.Now().AddDate(0, 0, -settings.ServerRetentionDays) + // log.Printf("Cleaning server collections with %d days retention (cutoff: %s)", + // settings.ServerRetentionDays, cutoffDate.Format("2006-01-02 15:04:05")) + for _, collection := range serverCollections { + wg.Add(1) + go func(col string) { + defer wg.Done() + result := s.cleanupCollection(col, cutoffDate) + resultsChan <- result + }(collection) + } + } else { + // log.Println("Skipping server collections cleanup - retention days is 0") + } + + // Wait for all collections to complete and collect results + go func() { + wg.Wait() + close(resultsChan) + }() + + // Collect all results + for result := range resultsChan { + summary.Results = append(summary.Results, result) + summary.TotalDeleted += result.RecordsDeleted + } + + summary.EndTime = time.Now() + summary.Duration = summary.EndTime.Sub(summary.StartTime) + + // Update last cleanup time after successful completion + if err := s.updateLastCleanupTime(); err != nil { + // log.Printf("Warning: Failed to update last cleanup time: %v", err) + } + + // log.Printf("Data retention cleanup completed. Total records deleted: %d, Duration: %v", + // summary.TotalDeleted, summary.Duration) + + return summary, nil +} + +// cleanupCollection deletes records older than the cutoff date from a specific collection +func (s *Service) cleanupCollection(collection string, cutoffDate time.Time) CleanupResult { + result := CleanupResult{ + Collection: collection, + } + + // Format cutoff date for PocketBase filter (RFC3339 format) + cutoffStr := cutoffDate.UTC().Format("2006-01-02T15:04:05.000Z") + + // Get records to delete (balanced batch size for performance vs rate limits) + batchSize := 50 + totalDeleted := 0 + + for { + // Always fetch page 1 since we're deleting records (pagination shifts after deletes) + filter := fmt.Sprintf("created < '%s'", cutoffStr) + encodedFilter := url.QueryEscape(filter) + requestURL := fmt.Sprintf("%s/api/collections/%s/records?page=1&perPage=%d&filter=%s", + s.pbClient.GetBaseURL(), collection, batchSize, encodedFilter) + + resp, err := s.pbClient.GetHTTPClient().Get(requestURL) + if err != nil { + result.Error = fmt.Sprintf("failed to fetch records: %v", err) + return result + } + defer resp.Body.Close() + + // Check if response is successful + if resp.StatusCode != http.StatusOK { + result.Error = fmt.Sprintf("API request failed with status %d for collection %s", resp.StatusCode, collection) + return result + } + + var response struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + TotalItems int `json:"totalItems"` + } + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + result.Error = fmt.Sprintf("failed to decode response for collection %s: %v", collection, err) + return result + } + + if len(response.Items) == 0 { + // log.Printf("No more old records found in collection %s", collection) + break // No more records to delete + } + + // log.Printf("Found %d old records in collection %s (batch), total items in DB: %d", + // len(response.Items), collection, response.TotalItems) + + // Delete records with conservative rate limiting to avoid 429 errors + semaphore := make(chan struct{}, 3) // Further reduced to 3 concurrent deletes + var deleteWg sync.WaitGroup + var mu sync.Mutex + batchDeleted := 0 + rateLimitHit := false + + for _, record := range response.Items { + deleteWg.Add(1) + go func(recordID string) { + defer deleteWg.Done() + semaphore <- struct{}{} // Acquire + defer func() { <-semaphore }() // Release + + deleted := false + retryCount := 0 + maxRetries := 5 + + for !deleted && retryCount < maxRetries { + if err := s.deleteRecord(collection, recordID); err != nil { + if retryCount < maxRetries-1 { + // Progressive backoff with longer delays for rate limits + baseWait := time.Duration(1<= 2 { + // Extra penalty for persistent rate limits + baseWait += time.Duration(retryCount) * 2 * time.Second + } + + mu.Lock() + rateLimitHit = true + mu.Unlock() + + time.Sleep(baseWait) + retryCount++ + continue + } else { + // log.Printf("Failed to delete record %s from %s after %d retries: %v", + // recordID, collection, maxRetries, err) + break + } + } else { + deleted = true + mu.Lock() + batchDeleted++ + mu.Unlock() + } + } + + // Increased delay between deletions with adaptive timing + mu.Lock() + currentRateLimitHit := rateLimitHit + mu.Unlock() + + if currentRateLimitHit { + time.Sleep(150 * time.Millisecond) // Longer delay if rate limits detected + } else { + time.Sleep(75 * time.Millisecond) // Standard delay + } + }(record.ID) + } + + deleteWg.Wait() + totalDeleted += batchDeleted + + // If we got fewer records than batch size, we're done + if len(response.Items) < batchSize { + // log.Printf("Finished processing collection %s - no more old records", collection) + break + } + + // More conservative delay between batches with adaptive timing + if len(response.Items) > 0 { + mu.Lock() + currentRateLimitHit := rateLimitHit + mu.Unlock() + + baseDelay := 300 * time.Millisecond + if currentRateLimitHit { + baseDelay = 1 * time.Second // Much longer delay if rate limits were hit + // log.Printf("Processed batch for collection %s (%d deleted), rate limits detected - waiting longer before next batch...", collection, batchDeleted) + } else { + // log.Printf("Processed batch for collection %s (%d deleted), processing next batch...", collection, batchDeleted) + } + + time.Sleep(baseDelay) + } + } + + result.RecordsDeleted = totalDeleted + + if totalDeleted > 0 { + // log.Printf("Deleted %d records from collection %s", totalDeleted, collection) + } + + return result +} + +// deleteRecord deletes a single record from a collection +func (s *Service) deleteRecord(collection, recordID string) error { + requestURL := fmt.Sprintf("%s/api/collections/%s/records/%s", s.pbClient.GetBaseURL(), collection, recordID) + + req, err := http.NewRequest("DELETE", requestURL, nil) + if err != nil { + return err + } + + resp, err := s.pbClient.GetHTTPClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to delete record, status: %d", resp.StatusCode) + } + + return nil +} + +// ScheduleCleanup runs cleanup based on schedule (can be called periodically) +func (s *Service) ScheduleCleanup() error { + // log.Println("Starting scheduled data retention cleanup...") + + _, err := s.CleanupOldRecords() + if err != nil { + // log.Printf("Scheduled cleanup failed: %v", err) + return err + } + + // log.Printf("Scheduled cleanup completed successfully. Summary: %+v", summary) + return nil +} + +// shouldRunCleanup checks if cleanup should run based on last cleanup time (once per day) +func (s *Service) shouldRunCleanup(settings *DataSettings) bool { + if settings.LastCleanup == "" { + // log.Println("No previous cleanup found, proceeding with cleanup...") + return true + } + + lastCleanup, err := time.Parse(time.RFC3339, settings.LastCleanup) + if err != nil { + // log.Printf("Failed to parse last cleanup time '%s', proceeding with cleanup: %v", settings.LastCleanup, err) + return true + } + + timeSinceLastCleanup := time.Since(lastCleanup) + minInterval := 24 * time.Hour + + if timeSinceLastCleanup < minInterval { + // log.Printf("Last cleanup was %v ago (less than 24h), skipping cleanup", timeSinceLastCleanup) + return false + } + + // log.Printf("Last cleanup was %v ago, proceeding with cleanup", timeSinceLastCleanup) + return true +} + +// updateLastCleanupTime updates the last cleanup timestamp in data settings +func (s *Service) updateLastCleanupTime() error { + settings, err := s.GetDataSettings() + if err != nil { + return fmt.Errorf("failed to get data settings: %w", err) + } + + // Update the last cleanup time + now := time.Now().UTC().Format(time.RFC3339) + + // Create the update payload + updateData := map[string]interface{}{ + "last_cleanup": now, + } + + jsonData, err := json.Marshal(updateData) + if err != nil { + return fmt.Errorf("failed to marshal update data: %w", err) + } + + // Update the record via PocketBase API + requestURL := fmt.Sprintf("%s/api/collections/data_settings/records/%s", s.pbClient.GetBaseURL(), settings.ID) + + req, err := http.NewRequest("PATCH", requestURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create update request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := s.pbClient.GetHTTPClient().Do(req) + if err != nil { + return fmt.Errorf("failed to update last cleanup time: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to update last cleanup time, status: %d", resp.StatusCode) + } + + // log.Printf("Updated last cleanup time to: %s", now) + return nil +} \ No newline at end of file diff --git a/server/service-operation/data-retention/types.go b/server/service-operation/data-retention/types.go new file mode 100644 index 0000000..7ebdb1c --- /dev/null +++ b/server/service-operation/data-retention/types.go @@ -0,0 +1,42 @@ +package dataretention + +import "time" + +// DataSettings represents the data retention configuration from PocketBase +type DataSettings struct { + ID string `json:"id"` + CollectionId string `json:"collectionId"` + CollectionName string `json:"collectionName"` + RetentionDays int `json:"retention_days"` + ServerRetentionDays int `json:"server_retention_days"` + UptimeRetentionDays int `json:"uptime_retention_days"` + LastCleanup string `json:"last_cleanup"` + Backup string `json:"backup"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +// DataSettingsResponse represents the response from PocketBase +type DataSettingsResponse struct { + Items []DataSettings `json:"items"` + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` +} + +// CleanupResult represents the result of a cleanup operation +type CleanupResult struct { + Collection string `json:"collection"` + RecordsDeleted int `json:"records_deleted"` + Error string `json:"error,omitempty"` +} + +// CleanupSummary represents the overall cleanup summary +type CleanupSummary struct { + TotalDeleted int `json:"total_deleted"` + Results []CleanupResult `json:"results"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Duration time.Duration `json:"duration"` +} \ No newline at end of file diff --git a/server/service-operation/go.mod b/server/service-operation/go.mod new file mode 100644 index 0000000..eeb5e5c --- /dev/null +++ b/server/service-operation/go.mod @@ -0,0 +1,10 @@ +module service-operation + +go 1.21 + +require ( + github.com/gorilla/mux v1.8.1 + golang.org/x/net v0.17.0 +) + +require golang.org/x/sys v0.13.0 // indirect diff --git a/server/service-operation/go.sum b/server/service-operation/go.sum new file mode 100644 index 0000000..afa7a8f --- /dev/null +++ b/server/service-operation/go.sum @@ -0,0 +1,6 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/server/service-operation/handlers/health_handler.go b/server/service-operation/handlers/health_handler.go new file mode 100644 index 0000000..7c66df4 --- /dev/null +++ b/server/service-operation/handlers/health_handler.go @@ -0,0 +1,21 @@ + +package handlers + +import ( + "encoding/json" + "net/http" + "time" +) + +func (h *OperationHandler) HandleHealth(w http.ResponseWriter, r *http.Request) { + health := map[string]interface{}{ + "status": "healthy", + "service": "service-operation", + "timestamp": time.Now().Unix(), + "version": "1.0.0", + "operations": []string{"ping", "dns", "tcp", "http"}, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(health) +} \ No newline at end of file diff --git a/server/service-operation/handlers/metrics_handler.go b/server/service-operation/handlers/metrics_handler.go new file mode 100644 index 0000000..52bb09a --- /dev/null +++ b/server/service-operation/handlers/metrics_handler.go @@ -0,0 +1,12 @@ + +package handlers + +import ( + "service-operation/shared/savers" + "service-operation/types" +) + +func (h *OperationHandler) saveMetricsToPocketBase(result *types.OperationResult, serviceID string) { + metricsSaver := savers.NewMetricsSaver(h.pbClient) + metricsSaver.SaveMetricsToPocketBase(result, serviceID) +} \ No newline at end of file diff --git a/server/service-operation/handlers/operation.go b/server/service-operation/handlers/operation.go new file mode 100644 index 0000000..af96744 --- /dev/null +++ b/server/service-operation/handlers/operation.go @@ -0,0 +1,19 @@ + +package handlers + +import ( + "service-operation/config" + "service-operation/pocketbase" +) + +type OperationHandler struct { + config *config.Config + pbClient *pocketbase.PocketBaseClient +} + +func NewOperationHandler(cfg *config.Config, pbClient *pocketbase.PocketBaseClient) *OperationHandler { + return &OperationHandler{ + config: cfg, + pbClient: pbClient, + } +} \ No newline at end of file diff --git a/server/service-operation/handlers/operation_handler.go b/server/service-operation/handlers/operation_handler.go new file mode 100644 index 0000000..1666c85 --- /dev/null +++ b/server/service-operation/handlers/operation_handler.go @@ -0,0 +1,108 @@ + +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "service-operation/operations" + "service-operation/types" +) + +func (h *OperationHandler) HandleOperation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req types.OperationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON payload", http.StatusBadRequest) + return + } + + if req.Host == "" && req.URL == "" { + http.Error(w, "Host or URL is required", http.StatusBadRequest) + return + } + + // Set defaults + if req.Count <= 0 { + req.Count = h.config.DefaultCount + } + if req.Count > h.config.MaxCount { + req.Count = h.config.MaxCount + } + + if req.Timeout <= 0 { + req.Timeout = int(h.config.DefaultTimeout.Seconds()) + } + if time.Duration(req.Timeout)*time.Second > h.config.MaxTimeout { + req.Timeout = int(h.config.MaxTimeout.Seconds()) + } + + timeout := time.Duration(req.Timeout) * time.Second + var result *types.OperationResult + var err error + + switch req.Type { + case types.OperationPing: + pingOp := operations.NewPingOperation(timeout) + result, err = pingOp.Execute(req.Host, req.Count) + + case types.OperationDNS: + dnsOp := operations.NewDNSOperation(timeout) + query := req.Query + if query == "" { + query = "A" + } + result, err = dnsOp.Execute(req.Host, query) + + case types.OperationTCP: + if req.Port <= 0 { + http.Error(w, "Port is required for TCP operations", http.StatusBadRequest) + return + } + tcpOp := operations.NewTCPOperation(timeout) + result, err = tcpOp.Execute(req.Host, req.Port) + + case types.OperationHTTP: + httpOp := operations.NewHTTPOperation(timeout) + url := req.URL + if url == "" { + url = req.Host + } + method := req.Method + if method == "" { + method = "GET" + } + result, err = httpOp.Execute(url, method) + + case types.OperationSSL: + sslOp := operations.NewSSLOperation(timeout) + result, err = sslOp.Execute(req.Host) + + default: + http.Error(w, "Invalid operation type", http.StatusBadRequest) + return + } + + if err != nil { + result = &types.OperationResult{ + Type: req.Type, + Host: req.Host, + Port: req.Port, + Success: false, + Error: err.Error(), + } + } + + // Save metrics to PocketBase if available + if h.pbClient != nil && h.pbClient.IsAuthenticated() { + go h.saveMetricsToPocketBase(result, req.ServiceID) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} \ No newline at end of file diff --git a/server/service-operation/handlers/quick_operation_handler.go b/server/service-operation/handlers/quick_operation_handler.go new file mode 100644 index 0000000..0ee7d25 --- /dev/null +++ b/server/service-operation/handlers/quick_operation_handler.go @@ -0,0 +1,67 @@ + +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "service-operation/types" +) + +func (h *OperationHandler) HandleQuickOperation(w http.ResponseWriter, r *http.Request) { + opType := r.URL.Query().Get("type") + host := r.URL.Query().Get("host") + + if host == "" || opType == "" { + http.Error(w, "Type and host parameters are required", http.StatusBadRequest) + return + } + + req := types.OperationRequest{ + Type: types.OperationType(opType), + Host: host, + } + + // Parse optional parameters + if countStr := r.URL.Query().Get("count"); countStr != "" { + if c, err := strconv.Atoi(countStr); err == nil && c > 0 && c <= h.config.MaxCount { + req.Count = c + } + } + + if portStr := r.URL.Query().Get("port"); portStr != "" { + if p, err := strconv.Atoi(portStr); err == nil && p > 0 && p <= 65535 { + req.Port = p + } + } + + if query := r.URL.Query().Get("query"); query != "" { + req.Query = query + } + + if url := r.URL.Query().Get("url"); url != "" { + req.URL = url + } + + if method := r.URL.Query().Get("method"); method != "" { + req.Method = method + } + + if serviceID := r.URL.Query().Get("service_id"); serviceID != "" { + req.ServiceID = serviceID + } + + // Forward to main handler + reqBody, _ := json.Marshal(req) + r.Body = http.NoBody + r.Method = http.MethodPost + r.Header.Set("Content-Type", "application/json") + + // Create a new request with the body + newReq := r.Clone(r.Context()) + newReq.Body = http.NoBody + newReq.ContentLength = int64(len(reqBody)) + + h.HandleOperation(w, newReq) +} \ No newline at end of file diff --git a/server/service-operation/handlers/utils.go b/server/service-operation/handlers/utils.go new file mode 100644 index 0000000..116731b --- /dev/null +++ b/server/service-operation/handlers/utils.go @@ -0,0 +1,45 @@ + +package handlers + +import ( + "encoding/json" + + "service-operation/types" +) + +func getStatusString(success bool) string { + if success { + return "up" + } + return "down" +} + +func formatResultDetails(result *types.OperationResult) string { + details := map[string]interface{}{ + "type": result.Type, + "response_time": result.ResponseTime, + "start_time": result.StartTime, + "end_time": result.EndTime, + } + + // Add type-specific details + switch result.Type { + case types.OperationPing: + details["packets_sent"] = result.PacketsSent + details["packets_recv"] = result.PacketsRecv + details["packet_loss"] = result.PacketLoss + details["avg_rtt"] = result.AvgRTT + case types.OperationHTTP: + details["status_code"] = result.HTTPStatusCode + details["method"] = result.HTTPMethod + details["content_length"] = result.ContentLength + case types.OperationTCP: + details["tcp_connected"] = result.TCPConnected + case types.OperationDNS: + details["dns_records"] = result.DNSRecords + details["dns_type"] = result.DNSType + } + + jsonData, _ := json.Marshal(details) + return string(jsonData) +} \ No newline at end of file diff --git a/server/service-operation/main.go b/server/service-operation/main.go new file mode 100644 index 0000000..37999aa --- /dev/null +++ b/server/service-operation/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gorilla/mux" + "service-operation/config" + "service-operation/handlers" + "service-operation/monitoring" + "service-operation/pocketbase" + servermonitoring "service-operation/server-monitoring" + sslmonitoring "service-operation/ssl-monitoring" + uptimemonitoring "service-operation/uptime-monitoring" + dataretention "service-operation/data-retention" +) + +func main() { + //log.Println("๐Ÿš€ === STARTING SERVICE OPERATION SERVER ===") + + cfg := config.Load() + //log.Printf("๐Ÿ“‹ Configuration loaded:") + //log.Printf(" - Port: %s", cfg.Port) + //log.Printf(" - Backend PB Enabled: %t", cfg.PocketBaseEnabled) + if cfg.PocketBaseEnabled { + //log.Printf(" - PocketBase URL: %s", cfg.PocketBaseURL) + } + + // Initialize PocketBase client (no credentials required) + var pbClient *pocketbase.PocketBaseClient + var monitoringService *monitoring.MonitoringService + var sslMonitoringService *monitoring.SSLMonitoringService + var sslNotificationService *sslmonitoring.SSLMonitor + var serverMonitoringService *servermonitoring.ServerMonitoringService + var uptimeMonitoringService *uptimemonitoring.UptimeMonitor + var dataRetentionScheduler *dataretention.Scheduler + + if cfg.PocketBaseEnabled { + //log.Println("๐Ÿ”ง Initializing PocketBase client...") + var err error + pbClient, err = pocketbase.NewPocketBaseClient(cfg.PocketBaseURL) + if err != nil { + //log.Printf("โš ๏ธ WARNING: Failed to initialize PocketBase client: %v", err) + } else { + //log.Println("โœ… PocketBase client initialized successfully") + + //log.Println("๐Ÿ” Testing PocketBase connection...") + if err := pbClient.TestConnection(); err != nil { + //log.Printf("โš ๏ธ WARNING: PocketBase connection test failed: %v", err) + } else { + //log.Println("โœ… PocketBase connection test successful") + + // Initialize and start service monitoring with regional support + //log.Println("๐Ÿ”ง Initializing service monitoring...") + monitoringService = monitoring.NewMonitoringService(pbClient) + go monitoringService.Start() + //log.Println("โœ… Service monitoring started with regional agent support") + + // Initialize and start SSL monitoring service (original) + //log.Println("๐Ÿ”ง Initializing SSL monitoring (original)...") + sslMonitoringService = monitoring.NewSSLMonitoringService(pbClient) + go sslMonitoringService.Start() + //log.Println("โœ… SSL monitoring started (independent of regional agents)") + + // Initialize and start SSL notification service (new) + //log.Println("๐Ÿ”ง Initializing SSL notification monitoring...") + sslNotificationService = sslmonitoring.NewSSLMonitor(pbClient) + go sslNotificationService.Start() + //log.Println("โœ… SSL notification monitoring started with Telegram support") + + // Initialize and start server monitoring service + //log.Println("๐Ÿ”ง Initializing server monitoring...") + serverMonitoringService = servermonitoring.NewServerMonitoringService(pbClient) + serverMonitoringService.Start() + //log.Println("โœ… Server monitoring started with notification support") + + // Initialize and start uptime monitoring service + //log.Println("๐Ÿ”ง Initializing uptime monitoring...") + uptimeMonitoringService = uptimemonitoring.NewUptimeMonitor(pbClient) + go uptimeMonitoringService.Start() + //log.Println("โœ… Uptime monitoring started with notification support") + + // Initialize and start data retention scheduler + //log.Println("๐Ÿ”ง Initializing data retention scheduler...") + dataRetentionScheduler = dataretention.NewScheduler(pbClient, 24*time.Hour) // Run daily + go dataRetentionScheduler.Start() + //log.Println("โœ… Data retention scheduler started (daily cleanup)") + } + } + } + + //log.Println("๐Ÿ”ง Initializing HTTP handlers...") + handler := handlers.NewOperationHandler(cfg, pbClient) + + router := mux.NewRouter() + + // Main operation endpoint + router.HandleFunc("/operation", handler.HandleOperation).Methods("POST") + + // Quick operation endpoint with query parameters + router.HandleFunc("/operation/quick", handler.HandleQuickOperation).Methods("GET") + + // Legacy ping endpoint for backward compatibility + router.HandleFunc("/ping", handler.HandleOperation).Methods("POST") + router.HandleFunc("/ping/quick", handler.HandleQuickOperation).Methods("GET") + + // Health check + router.HandleFunc("/health", handler.HandleHealth).Methods("GET") + + log.Printf("=== ๐ŸŒ CHECKCLE SERVICE OPERATION SERVER READY ===") + log.Printf("๐Ÿš€ Starting on port %s", cfg.Port) + if pbClient != nil { + log.Printf("โœ“Backend integration enabled at %s ", pbClient.GetBaseURL()) + } + if monitoringService != nil { + log.Printf("โœ“Service monitoring enabled with regional agent support") + } + if sslMonitoringService != nil { + //log.Printf("๐Ÿ”’ SSL certificate monitoring enabled (independent)") + } + if sslNotificationService != nil { + log.Printf("โœ“SSL notification monitoring enabled with Telegram support") + } + if serverMonitoringService != nil { + log.Printf("โœ“Server monitoring enabled with notification support") + } + if uptimeMonitoringService != nil { + log.Printf("โœ“Uptime monitoring enabled with notification support") + } + log.Printf("โœ“Supported operations: ping, dns, tcp, http, ssl") + if dataRetentionScheduler != nil { + log.Printf("โœ“Data retention scheduler enabled (daily cleanup)") + } + + + // Setup graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("๐Ÿ›‘ === GRACEFUL SHUTDOWN INITIATED ===") + log.Println("๐Ÿ›‘ Shutting down monitoring services...") + + if monitoringService != nil { + log.Println("๐Ÿ›‘ Stopping service monitoring...") + monitoringService.Stop() + } + if sslMonitoringService != nil { + log.Println("๐Ÿ›‘ Stopping SSL monitoring...") + sslMonitoringService.Stop() + } + if sslNotificationService != nil { + log.Println("๐Ÿ›‘ Stopping SSL notification monitoring...") + sslNotificationService.Stop() + } + if serverMonitoringService != nil { + log.Println("๐Ÿ›‘ Stopping server monitoring...") + serverMonitoringService.Stop() + } + if uptimeMonitoringService != nil { + log.Println("๐Ÿ›‘ Stopping uptime monitoring...") + uptimeMonitoringService.Stop() + } + if dataRetentionScheduler != nil { + log.Println("๐Ÿ›‘ Stopping data retention scheduler...") + dataRetentionScheduler.Stop() + } + + log.Println("โœ… All services stopped gracefully") + log.Println("๐Ÿ›‘ === SERVICE OPERATION SERVER STOPPED ===") + os.Exit(0) + }() + + //log.Println("๐ŸŒ HTTP server starting...") + if err := http.ListenAndServe(":"+cfg.Port, router); err != nil { + log.Fatal("โŒ FATAL: Failed to start HTTP server:", err) + } +} \ No newline at end of file diff --git a/server/service-operation/monitoring/checker.go b/server/service-operation/monitoring/checker.go new file mode 100644 index 0000000..a7c4a7a --- /dev/null +++ b/server/service-operation/monitoring/checker.go @@ -0,0 +1,123 @@ + +package monitoring + +import ( + "log" + "strings" + "time" + + "service-operation/operations" + "service-operation/pocketbase" + "service-operation/shared/savers" + "service-operation/types" +) + +func (ms *MonitoringService) performCheck(service pocketbase.Service) { + // First, fetch the latest service status from PocketBase to ensure we have current data + latestService, err := ms.pbClient.GetService(service.ID) + if err != nil { + log.Printf("Failed to fetch latest service status for %s: %v", service.Name, err) + return + } + + // Respect the service status - don't check paused services + if latestService.Status == "paused" { + return // Silently skip paused services + } + + timeout := 10 * time.Second // Default timeout + var result *types.OperationResult + + serviceType := strings.ToLower(latestService.ServiceType) + + // Single log message for check start + //log.Printf("Checking %s (%s)", latestService.Name, serviceType) + + switch serviceType { + case "ping", "icmp": + pingOp := operations.NewPingOperation(timeout) + host := latestService.Host + if host == "" { + host = latestService.URL + } + result, err = pingOp.Execute(host, 1) // Single ping for monitoring + + case "dns": + dnsOp := operations.NewDNSOperation(timeout) + host := latestService.Host + if host == "" { + host = latestService.Domain + } + // Default to A record, but could be made configurable + queryType := "A" + result, err = dnsOp.Execute(host, queryType) + + case "tcp": + tcpOp := operations.NewTCPOperation(timeout) + host := latestService.Host + if host == "" { + host = latestService.URL + } + port := latestService.Port + if port <= 0 { + port = 80 // Default port + } + result, err = tcpOp.Execute(host, port) + + case "http", "https": + httpOp := operations.NewHTTPOperation(timeout) + url := latestService.URL + if url == "" { + url = latestService.Host + } + result, err = httpOp.Execute(url, "GET") + + default: + log.Printf("Unknown service type: %s for service %s", latestService.ServiceType, latestService.Name) + return + } + + // Determine status based on result + status := "down" + errorMessage := "" + responseTime := int64(0) + + if err != nil { + errorMessage = err.Error() + log.Printf("โŒ %s failed: %v", latestService.Name, err) + } else if result != nil { + responseTime = result.ResponseTime.Milliseconds() + if result.Success { + status = "up" + //log.Printf("โœ… %s: %.0fms", latestService.Name, float64(responseTime)) + } else { + status = "down" + errorMessage = result.Error + log.Printf("โŒ %s failed: %s", latestService.Name, errorMessage) + } + } + + // Only update service status if the service is not paused + // Check one more time before updating to prevent race conditions + currentService, err := ms.pbClient.GetService(latestService.ID) + if err != nil { + log.Printf("Failed to verify service status before update for %s: %v", latestService.Name, err) + return + } + + if currentService.Status == "paused" { + return // Silently skip status update for paused services + } + + // Update service status in PocketBase only if not paused + if err := ms.pbClient.UpdateServiceStatus(latestService.ID, status, responseTime, errorMessage); err != nil { + log.Printf("Failed to update service status for %s: %v", latestService.Name, err) + } + + // Save metrics data with regional information + if result != nil { + regionName, agentID := ms.GetRegionalInfo() + metricsSaver := savers.NewMetricsSaverWithRegion(ms.pbClient, regionName, agentID) + metricsSaver.SaveMetricsForService(*latestService, result) + } +} \ No newline at end of file diff --git a/server/service-operation/monitoring/metrics_saver.go b/server/service-operation/monitoring/metrics_saver.go new file mode 100644 index 0000000..1835e03 --- /dev/null +++ b/server/service-operation/monitoring/metrics_saver.go @@ -0,0 +1,33 @@ + +package monitoring + +import ( + "log" + "time" + + "service-operation/pocketbase" +) + +// Simplified metrics saver for basic service status updates only +func (ms *MonitoringService) saveMetrics(service pocketbase.Service, status string, responseTime int64, errorMessage string) { + // Save general metrics using the new structure + metrics := pocketbase.MetricsRecord{ + ServiceName: service.Name, + Host: service.Host, + Uptime: 0, // This would need to be calculated based on your requirements + ResponseTime: responseTime, + LastChecked: time.Now().Format(time.RFC3339), + Port: service.Port, + ServiceType: service.ServiceType, + Status: status, + ErrorMessage: errorMessage, + CheckedAt: time.Now().Format(time.RFC3339), + } + + if err := ms.pbClient.SaveMetrics(metrics); err != nil { + log.Printf("Failed to save metrics for %s: %v", service.Name, err) + } +} + +// This method is now removed to prevent duplicate saves +// The shared savers handle everything in SaveMetricsForService \ No newline at end of file diff --git a/server/service-operation/monitoring/monitor.go b/server/service-operation/monitoring/monitor.go new file mode 100644 index 0000000..ccd42d9 --- /dev/null +++ b/server/service-operation/monitoring/monitor.go @@ -0,0 +1,52 @@ + +package monitoring + +import ( + "log" + "time" + + "service-operation/pocketbase" +) + +type ServiceMonitor struct { + service pocketbase.Service + ticker *time.Ticker + stopChan chan bool +} + +func (ms *MonitoringService) startMonitor(service pocketbase.Service) { + if service.HeartbeatInterval <= 0 { + service.HeartbeatInterval = 60 // Default to 60 seconds + } + + monitor := &ServiceMonitor{ + service: service, + ticker: time.NewTicker(time.Duration(service.HeartbeatInterval) * time.Second), + stopChan: make(chan bool), + } + + ms.activeServices[service.ID] = monitor + + log.Printf("Starting monitor for service: %s (%s)", service.Name, service.ServiceType) + + go func() { + // Perform initial check + ms.performCheck(service) + + for { + select { + case <-monitor.ticker.C: + ms.performCheck(service) + case <-monitor.stopChan: + monitor.ticker.Stop() + return + } + } + }() +} + +func (ms *MonitoringService) stopMonitor(serviceID string, monitor *ServiceMonitor) { + log.Printf("Stopping monitor for service: %s", serviceID) + monitor.stopChan <- true + delete(ms.activeServices, serviceID) +} \ No newline at end of file diff --git a/server/service-operation/monitoring/notification_monitor.go b/server/service-operation/monitoring/notification_monitor.go new file mode 100644 index 0000000..8e4938d --- /dev/null +++ b/server/service-operation/monitoring/notification_monitor.go @@ -0,0 +1,71 @@ +package monitoring + +import ( + "log" + + "service-operation/notification" + "service-operation/pocketbase" +) + +// NotificationMonitor handles notification logic for the monitoring service +type NotificationMonitor struct { + notifier *notification.ServiceNotifier + lastStatuses map[string]string // Track last known status for each service +} + +// NewNotificationMonitor creates a new notification monitor +func NewNotificationMonitor(pbClient *pocketbase.PocketBaseClient) *NotificationMonitor { + return &NotificationMonitor{ + notifier: notification.NewServiceNotifier(pbClient), + lastStatuses: make(map[string]string), + } +} + +// CheckAndNotify checks if notification should be sent and sends it +func (nm *NotificationMonitor) CheckAndNotify(service pocketbase.Service, currentStatus string) { + // Skip if no notification configured + if service.NotificationID == "" { + return + } + + // Get last known status + lastStatus, exists := nm.lastStatuses[service.ID] + + // Send notification if: + // 1. This is the first check (no previous status) + // 2. Status has changed + // 3. Service is currently down (always notify for down status) + shouldNotify := !exists || lastStatus != currentStatus || currentStatus == "down" + + if shouldNotify { + log.Printf("Sending notification for service %s: %s -> %s", service.Name, lastStatus, currentStatus) + + // Send notification using custom method + if err := nm.notifier.NotifyCustom( + service.NotificationID, + service.TemplateID, + service.Name, + currentStatus, + nm.formatStatusMessage(service, currentStatus), + ); err != nil { + log.Printf("Failed to send notification: %v", err) + } + } + + // Update last known status + nm.lastStatuses[service.ID] = currentStatus +} + +// formatStatusMessage creates a formatted message for the status change +func (nm *NotificationMonitor) formatStatusMessage(service pocketbase.Service, status string) string { + switch status { + case "up": + return "Service is now operational" + case "down": + return "Service is currently unavailable" + case "warning": + return "Service is experiencing issues" + default: + return "Service status has changed" + } +} \ No newline at end of file diff --git a/server/service-operation/monitoring/regional_monitor.go b/server/service-operation/monitoring/regional_monitor.go new file mode 100644 index 0000000..1d49b8e --- /dev/null +++ b/server/service-operation/monitoring/regional_monitor.go @@ -0,0 +1,114 @@ + +package monitoring + +import ( + "log" + "time" + + "service-operation/pocketbase" +) + +type RegionalMonitor struct { + pbClient *pocketbase.PocketBaseClient + regionalService *pocketbase.RegionalService + isOnline bool + ticker *time.Ticker + stopChan chan bool +} + +func NewRegionalMonitor(pbClient *pocketbase.PocketBaseClient) *RegionalMonitor { + return &RegionalMonitor{ + pbClient: pbClient, + isOnline: false, + stopChan: make(chan bool), + } +} + +func (rm *RegionalMonitor) Start() { + // Get or create the "Default" regional service + service, err := rm.pbClient.GetDefaultRegionalService() + if err != nil { + log.Printf("Warning: Could not get default regional service: %v", err) + log.Printf("Regional monitoring will continue with fallback values") + // Continue with fallback service + service = &pocketbase.RegionalService{ + ID: "default", + RegionName: "Default", + Status: "active", + AgentID: "1", + AgentIPAddress: "127.0.0.1", + Connection: "offline", + Token: "default-token", + } + } + + rm.regionalService = service + rm.ticker = time.NewTicker(30 * time.Second) // Check connection every 30 seconds + + // Initial connection status update + rm.updateConnectionStatus("online") + rm.isOnline = true + + log.Printf("โœ“Default Regional monitor started for region: %s (Agent ID: %s)", + service.RegionName, service.AgentID) + + go func() { + for { + select { + case <-rm.ticker.C: + rm.checkConnection() + case <-rm.stopChan: + rm.ticker.Stop() + return + } + } + }() +} + +func (rm *RegionalMonitor) Stop() { + if rm.regionalService != nil { + rm.updateConnectionStatus("offline") + log.Printf("Regional monitor stopped for region: %s", rm.regionalService.RegionName) + } + + if rm.ticker != nil { + rm.ticker.Stop() + } + + rm.stopChan <- true +} + +func (rm *RegionalMonitor) checkConnection() { + // Test PocketBase connection to determine if we're online + err := rm.pbClient.TestConnection() + + if err != nil && rm.isOnline { + // We were online but now we're offline + rm.updateConnectionStatus("offline") + rm.isOnline = false + log.Printf("Regional agent went offline: %v", err) + } else if err == nil && !rm.isOnline { + // We were offline but now we're online + rm.updateConnectionStatus("online") + rm.isOnline = true + log.Printf("Regional agent back online") + } +} + +func (rm *RegionalMonitor) updateConnectionStatus(status string) { + if rm.regionalService == nil { + return + } + + if err := rm.pbClient.UpdateRegionalServiceConnection(rm.regionalService.ID, status); err != nil { + // Don't log errors for update failures - might be due to collection access issues + // log.Printf("Failed to update regional service connection status: %v", err) + } +} + +func (rm *RegionalMonitor) GetRegionalInfo() (string, string) { + if rm.regionalService == nil { + return "Default", "1" // Fallback values + } + return rm.regionalService.RegionName, rm.regionalService.AgentID +} \ No newline at end of file diff --git a/server/service-operation/monitoring/service.go b/server/service-operation/monitoring/service.go new file mode 100644 index 0000000..6173c4e --- /dev/null +++ b/server/service-operation/monitoring/service.go @@ -0,0 +1,123 @@ +package monitoring + +import ( + "log" + "sync" + "time" + + "service-operation/pocketbase" +) + +type MonitoringService struct { + pbClient *pocketbase.PocketBaseClient + activeServices map[string]*ServiceMonitor + regionalMonitor *RegionalMonitor + mu sync.RWMutex + stopChan chan bool + isRunning bool +} + +func NewMonitoringService(pbClient *pocketbase.PocketBaseClient) *MonitoringService { + return &MonitoringService{ + pbClient: pbClient, + activeServices: make(map[string]*ServiceMonitor), + regionalMonitor: NewRegionalMonitor(pbClient), + stopChan: make(chan bool), + isRunning: false, + } +} + +func (ms *MonitoringService) Start() { + ms.mu.Lock() + defer ms.mu.Unlock() + + if ms.isRunning { + log.Println("Monitoring service is already running") + return + } + + ms.isRunning = true + //log.Println("Starting monitoring service...") + + // Start regional monitoring + ms.regionalMonitor.Start() + + // Start monitoring all services from PocketBase + go ms.monitoringLoop() +} + +func (ms *MonitoringService) Stop() { + ms.mu.Lock() + defer ms.mu.Unlock() + + if !ms.isRunning { + return + } + + log.Println("Stopping monitoring service...") + ms.isRunning = false + + // Stop regional monitoring + ms.regionalMonitor.Stop() + + // Stop all active monitors + for serviceID, monitor := range ms.activeServices { + ms.stopMonitor(serviceID, monitor) + } + + ms.stopChan <- true +} + +func (ms *MonitoringService) GetRegionalInfo() (string, string) { + return ms.regionalMonitor.GetRegionalInfo() +} + +func (ms *MonitoringService) monitoringLoop() { + ticker := time.NewTicker(30 * time.Second) // Check for new services every 30 seconds + defer ticker.Stop() + + // Initial load of services + ms.loadAndStartServices() + + for { + select { + case <-ticker.C: + ms.loadAndStartServices() + case <-ms.stopChan: + return + } + } +} + +func (ms *MonitoringService) loadAndStartServices() { + // Only get services that are NOT paused + services, err := ms.pbClient.GetActiveServices() + if err != nil { + log.Printf("Failed to load services: %v", err) + return + } + + ms.mu.Lock() + defer ms.mu.Unlock() + + // Filter out paused services and start monitoring for active ones + activeServiceIDs := make(map[string]bool) + for _, service := range services { + if service.Status != "paused" { + activeServiceIDs[service.ID] = true + + // Start monitoring if not already active + if _, exists := ms.activeServices[service.ID]; !exists { + ms.startMonitor(service) + } + } + } + + // Stop monitoring for paused or removed services + for serviceID, monitor := range ms.activeServices { + if !activeServiceIDs[serviceID] { + log.Printf("Stopping monitoring for service %s (paused or removed)", serviceID) + ms.stopMonitor(serviceID, monitor) + } + } +} \ No newline at end of file diff --git a/server/service-operation/monitoring/ssl_monitor.go b/server/service-operation/monitoring/ssl_monitor.go new file mode 100644 index 0000000..0103bd0 --- /dev/null +++ b/server/service-operation/monitoring/ssl_monitor.go @@ -0,0 +1,344 @@ + +package monitoring + +import ( + "fmt" + "time" + + "service-operation/operations" + "service-operation/pocketbase" + "service-operation/types" +) + +type SSLMonitoringService struct { + pbClient *pocketbase.PocketBaseClient + stopChan chan bool + retryQueue map[string]int // Track retry count per certificate + maxRetries int +} + +func NewSSLMonitoringService(pbClient *pocketbase.PocketBaseClient) *SSLMonitoringService { + return &SSLMonitoringService{ + pbClient: pbClient, + stopChan: make(chan bool), + retryQueue: make(map[string]int), + maxRetries: 3, + } +} + +func (s *SSLMonitoringService) Start() { + ticker := time.NewTicker(1 * time.Minute) // Check every minute for scheduling + defer ticker.Stop() + + // log.Println("SSL monitoring service started with interval and check_at scheduling") + + for { + select { + case <-ticker.C: + s.checkSSLCertificates() + case <-s.stopChan: + // log.Println("SSL monitoring service stopped") + return + } + } +} + +func (s *SSLMonitoringService) Stop() { + s.stopChan <- true +} + +func (s *SSLMonitoringService) checkSSLCertificates() { + // log.Println("Fetching SSL certificates from PocketBase...") + + certificates, err := s.pbClient.GetSSLCertificates() + if err != nil { + // log.Printf("Failed to fetch SSL certificates: %v", err) + _ = err + return + } + + // log.Printf("Found %d SSL certificates to check", len(certificates)) + + for _, cert := range certificates { + if s.shouldCheckCertificate(cert) { + go s.checkSingleCertificateWithRetry(cert) + } + } + + _ = certificates +} + +func (s *SSLMonitoringService) shouldCheckCertificate(cert types.SSLCertificate) bool { + now := time.Now() + + // Priority 1: Check if check_at is set and is due + if cert.CheckAt != "" { + if checkAt, err := s.parseFlexibleTime(cert.CheckAt); err == nil { + if now.After(checkAt) || now.Equal(checkAt) { + // log.Printf("Certificate %s is due for manual check (check_at: %s)", + // cert.Domain, checkAt.Format("2006-01-02 15:04:05")) + _ = checkAt + return true + } else { + // log.Printf("Certificate %s scheduled for later check (check_at: %s)", + // cert.Domain, checkAt.Format("2006-01-02 15:04:05")) + _ = checkAt + return false + } + } else { + // log.Printf("Error parsing check_at for %s: %v", cert.Domain, err) + _ = err + } + } + + // Priority 2: Check based on check_interval (in days) from last update + if cert.Updated == "" { + // log.Printf("Certificate %s has never been checked, scheduling check", cert.Domain) + return true + } + + // Parse last check time from updated field + lastCheck, err := s.parseFlexibleTime(cert.Updated) + if err != nil { + // log.Printf("Error parsing last check time for %s, scheduling check: %v", cert.Domain, err) + _ = err + return true + } + + // Get check interval in days (default to 1 day if not set or invalid) + checkIntervalDays := cert.CheckInterval + if checkIntervalDays <= 0 { + checkIntervalDays = 1 // Default to 1 day + } + + // Adjust check interval based on certificate status for critical certificates + adjustedIntervalDays := s.adjustCheckIntervalDays(cert, checkIntervalDays) + + // Calculate next check time based on days + nextCheck := lastCheck.Add(time.Duration(adjustedIntervalDays) * 24 * time.Hour) + shouldCheck := now.After(nextCheck) + + if shouldCheck { + // log.Printf("Certificate %s is due for interval check (last: %s, interval: %d days)", + // cert.Domain, lastCheck.Format("2006-01-02 15:04:05"), adjustedIntervalDays) + _ = lastCheck + _ = adjustedIntervalDays + } else { + // log.Printf("Certificate %s not due yet (next check: %s, interval: %d days)", + // cert.Domain, nextCheck.Format("2006-01-02 15:04:05"), adjustedIntervalDays) + _ = nextCheck + _ = adjustedIntervalDays + } + + return shouldCheck +} + +// parseFlexibleTime tries multiple time formats to parse timestamps +func (s *SSLMonitoringService) parseFlexibleTime(timeStr string) (time.Time, error) { + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05.999Z", // ISO 8601 with milliseconds (PocketBase format) + "2006-01-02 15:04:05.999999Z", // ISO 8601 with microseconds + "2006-01-02 15:04:05Z", // ISO 8601 without milliseconds + "2006-01-02T15:04:05.999Z", // RFC3339 with milliseconds + "2006-01-02T15:04:05.999999Z", // RFC3339 with microseconds + "2006-01-02T15:04:05Z", // RFC3339 without milliseconds + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02 15:04:05.999999 -0700 MST", + "2006-01-02 15:04:05 -0700 MST", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05.999999", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05.999999999Z", + } + + for _, format := range formats { + if t, err := time.Parse(format, timeStr); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse time string: %s", timeStr) +} + +// adjustCheckIntervalDays adjusts the check interval based on certificate status and days left +func (s *SSLMonitoringService) adjustCheckIntervalDays(cert types.SSLCertificate, defaultIntervalDays int) int { + // Check more frequently for certificates that are expiring soon or have errors + if cert.DaysLeft <= 7 { + return 1 // Check daily for certificates expiring within 7 days + } else if cert.DaysLeft <= 30 { + // Check every 2 days for certificates expiring within 30 days + if defaultIntervalDays > 2 { + return 2 + } + } else if cert.Status == "error" { + return 1 // Check daily for certificates with errors + } + + return defaultIntervalDays +} + +func (s *SSLMonitoringService) checkSingleCertificateWithRetry(cert types.SSLCertificate) { + retryCount := s.retryQueue[cert.ID] + + // log.Printf("๐Ÿ” Checking SSL certificate for domain: %s (attempt %d/%d)", + // cert.Domain, retryCount+1, s.maxRetries+1) + + result, err := s.performSSLCheck(cert.Domain) + + if err != nil && retryCount < s.maxRetries { + // Increment retry count and schedule retry + s.retryQueue[cert.ID] = retryCount + 1 + // log.Printf("SSL check failed for %s, will retry (%d/%d): %v", + // cert.Domain, retryCount+1, s.maxRetries, err) + + // Schedule retry with exponential backoff + go func() { + backoffDuration := time.Duration((retryCount + 1) * 30) * time.Second + time.Sleep(backoffDuration) + s.checkSingleCertificateWithRetry(cert) + }() + + _ = retryCount + _ = err + return + } + + // Reset retry count on success or max retries reached + delete(s.retryQueue, cert.ID) + + if err != nil { + // log.Printf("โŒ SSL check failed for domain %s after %d attempts: %v", + // cert.Domain, s.maxRetries+1, err) + s.updateCertificateWithError(cert, err) + return + } + + // Update certificate with successful results + s.updateCertificateWithResults(cert, result) +} + +func (s *SSLMonitoringService) performSSLCheck(domain string) (*types.OperationResult, error) { + // log.Printf("Performing SSL check for domain: %s", domain) + sslOp := operations.NewSSLOperation(30 * time.Second) + result, err := sslOp.Execute(domain) + + if err != nil { + // log.Printf("SSL operation failed for %s: %v", domain, err) + _ = err + return nil, err + } + + if result == nil { + // log.Printf("SSL operation returned nil result for %s", domain) + return nil, fmt.Errorf("SSL check returned nil result") + } + + // log.Printf("SSL check completed for %s: success=%v, days_left=%d", + // domain, result.Success, result.SSLDaysLeft) + + _ = domain + _ = result + return result, nil +} + +func (s *SSLMonitoringService) updateCertificateWithError(cert types.SSLCertificate, err error) { + // log.Printf("Updating certificate %s with error status", cert.Domain) + + updateData := map[string]interface{}{ + "status": "error", + "updated": time.Now().Format(time.RFC3339), + "error_message": err.Error(), + } + + // Calculate next check time based on check_interval (in days) with shorter interval for errors + checkIntervalDays := cert.CheckInterval + if checkIntervalDays <= 0 { + checkIntervalDays = 1 + } + // For errors, check again in half the normal interval (minimum 1 day) + errorIntervalDays := checkIntervalDays / 2 + if errorIntervalDays < 1 { + errorIntervalDays = 1 + } + + nextCheck := time.Now().Add(time.Duration(errorIntervalDays) * 24 * time.Hour) + updateData["check_at"] = nextCheck.Format(time.RFC3339) + + if updateErr := s.pbClient.UpdateSSLCertificate(cert.ID, updateData); updateErr != nil { + // log.Printf("Failed to update SSL certificate %s with error status: %v", cert.ID, updateErr) + _ = updateErr + } else { + // log.Printf("๐Ÿ“ Updated certificate %s with error status (next check in %d days)", + // cert.Domain, errorIntervalDays) + _ = errorIntervalDays + } + + _ = err + _ = updateData + _ = nextCheck +} + +func (s *SSLMonitoringService) updateCertificateWithResults(cert types.SSLCertificate, result *types.OperationResult) { + status := getSSLStatus(result) + + // log.Printf("Updating certificate %s with results: status=%s, days_left=%d, issuer=%s", + // cert.Domain, status, result.SSLDaysLeft, result.SSLIssuer) + + updateData := map[string]interface{}{ + "status": status, + "valid_from": result.SSLValidFrom.Format(time.RFC3339), + "valid_till": result.SSLValidTill.Format(time.RFC3339), + "days_left": result.SSLDaysLeft, + "valid_days_to_expire": result.SSLDaysLeft, + "resolved_ip": result.SSLResolvedIP, + "issuer_cn": result.SSLIssuer, // Now contains organization name like "Google Trust Services" + "issued_to": result.SSLSubject, // Now contains organization name + "serial_number": result.SSLSerialNumber, + "cert_alg": result.SSLAlgorithm, + "cert_sans": result.SSLSANs, + "updated": time.Now().Format(time.RFC3339), + "error_message": "", // Clear any previous error + } + + // Calculate next check time based on check_interval (in days) and certificate status + checkIntervalDays := cert.CheckInterval + if checkIntervalDays <= 0 { + checkIntervalDays = 1 // Default to 1 day + } + + adjustedIntervalDays := s.adjustCheckIntervalDays(cert, checkIntervalDays) + nextCheck := time.Now().Add(time.Duration(adjustedIntervalDays) * 24 * time.Hour) + updateData["check_at"] = nextCheck.Format(time.RFC3339) + + if err := s.pbClient.UpdateSSLCertificate(cert.ID, updateData); err != nil { + // log.Printf("Failed to update SSL certificate %s: %v", cert.ID, err) + _ = err + } else { + // log.Printf("โœ… SSL certificate updated for %s: %s (%d days left, issuer: %s, next check in %d days)", + // cert.Domain, status, result.SSLDaysLeft, result.SSLIssuer, adjustedIntervalDays) + _ = status + _ = result + _ = adjustedIntervalDays + } + + _ = updateData + _ = nextCheck +} + +func getSSLStatus(result *types.OperationResult) string { + if !result.Success { + return "error" + } + + if result.SSLDaysLeft <= 0 { + return "expired" + } else if result.SSLDaysLeft <= 7 { + return "critical" // Very urgent + } else if result.SSLDaysLeft <= 30 { + return "expiring_soon" + } + + return "valid" +} \ No newline at end of file diff --git a/server/service-operation/monitoring/utils.go b/server/service-operation/monitoring/utils.go new file mode 100644 index 0000000..efb79be --- /dev/null +++ b/server/service-operation/monitoring/utils.go @@ -0,0 +1,10 @@ + +package monitoring + +// This file previously contained saveDetailedDataByType which was causing duplicate saves +// All detailed data saving is now handled by the shared savers in a single place +// to prevent duplicate records in PocketBase + +// The monitoring service now uses only: +// 1. SaveMetricsForService from shared/savers for complete data saving +// 2. Direct service status updates for the services table \ No newline at end of file diff --git a/server/service-operation/notification/discord.go b/server/service-operation/notification/discord.go new file mode 100644 index 0000000..54a7dff --- /dev/null +++ b/server/service-operation/notification/discord.go @@ -0,0 +1,214 @@ + +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// DiscordService handles Discord notifications +type DiscordService struct{} + +// NewDiscordService creates a new Discord notification service +func NewDiscordService() *DiscordService { + return &DiscordService{} +} + +// DiscordPayload represents the payload for Discord webhook +type DiscordPayload struct { + Content string `json:"content,omitempty"` + Embeds []DiscordEmbed `json:"embeds,omitempty"` +} + +// DiscordEmbed represents a Discord embed +type DiscordEmbed struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Color int `json:"color,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Fields []DiscordEmbedField `json:"fields,omitempty"` + Footer *DiscordEmbedFooter `json:"footer,omitempty"` +} + +// DiscordEmbedField represents a field in Discord embed +type DiscordEmbedField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline,omitempty"` +} + +// DiscordEmbedFooter represents footer in Discord embed +type DiscordEmbedFooter struct { + Text string `json:"text"` +} + +// SendNotification sends a notification via Discord webhook +func (ds *DiscordService) SendNotification(config *AlertConfiguration, message string) error { + if config.DiscordWebhookURL == "" { + return fmt.Errorf("discord webhook URL is required") + } + + // Create rich embed for better formatting + embed := ds.createDiscordEmbed(message) + + payload := DiscordPayload{ + Embeds: []DiscordEmbed{embed}, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal Discord payload: %v", err) + } + + resp, err := http.Post(config.DiscordWebhookURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("discord webhook request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("discord webhook error, status: %d", resp.StatusCode) + } + + return nil +} + +// createDiscordEmbed creates a formatted Discord embed from the message +func (ds *DiscordService) createDiscordEmbed(message string) DiscordEmbed { + // Parse the message to extract structured information + lines := strings.Split(message, "\n") + + if len(lines) == 0 { + return DiscordEmbed{ + Description: message, + Color: ds.getDefaultColor(), + Timestamp: time.Now().Format(time.RFC3339), + } + } + + // Extract title from first line and determine status + title := strings.TrimSpace(lines[0]) + color := ds.determineColorFromMessage(title) + + // Create fields from the remaining lines + var fields []DiscordEmbedField + + for i, line := range lines { + if i == 0 { + continue // Skip title line + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Parse field from "โ€ข Key: Value" format + if strings.HasPrefix(line, "โ€ข") || strings.HasPrefix(line, "-") { + line = strings.TrimPrefix(line, "โ€ข") + line = strings.TrimPrefix(line, "-") + line = strings.TrimSpace(line) + + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + fieldName := strings.TrimSpace(parts[0]) + fieldValue := strings.TrimSpace(parts[1]) + + if fieldName != "" && fieldValue != "" { + fields = append(fields, DiscordEmbedField{ + Name: fieldName, + Value: fieldValue, + Inline: true, + }) + } + } else { + // If not in key:value format, add as description field + fields = append(fields, DiscordEmbedField{ + Name: "Details", + Value: line, + Inline: false, + }) + } + } + } + + // If no structured fields found, use the entire message as description + if len(fields) == 0 { + // Remove the first line from description since it's used as title + description := "" + if len(lines) > 1 { + description = strings.Join(lines[1:], "\n") + } + + return DiscordEmbed{ + Title: title, + Description: description, + Color: color, + Timestamp: time.Now().Format(time.RFC3339), + Footer: &DiscordEmbedFooter{ + Text: "CheckCle System Alert", + }, + } + } + + return DiscordEmbed{ + Title: title, + Color: color, + Timestamp: time.Now().Format(time.RFC3339), + Fields: fields, + Footer: &DiscordEmbedFooter{ + Text: "CheckCle System Alert", + }, + } +} + +// determineColorFromMessage determines Discord embed color based on message content +func (ds *DiscordService) determineColorFromMessage(message string) int { + messageLower := strings.ToLower(message) + + // Red for critical/error states + if strings.Contains(messageLower, "expired") || + strings.Contains(messageLower, "down") || + strings.Contains(messageLower, "failed") || + strings.Contains(messageLower, "error") || + strings.Contains(messageLower, "critical") || + strings.Contains(messageLower, "๐Ÿšจ") || + strings.Contains(messageLower, "๐Ÿ”ด") { + return 15158332 // Red (#E74C3C) + } + + // Orange for warnings + if strings.Contains(messageLower, "expiring_soon") || + strings.Contains(messageLower, "expiring soon") || + strings.Contains(messageLower, "warning") || + strings.Contains(messageLower, "maintenance") || + strings.Contains(messageLower, "paused") || + strings.Contains(messageLower, "โš ๏ธ") || + strings.Contains(messageLower, "๐ŸŸก") || + strings.Contains(messageLower, "๐ŸŸ ") { + return 15105570 // Orange (#E67E22) + } + + // Green for success/up states + if strings.Contains(messageLower, "up") || + strings.Contains(messageLower, "resolved") || + strings.Contains(messageLower, "success") || + strings.Contains(messageLower, "restored") || + strings.Contains(messageLower, "valid") || + strings.Contains(messageLower, "๐ŸŸข") || + strings.Contains(messageLower, "โœ…") { + return 3066993 // Green (#2ECC71) + } + + // Blue for info/default + return 3447003 // Blue (#3498DB) +} + +// getDefaultColor returns the default Discord embed color (blue) +func (ds *DiscordService) getDefaultColor() int { + return 3447003 // Blue (#3498DB) +} \ No newline at end of file diff --git a/server/service-operation/notification/email.go b/server/service-operation/notification/email.go new file mode 100644 index 0000000..e028ac4 --- /dev/null +++ b/server/service-operation/notification/email.go @@ -0,0 +1,532 @@ +package notification + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "strconv" + "strings" + "time" +) + +// EmailService handles email notifications +type EmailService struct{} + +// NewEmailService creates a new email notification service +func NewEmailService() *EmailService { + // log.Printf("โœ… Email notification service initialized") + return &EmailService{} +} + +// SendNotification sends a notification via email +func (es *EmailService) SendNotification(config *AlertConfiguration, message string) error { + // log.Printf("๐Ÿ“ง === SENDING EMAIL NOTIFICATION ===") + // log.Printf("๐Ÿ”” Email notification request received") + // log.Printf("๐Ÿ“Š Email Configuration:") + // log.Printf(" - Email Address: %s", config.EmailAddress) + // log.Printf(" - Sender Name: %s", config.EmailSenderName) + // log.Printf(" - SMTP Server: %s", config.SMTPServer) + // log.Printf(" - SMTP Port: %s", config.SMTPPort) + // log.Printf(" - SMTP Password present: %t", config.SMTPPassword != "") + // log.Printf(" - Message Length: %d chars", len(message)) + + // Validate email configuration + if config.EmailAddress == "" || config.SMTPServer == "" || config.SMTPPort == "" { + // log.Printf("โŒ EMAIL CONFIGURATION ERROR: Missing required fields") + // log.Printf(" - Email Address present: %t", config.EmailAddress != "") + // log.Printf(" - SMTP Server present: %t", config.SMTPServer != "") + // log.Printf(" - SMTP Port present: %t", config.SMTPPort != "") + return fmt.Errorf("email configuration is incomplete") + } + + if config.SMTPPassword == "" { + // log.Printf("โš ๏ธ WARNING: SMTP password not provided - authentication may fail") + } + + port, err := strconv.Atoi(config.SMTPPort) + if err != nil { + // log.Printf("โŒ SMTP PORT ERROR: Invalid port '%s': %v", config.SMTPPort, err) + return fmt.Errorf("invalid SMTP port: %v", err) + } + + // log.Printf("โœ… Email configuration validation passed") + // log.Printf("๐Ÿ”ง Parsed SMTP Port: %d", port) + + // Determine alert severity and get appropriate emoji/color + severity, emoji := es.parseMessageSeverity(message) + // log.Printf("๐Ÿ“‹ Message Analysis:") + // log.Printf(" - Detected Severity: %s", severity) + // log.Printf(" - Emoji: %s", emoji) + + // Create enhanced email content + subject := es.createEmailSubject(config.EmailSenderName, severity) + htmlBody := es.createHTMLEmailBody(message, severity, emoji) + plainBody := es.createPlainEmailBody(message, severity, emoji) + + // log.Printf("๐Ÿ“ Email Content Created:") + // log.Printf(" - Subject: %s", subject) + // log.Printf(" - HTML Body Length: %d chars", len(htmlBody)) + // log.Printf(" - Plain Body Length: %d chars", len(plainBody)) + + // Prepare email message with both HTML and plain text + emailMessage := es.createMIMEMessage(config.EmailSenderName, config.EmailAddress, subject, htmlBody, plainBody) + + // log.Printf("๐Ÿ“ค Preparing to send email...") + // log.Printf(" - SMTP Server: %s:%d", config.SMTPServer, port) + // log.Printf(" - From: %s", config.EmailSenderName) + // log.Printf(" - To: %s", config.EmailAddress) + + // Send email with enhanced SMTP handling + err = es.sendSMTPEmail(config.SMTPServer, port, config.EmailSenderName, config.EmailAddress, config.SMTPPassword, emailMessage) + if err != nil { + // log.Printf("โŒ EMAIL SENDING FAILED: %v", err) + // log.Printf("โŒ Error Details:") + // log.Printf(" - SMTP Server: %s:%d", config.SMTPServer, port) + // log.Printf(" - Error Type: %T", err) + // log.Printf(" - Error Message: %s", err.Error()) + return fmt.Errorf("failed to send email: %v", err) + } + + // log.Printf("โœ… EMAIL SENT SUCCESSFULLY") + // log.Printf("โœ… Email Details:") + // log.Printf(" - Recipient: %s", config.EmailAddress) + // log.Printf(" - Subject: %s", subject) + // log.Printf(" - Severity: %s", severity) + // log.Printf(" - Timestamp: %s", time.Now().Format("2006-01-02 15:04:05")) + // log.Printf("=== EMAIL NOTIFICATION COMPLETE ===") + + return nil +} + +// parseMessageSeverity analyzes the message to determine severity level +func (es *EmailService) parseMessageSeverity(message string) (string, string) { + messageUpper := strings.ToUpper(message) + + // Check for critical indicators + if strings.Contains(messageUpper, "๐Ÿšจ") || strings.Contains(messageUpper, "CRITICAL") || + strings.Contains(messageUpper, "DOWN") || strings.Contains(messageUpper, "ERROR") || + strings.Contains(messageUpper, "FAILED") || strings.Contains(messageUpper, "๐Ÿ”ด") { + return "CRITICAL", "๐Ÿšจ" + } + + // Check for warning indicators + if strings.Contains(messageUpper, "โš ๏ธ") || strings.Contains(messageUpper, "WARNING") || + strings.Contains(messageUpper, "๐ŸŸก") || strings.Contains(messageUpper, "INCIDENT") { + return "WARNING", "โš ๏ธ" + } + + // Check for success indicators + if strings.Contains(messageUpper, "๐ŸŸข") || strings.Contains(messageUpper, "UP") || + strings.Contains(messageUpper, "SUCCESS") || strings.Contains(messageUpper, "RESOLVED") || + strings.Contains(messageUpper, "RESTORED") { + return "SUCCESS", "โœ…" + } + + // Check for maintenance indicators + if strings.Contains(messageUpper, "๐ŸŸ ") || strings.Contains(messageUpper, "MAINTENANCE") || + strings.Contains(messageUpper, "PAUSED") { + return "MAINTENANCE", "๐Ÿ”ง" + } + + // Default to info + return "INFO", "โ„น๏ธ" +} + +// createEmailSubject creates an appropriate email subject +func (es *EmailService) createEmailSubject(senderName, severity string) string { + prefix := "Service Alert" + if senderName != "" { + prefix = fmt.Sprintf("%s - Service Alert", senderName) + } + + switch severity { + case "CRITICAL": + return fmt.Sprintf("๐Ÿšจ [CRITICAL] %s", prefix) + case "WARNING": + return fmt.Sprintf("โš ๏ธ [WARNING] %s", prefix) + case "SUCCESS": + return fmt.Sprintf("โœ… [RESOLVED] %s", prefix) + case "MAINTENANCE": + return fmt.Sprintf("๐Ÿ”ง [MAINTENANCE] %s", prefix) + default: + return fmt.Sprintf("โ„น๏ธ [INFO] %s", prefix) + } +} + +// createHTMLEmailBody creates a rich HTML email body +func (es *EmailService) createHTMLEmailBody(message, severity, emoji string) string { + // Get color scheme based on severity + var bgColor, borderColor, textColor string + switch severity { + case "CRITICAL": + bgColor = "#fee2e2" + borderColor = "#dc2626" + textColor = "#991b1b" + case "WARNING": + bgColor = "#fef3c7" + borderColor = "#d97706" + textColor = "#92400e" + case "SUCCESS": + bgColor = "#dcfce7" + borderColor = "#16a34a" + textColor = "#15803d" + case "MAINTENANCE": + bgColor = "#fed7aa" + borderColor = "#ea580c" + textColor = "#c2410c" + default: + bgColor = "#dbeafe" + borderColor = "#2563eb" + textColor = "#1d4ed8" + } + + // Format message content for HTML + htmlMessage := strings.ReplaceAll(message, "\n", "
") + + // Enhance bullet points and formatting + htmlMessage = strings.ReplaceAll(htmlMessage, " - ", "
  โ€ข ") + htmlMessage = strings.ReplaceAll(htmlMessage, "Service:", "Service:") + htmlMessage = strings.ReplaceAll(htmlMessage, "Status:", "Status:") + htmlMessage = strings.ReplaceAll(htmlMessage, "Host:", "Host:") + htmlMessage = strings.ReplaceAll(htmlMessage, "Type:", "Type:") + htmlMessage = strings.ReplaceAll(htmlMessage, "Response time:", "Response time:") + htmlMessage = strings.ReplaceAll(htmlMessage, "Time:", "Time:") + htmlMessage = strings.ReplaceAll(htmlMessage, "Domain:", "Domain:") + htmlMessage = strings.ReplaceAll(htmlMessage, "Days Remaining:", "Days Remaining:") + htmlMessage = strings.ReplaceAll(htmlMessage, "Expiration Date:", "Expiration Date:") + + return fmt.Sprintf(` + + + + + + Service Alert Notification + + +
+ +
+

+ %s Service Alert Notification +

+
+ + +
+
+

+ %s +

+
+ + +
+

+ This is an automated notification from your monitoring system.
+ Generated at: %s +

+
+
+
+ +`, + bgColor, textColor, borderColor, emoji, borderColor, htmlMessage, time.Now().Format("2006-01-02 15:04:05 MST")) +} + +// createPlainEmailBody creates a plain text email body +func (es *EmailService) createPlainEmailBody(message, severity, emoji string) string { + separator := strings.Repeat("=", 50) + + return fmt.Sprintf(`%s +%s SERVICE ALERT NOTIFICATION +%s + +%s + +%s +This is an automated notification from your monitoring system. +Generated at: %s +%s`, + separator, + emoji+" "+severity, + separator, + message, + separator, + time.Now().Format("2006-01-02 15:04:05 MST"), + separator) +} + +// createMIMEMessage creates a MIME message with both HTML and plain text +func (es *EmailService) createMIMEMessage(fromName, toEmail, subject, htmlBody, plainBody string) string { + boundary := fmt.Sprintf("boundary_%d", time.Now().Unix()) + + headers := fmt.Sprintf(`From: %s +To: %s +Subject: %s +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="%s" + +`, fromName, toEmail, subject, boundary) + + body := fmt.Sprintf(`--%s +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +%s + +--%s +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +%s + +--%s-- +`, boundary, plainBody, boundary, htmlBody, boundary) + + return headers + body +} + +// sendSMTPEmail sends the email using SMTP with proper authentication +func (es *EmailService) sendSMTPEmail(smtpServer string, port int, fromEmail, toEmail, password, message string) error { + addr := fmt.Sprintf("%s:%d", smtpServer, port) + + // log.Printf("๐Ÿ”Œ Connecting to SMTP server: %s", addr) + + // Extract hostname from SMTP server for proper HELO + hostname := smtpServer + if strings.Contains(hostname, ".") { + // Use the SMTP server hostname for HELO + hostname = smtpServer + } + + // log.Printf("๐Ÿ”ง Using hostname for HELO: %s", hostname) + + // For port 587 (STARTTLS) - most common for authenticated SMTP + if port == 587 { + // log.Printf("๐Ÿ” Attempting STARTTLS connection with authentication...") + return es.sendWithSTARTTLSAuth(addr, hostname, fromEmail, toEmail, password, message) + } + + // For port 465 (SSL/TLS) + if port == 465 { + // log.Printf("๐Ÿ”’ Attempting SSL connection with authentication...") + return es.sendWithSSLAuth(addr, hostname, fromEmail, toEmail, password, message) + } + + // For port 25 (Plain SMTP with optional STARTTLS) + if port == 25 { + // log.Printf("๐Ÿ“ง Attempting plain SMTP with optional STARTTLS...") + return es.sendWithSTARTTLSAuth(addr, hostname, fromEmail, toEmail, password, message) + } + + // Fallback to STARTTLS for any other port + // log.Printf("๐Ÿ“ง Using STARTTLS with auth fallback for port %d...", port) + return es.sendWithSTARTTLSAuth(addr, hostname, fromEmail, toEmail, password, message) +} + +// sendWithSTARTTLSAuth sends email with STARTTLS and authentication +func (es *EmailService) sendWithSTARTTLSAuth(addr, hostname, fromEmail, toEmail, password, message string) error { + // log.Printf("๐Ÿ” Establishing STARTTLS connection to %s", addr) + + // Connect to server + client, err := smtp.Dial(addr) + if err != nil { + // log.Printf("โŒ Failed to connect to SMTP server: %v", err) + return fmt.Errorf("failed to connect to SMTP server: %v", err) + } + defer client.Close() + + // Send EHLO with proper hostname + // log.Printf("๐Ÿ‘‹ Sending EHLO with hostname: %s", hostname) + if err = client.Hello(hostname); err != nil { + // log.Printf("โŒ EHLO failed: %v", err) + return fmt.Errorf("EHLO failed: %v", err) + } + + // Check if STARTTLS is supported and use it + if ok, _ := client.Extension("STARTTLS"); ok { + // log.Printf("๐Ÿ” STARTTLS supported, initiating TLS...") + tlsConfig := &tls.Config{ + ServerName: strings.Split(addr, ":")[0], + InsecureSkipVerify: false, + } + if err = client.StartTLS(tlsConfig); err != nil { + // log.Printf("โŒ STARTTLS failed: %v", err) + return fmt.Errorf("STARTTLS failed: %v", err) + } + // log.Printf("โœ… TLS connection established") + } else { + // log.Printf("โš ๏ธ STARTTLS not supported by server, continuing with plain connection") + } + + // Check for AUTH support and authenticate if available + if ok, mechanisms := client.Extension("AUTH"); ok { + // Suppress unused variable warning + _ = mechanisms + // log.Printf("๐Ÿ”‘ AUTH extension supported with mechanisms: %s", mechanisms) + + // Use the provided credentials for authentication + username := fromEmail + + // log.Printf("๐Ÿ” Attempting authentication for user: %s", username) + // log.Printf("๐Ÿ”‘ Password provided: %t", password != "") + + if password != "" { + // Create auth mechanism - try PLAIN first as it's most common + auth := smtp.PlainAuth("", username, password, strings.Split(addr, ":")[0]) + + if err := client.Auth(auth); err != nil { + // log.Printf("โŒ Authentication failed: %v", err) + return fmt.Errorf("SMTP authentication failed: %v", err) + } else { + // log.Printf("โœ… Authentication successful") + } + } else { + // log.Printf("โš ๏ธ No password provided, skipping authentication") + return fmt.Errorf("SMTP password is required for authentication") + } + } else { + // log.Printf("โ„น๏ธ AUTH extension not available, proceeding without authentication") + } + + // Set sender + // log.Printf("๐Ÿ“ค Setting sender: %s", fromEmail) + if err = client.Mail(fromEmail); err != nil { + // log.Printf("โŒ Failed to set sender: %v", err) + return fmt.Errorf("failed to set sender: %v", err) + } + + // Set recipient + // log.Printf("๐Ÿ“ฅ Setting recipient: %s", toEmail) + if err = client.Rcpt(toEmail); err != nil { + // log.Printf("โŒ Failed to set recipient: %v", err) + return fmt.Errorf("failed to set recipient: %v", err) + } + + // Send message + // log.Printf("๐Ÿ“ Sending message data...") + w, err := client.Data() + if err != nil { + // log.Printf("โŒ Failed to initiate data transfer: %v", err) + return fmt.Errorf("failed to initiate data transfer: %v", err) + } + + _, err = w.Write([]byte(message)) + if err != nil { + // log.Printf("โŒ Failed to write message data: %v", err) + return fmt.Errorf("failed to write message data: %v", err) + } + + err = w.Close() + if err != nil { + // log.Printf("โŒ Failed to close data writer: %v", err) + return fmt.Errorf("failed to close data writer: %v", err) + } + + // Quit gracefully + err = client.Quit() + if err != nil { + // log.Printf("โš ๏ธ Warning during QUIT: %v", err) + // Don't return error for QUIT issues as email might have been sent + } + + // log.Printf("โœ… Email sent successfully via STARTTLS") + return nil +} + +// sendWithSSLAuth sends email with SSL/TLS and authentication (for port 465) +func (es *EmailService) sendWithSSLAuth(addr, hostname, fromEmail, toEmail, password, message string) error { + // log.Printf("๐Ÿ”’ Establishing SSL/TLS connection to %s", addr) + + // Create TLS configuration + tlsConfig := &tls.Config{ + InsecureSkipVerify: false, + ServerName: strings.Split(addr, ":")[0], + } + + // Connect with TLS + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + // log.Printf("โŒ Failed to establish TLS connection: %v", err) + return fmt.Errorf("failed to establish TLS connection: %v", err) + } + defer conn.Close() + + // Create SMTP client + client, err := smtp.NewClient(conn, strings.Split(addr, ":")[0]) + if err != nil { + // log.Printf("โŒ Failed to create SMTP client: %v", err) + return fmt.Errorf("failed to create SMTP client: %v", err) + } + defer client.Quit() + + // Send EHLO with proper hostname + // log.Printf("๐Ÿ‘‹ Sending EHLO with hostname: %s", hostname) + if err = client.Hello(hostname); err != nil { + // log.Printf("โŒ EHLO failed: %v", err) + return fmt.Errorf("EHLO failed: %v", err) + } + + // Authenticate if AUTH is supported and password is provided + if ok, mechanisms := client.Extension("AUTH"); ok { + // Suppress unused variable warning + _ = mechanisms + // log.Printf("๐Ÿ”‘ AUTH extension supported with mechanisms: %s", mechanisms) + + username := fromEmail + + // log.Printf("๐Ÿ” Attempting authentication for user: %s", username) + // log.Printf("๐Ÿ”‘ Password provided: %t", password != "") + + if password != "" { + auth := smtp.PlainAuth("", username, password, strings.Split(addr, ":")[0]) + + if err := client.Auth(auth); err != nil { + // log.Printf("โŒ Authentication failed: %v", err) + return fmt.Errorf("SMTP authentication failed: %v", err) + } else { + // log.Printf("โœ… Authentication successful") + } + } else { + // log.Printf("โš ๏ธ No password provided, skipping authentication") + return fmt.Errorf("SMTP password is required for authentication") + } + } + + // Set sender and recipient + // log.Printf("๐Ÿ“ค Setting sender: %s", fromEmail) + if err := client.Mail(fromEmail); err != nil { + // log.Printf("โŒ Failed to set sender: %v", err) + return fmt.Errorf("failed to set sender: %v", err) + } + + // log.Printf("๐Ÿ“ฅ Setting recipient: %s", toEmail) + if err := client.Rcpt(toEmail); err != nil { + // log.Printf("โŒ Failed to set recipient: %v", err) + return fmt.Errorf("failed to set recipient: %v", err) + } + + // Send message + // log.Printf("๐Ÿ“ Sending message data...") + w, err := client.Data() + if err != nil { + // log.Printf("โŒ Failed to initiate data transfer: %v", err) + return fmt.Errorf("failed to initiate data transfer: %v", err) + } + + _, err = w.Write([]byte(message)) + if err != nil { + // log.Printf("โŒ Failed to write message data: %v", err) + return fmt.Errorf("failed to write message data: %v", err) + } + + err = w.Close() + if err != nil { + // log.Printf("โŒ Failed to close data writer: %v", err) + return fmt.Errorf("failed to close data writer: %v", err) + } + + // log.Printf("โœ… Email sent successfully via SSL/TLS") + return nil +} \ No newline at end of file diff --git a/server/service-operation/notification/google_chat.go b/server/service-operation/notification/google_chat.go new file mode 100644 index 0000000..66dbdb5 --- /dev/null +++ b/server/service-operation/notification/google_chat.go @@ -0,0 +1,336 @@ + +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// GoogleChatService handles Google Chat notifications +type GoogleChatService struct{} + +// NewGoogleChatService creates a new Google Chat notification service +func NewGoogleChatService() *GoogleChatService { + return &GoogleChatService{} +} + +// GoogleChatPayload represents the payload for Google Chat webhook +type GoogleChatPayload struct { + Text string `json:"text"` +} + +// SendNotification sends a notification via Google Chat webhook +func (gcs *GoogleChatService) SendNotification(config *AlertConfiguration, message string) error { + // fmt.Printf("๐Ÿ’ฌ [GOOGLE_CHAT] Attempting to send notification...\n") + // fmt.Printf("๐Ÿ’ฌ [GOOGLE_CHAT] Config - Webhook URL present: %v\n", config.GoogleChatWebhookURL != "") + // fmt.Printf("๐Ÿ’ฌ [GOOGLE_CHAT] Message: %s\n", message) + + if config.GoogleChatWebhookURL == "" { + return fmt.Errorf("google chat webhook URL is required") + } + + payload := GoogleChatPayload{ + Text: message, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + // fmt.Printf("โŒ [GOOGLE_CHAT] JSON marshal error: %v\n", err) + return err + } + + // fmt.Printf("๐Ÿ’ฌ [GOOGLE_CHAT] Sending POST request to webhook...\n") + resp, err := http.Post(config.GoogleChatWebhookURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + // fmt.Printf("โŒ [GOOGLE_CHAT] HTTP error: %v\n", err) + return err + } + defer resp.Body.Close() + + // fmt.Printf("๐Ÿ’ฌ [GOOGLE_CHAT] Response status: %d\n", resp.StatusCode) + + if resp.StatusCode != http.StatusOK { + // fmt.Printf("โŒ [GOOGLE_CHAT] Webhook error, status: %d\n", resp.StatusCode) + return fmt.Errorf("google chat webhook error, status: %d", resp.StatusCode) + } + + // fmt.Printf("โœ… [GOOGLE_CHAT] Message sent successfully!\n") + return nil +} + +// SendServerNotification sends a server-specific notification via Google Chat +func (gcs *GoogleChatService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := gcs.generateServerMessage(payload, template, resourceType) + return gcs.SendNotification(config, message) +} + +// SendServiceNotification sends a service-specific notification via Google Chat +func (gcs *GoogleChatService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := gcs.generateServiceMessage(payload, template) + return gcs.SendNotification(config, message) +} + +// generateServerMessage creates a message for server notifications using server template +func (gcs *GoogleChatService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + // Select appropriate template message based on status and resource type + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + // Handle resource-specific messages + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = gcs.generateDefaultServerMessage(payload, resourceType) + } + + return gcs.replacePlaceholders(templateMessage, payload) +} + +// generateServiceMessage creates a message for service notifications using service template +func (gcs *GoogleChatService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + // Select appropriate template message based on status + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = gcs.generateDefaultUptimeMessage(payload) + } + + return gcs.replacePlaceholders(templateMessage, payload) +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (gcs *GoogleChatService) replacePlaceholders(message string, payload *NotificationPayload) string { + // Replace basic placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", gcs.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", gcs.safeString(payload.Hostname)) + + // Replace URL with fallback to host + url := gcs.safeString(payload.URL) + if url == "N/A" && payload.Host != "" { + url = payload.Host + } + message = strings.ReplaceAll(message, "${url}", url) + + // Replace domain + message = strings.ReplaceAll(message, "${domain}", gcs.safeString(payload.Domain)) + + // Replace service type + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + // Replace region and agent info + message = strings.ReplaceAll(message, "${region_name}", gcs.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", gcs.safeString(payload.AgentID)) + + // Handle numeric fields safely + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + // Replace server monitoring fields + message = strings.ReplaceAll(message, "${cpu_usage}", gcs.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", gcs.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", gcs.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", gcs.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", gcs.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", gcs.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", gcs.safeString(payload.Threshold)) + + // Replace error message - important for uptime services + message = strings.ReplaceAll(message, "${error_message}", gcs.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", gcs.safeString(payload.ErrorMessage)) + + // Replace time placeholders + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} + +// safeString returns the string value or "N/A" if empty +func (gcs *GoogleChatService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// generateDefaultUptimeMessage creates a default uptime message with proper formatting +func (gcs *GoogleChatService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + // Status emoji mapping for Google Chat + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + case "maintenance", "paused": + statusEmoji = "๐ŸŸ " + } + + message := fmt.Sprintf("%s Service %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status)) + + // Build formatted details + details := []string{} + + // Add URL or host + if payload.URL != "" { + details = append(details, fmt.Sprintf(" - Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf(" - Host: %s", payload.Host)) + } + + // Add service type + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf(" - Type: %s", strings.ToUpper(payload.ServiceType))) + } + + // Add port if available + if payload.Port > 0 { + details = append(details, fmt.Sprintf(" - Port: %d", payload.Port)) + } + + // Add domain if available + if payload.Domain != "" { + details = append(details, fmt.Sprintf(" - Domain: %s", payload.Domain)) + } + + // Add response time + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf(" - Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, " - Response time: N/A") + } + + // Add region info + if payload.RegionName != "" { + details = append(details, fmt.Sprintf(" - Region: %s", payload.RegionName)) + } + + // Add agent info + if payload.AgentID != "" { + details = append(details, fmt.Sprintf(" - Agent: %s", payload.AgentID)) + } + + // Add uptime if available + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf(" - Uptime: %d%%", payload.Uptime)) + } + + // Add timestamp + details = append(details, fmt.Sprintf(" - Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + // Combine message with details + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + + return message +} + +// generateDefaultServerMessage creates a default server message +func (gcs *GoogleChatService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + } + + return fmt.Sprintf("%s๐Ÿ–ฅ๏ธ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} \ No newline at end of file diff --git a/server/service-operation/notification/gotify.go b/server/service-operation/notification/gotify.go new file mode 100644 index 0000000..d63a395 --- /dev/null +++ b/server/service-operation/notification/gotify.go @@ -0,0 +1,357 @@ +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// GotifyService handles Gotify notifications +type GotifyService struct{} + +// NewGotifyService creates a new Gotify notification service +func NewGotifyService() *GotifyService { + return &GotifyService{} +} + +// GotifyPayload represents the payload for Gotify API +type GotifyPayload struct { + Message string `json:"message"` + Title string `json:"title,omitempty"` + Priority int `json:"priority,omitempty"` +} + +// GotifyResponse represents the response from Gotify API +type GotifyResponse struct { + ID int `json:"id"` + AppID int `json:"appid"` + Message string `json:"message"` + Title string `json:"title"` + Priority int `json:"priority"` + Date string `json:"date"` +} + +// SendNotification sends a notification via Gotify +func (gs *GotifyService) SendNotification(config *AlertConfiguration, message string) error { + // fmt.Printf("๐Ÿ“ฑ [GOTIFY] Attempting to send notification...\n") + // fmt.Printf("๐Ÿ“ฑ [GOTIFY] Config - API Token: %s, Server URL: %s, Notify Name: %s\n", + // maskToken(config.APIToken), config.ServerURL, config.NotifyName) + // fmt.Printf("๐Ÿ“ฑ [GOTIFY] Message: %s\n", message) + + if config.APIToken == "" || config.ServerURL == "" { + return fmt.Errorf("Gotify API token and server URL are required") + } + + // Prepare the payload + payload := GotifyPayload{ + Message: message, + Title: config.NotifyName, // Use notify_name as the title + Priority: 5, // Default priority + } + + jsonData, err := json.Marshal(payload) + if err != nil { + // fmt.Printf("โŒ [GOTIFY] JSON marshal error: %v\n", err) + return err + } + + // fmt.Printf("๐Ÿ“ฑ [GOTIFY] Payload: %s\n", string(jsonData)) + // fmt.Printf("๐Ÿ“ฑ [GOTIFY] Sending POST request to Gotify API...\n") + + // Construct the Gotify API URL + apiURL := strings.TrimRight(config.ServerURL, "/") + "/message?token=" + config.APIToken + + // Send the request to Gotify API + resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + // fmt.Printf("โŒ [GOTIFY] HTTP error: %v\n", err) + return err + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // fmt.Printf("โŒ [GOTIFY] HTTP status error: %d\n", resp.StatusCode) + return fmt.Errorf("Gotify API error: HTTP %d", resp.StatusCode) + } + + // Parse the response + var gotifyResp GotifyResponse + if err := json.NewDecoder(resp.Body).Decode(&gotifyResp); err != nil { + // fmt.Printf("โŒ [GOTIFY] Response decode error: %v\n", err) + return err + } + + // fmt.Printf("๐Ÿ“ฑ [GOTIFY] API response: %+v\n", gotifyResp) + + // Check if the message was created successfully + if gotifyResp.ID == 0 { + // fmt.Printf("โŒ [GOTIFY] API error: No message ID returned\n") + return fmt.Errorf("Gotify API error: message not created") + } + + // fmt.Printf("โœ… [GOTIFY] Message sent successfully! ID: %d\n", gotifyResp.ID) + return nil +} + +// SendServerNotification sends a server-specific notification via Gotify +func (gs *GotifyService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := gs.generateServerMessage(payload, template, resourceType) + return gs.SendNotification(config, message) +} + +// SendServiceNotification sends a service-specific notification via Gotify +func (gs *GotifyService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := gs.generateServiceMessage(payload, template) + return gs.SendNotification(config, message) +} + +// generateServerMessage creates a message for server notifications using server template +func (gs *GotifyService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + // Select appropriate template message based on status and resource type + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + // Handle resource-specific messages + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = gs.generateDefaultServerMessage(payload, resourceType) + } + + return gs.replacePlaceholders(templateMessage, payload) +} + +// generateServiceMessage creates a message for service notifications using service template +func (gs *GotifyService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + // Select appropriate template message based on status + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = gs.generateDefaultUptimeMessage(payload) + } + + return gs.replacePlaceholders(templateMessage, payload) +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (gs *GotifyService) replacePlaceholders(message string, payload *NotificationPayload) string { + // Replace basic placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${server_name}", payload.ServiceName) // server_name maps to service_name + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", gs.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", gs.safeString(payload.Hostname)) + + // Replace URL with fallback to host + url := gs.safeString(payload.URL) + if url == "N/A" && payload.Host != "" { + url = payload.Host + } + message = strings.ReplaceAll(message, "${url}", url) + + // Replace domain + message = strings.ReplaceAll(message, "${domain}", gs.safeString(payload.Domain)) + + // Replace service type + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + // Replace region and agent info + message = strings.ReplaceAll(message, "${region_name}", gs.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", gs.safeString(payload.AgentID)) + + // Handle numeric fields safely + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + // Replace server monitoring fields + message = strings.ReplaceAll(message, "${cpu_usage}", gs.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", gs.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", gs.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", gs.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", gs.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", gs.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", gs.safeString(payload.Threshold)) + + // Replace SSL certificate fields + message = strings.ReplaceAll(message, "${certificate_name}", gs.safeString(payload.CertificateName)) + message = strings.ReplaceAll(message, "${expiry_date}", gs.safeString(payload.ExpiryDate)) + message = strings.ReplaceAll(message, "${days_left}", gs.safeString(payload.DaysLeft)) + message = strings.ReplaceAll(message, "${issuer_cn}", gs.safeString(payload.IssuerCN)) + message = strings.ReplaceAll(message, "${issuer}", gs.safeString(payload.IssuerCN)) // alias + + // Replace error message - important for uptime services + message = strings.ReplaceAll(message, "${error_message}", gs.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", gs.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${message}", gs.safeString(payload.Message)) + + // Replace time placeholders + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} + +// safeString returns the string value or "N/A" if empty +func (gs *GotifyService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// generateDefaultUptimeMessage creates a default uptime message with proper formatting +func (gs *GotifyService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + message := fmt.Sprintf("Service %s is %s.", payload.ServiceName, strings.ToUpper(payload.Status)) + + // Build formatted details + details := []string{} + + // Add URL or host + if payload.URL != "" { + details = append(details, fmt.Sprintf("Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf("Host: %s", payload.Host)) + } + + // Add service type + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf("Type: %s", strings.ToUpper(payload.ServiceType))) + } + + // Add port if available + if payload.Port > 0 { + details = append(details, fmt.Sprintf("Port: %d", payload.Port)) + } + + // Add domain if available + if payload.Domain != "" { + details = append(details, fmt.Sprintf("Domain: %s", payload.Domain)) + } + + // Add response time + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf("Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, "Response time: N/A") + } + + // Add region info + if payload.RegionName != "" { + details = append(details, fmt.Sprintf("Region: %s", payload.RegionName)) + } + + // Add agent info + if payload.AgentID != "" { + details = append(details, fmt.Sprintf("Agent: %s", payload.AgentID)) + } + + // Add uptime if available + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf("Uptime: %d%%", payload.Uptime)) + } + + // Add timestamp + details = append(details, fmt.Sprintf("Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + // Combine message with details + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + + return message +} + +// generateDefaultServerMessage creates a default server message +func (gs *GotifyService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + return fmt.Sprintf("Server %s (%s) status: %s", payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} \ No newline at end of file diff --git a/server/service-operation/notification/integration.go b/server/service-operation/notification/integration.go new file mode 100644 index 0000000..8c44e6a --- /dev/null +++ b/server/service-operation/notification/integration.go @@ -0,0 +1,61 @@ + +package notification + +import ( + "log" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +// ServiceNotifier provides an easy interface to send notifications for service events +type ServiceNotifier struct { + manager *NotificationManager +} + +// NewServiceNotifier creates a new service notifier +func NewServiceNotifier(pbClient *pocketbase.PocketBaseClient) *ServiceNotifier { + return &ServiceNotifier{ + manager: NewNotificationManager(pbClient), + } +} + +// NotifyServiceStatus sends a notification for service status change +func (sn *ServiceNotifier) NotifyServiceStatus(service pocketbase.Service, result *types.OperationResult) { + // Check if service has notification configured + if service.NotificationID == "" { + return + } + + // Create notification payload + payload := &NotificationPayload{ + ServiceName: service.Name, + Status: service.Status, + Host: service.Host, + Port: service.Port, + ServiceType: service.ServiceType, + ResponseTime: result.ResponseTime.Milliseconds(), + Timestamp: time.Now(), + ErrorMessage: result.Error, + } + + // Send notification + if err := sn.manager.SendServiceNotification(payload, service.NotificationID, service.TemplateID); err != nil { + log.Printf("Failed to send notification for service %s: %v", service.Name, err) + } else { + log.Printf("Notification sent successfully for service %s", service.Name) + } +} + +// NotifyCustom sends a custom notification +func (sn *ServiceNotifier) NotifyCustom(notificationID, templateID, serviceName, status, message string) error { + payload := &NotificationPayload{ + ServiceName: serviceName, + Status: status, + Message: message, + Timestamp: time.Now(), + } + + return sn.manager.SendServiceNotification(payload, notificationID, templateID) +} \ No newline at end of file diff --git a/server/service-operation/notification/manager.go b/server/service-operation/notification/manager.go new file mode 100644 index 0000000..356f130 --- /dev/null +++ b/server/service-operation/notification/manager.go @@ -0,0 +1,72 @@ + +package notification + +import ( + // "log" + + "service-operation/pocketbase" +) + +// NotificationManager handles all notification operations +type NotificationManager struct { + pbClient *pocketbase.PocketBaseClient + services map[string]NotificationService + serverManager *ServerNotificationManager + uptimeManager *UptimeNotificationManager + sslManager *SSLNotificationManager +} + +// NewNotificationManager creates a new notification manager +func NewNotificationManager(pbClient *pocketbase.PocketBaseClient) *NotificationManager { + // Initialize notification services + services := make(map[string]NotificationService) + + // log.Printf("๐Ÿ”ง Initializing notification services...") + services["telegram"] = NewTelegramService() + services["signal"] = NewSignalService() + services["discord"] = NewDiscordService() + services["slack"] = NewSlackService() + services["google_chat"] = NewGoogleChatService() + services["email"] = NewEmailService() + services["webhook"] = NewWebhookService() + services["ntfy"] = NewNtfyService() + services["pushover"] = NewPushoverService() + services["notifiarr"] = NewNotifiarrService() + services["gotify"] = NewGotifyService() + services["matrix"] = NewMatrixService() + + // log.Printf("โœ… Notification services initialized: %v", getKeys(services)) + + // Create specialized managers + serverManager := NewServerNotificationManager(pbClient, services) + uptimeManager := NewUptimeNotificationManager(pbClient, services) + sslManager := NewSSLNotificationManager(pbClient, services) + + return &NotificationManager{ + pbClient: pbClient, + services: services, + serverManager: serverManager, + uptimeManager: uptimeManager, + sslManager: sslManager, + } +} + +// SendServiceNotification sends notification for a service based on its configuration +func (nm *NotificationManager) SendServiceNotification(payload *NotificationPayload, notificationID, templateID string) error { + return nm.serverManager.SendServiceNotification(payload, notificationID, templateID) +} + +// SendResourceNotification sends notification for specific resource alerts (CPU, RAM, Disk, etc.) +func (nm *NotificationManager) SendResourceNotification(payload *NotificationPayload, notificationID, templateID, resourceType string) error { + return nm.serverManager.SendResourceNotification(payload, notificationID, templateID, resourceType) +} + +// SendUptimeServiceNotification sends notification for uptime services using service templates +func (nm *NotificationManager) SendUptimeServiceNotification(payload *NotificationPayload, notificationID, templateID string) error { + return nm.uptimeManager.SendUptimeServiceNotification(payload, notificationID, templateID) +} + +// SendSSLNotification sends notification for SSL certificates using SSL templates +func (nm *NotificationManager) SendSSLNotification(payload *NotificationPayload, notificationID, templateID string) error { + return nm.sslManager.SendSSLNotification(payload, notificationID, templateID) +} \ No newline at end of file diff --git a/server/service-operation/notification/matrix.go b/server/service-operation/notification/matrix.go new file mode 100644 index 0000000..4e43ab9 --- /dev/null +++ b/server/service-operation/notification/matrix.go @@ -0,0 +1,379 @@ +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "net/http" + "net/url" + "strings" + "time" +) + +// MatrixService handles Matrix chat notifications +type MatrixService struct{} + +// NewMatrixService creates a new Matrix notification service +func NewMatrixService() *MatrixService { + return &MatrixService{} +} + +// matrixMessagePayload represents the payload for the Matrix Client-Server API +type matrixMessagePayload struct { + MsgType string `json:"msgtype"` + Body string `json:"body"` + Format string `json:"format,omitempty"` + FormattedBody string `json:"formatted_body,omitempty"` +} + +// SendNotification sends a formatted message to a Matrix room. +// It sends both a plain-text fallback body and an HTML formatted_body with +// severity-based colors so clients that support Matrix's custom HTML format +// display a rich, colored notification. +func (ms *MatrixService) SendNotification(config *AlertConfiguration, message string) error { + if config.MatrixHomeserver == "" || config.MatrixRoomID == "" || config.MatrixAccessToken == "" { + return fmt.Errorf("matrix homeserver, room ID and access token are required") + } + + // Encode the room ID for use in the URL path + encodedRoomID := url.PathEscape(config.MatrixRoomID) + + // Use Unix nanoseconds as a unique transaction ID to prevent duplicate messages + txnID := fmt.Sprintf("%d", time.Now().UnixNano()) + + apiURL := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s", + strings.TrimRight(config.MatrixHomeserver, "/"), + encodedRoomID, + txnID, + ) + + payload := matrixMessagePayload{ + MsgType: "m.text", + Body: message, + Format: "org.matrix.custom.html", + FormattedBody: ms.buildHTMLBody(message), + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("matrix: failed to marshal payload: %w", err) + } + + req, err := http.NewRequest(http.MethodPut, apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("matrix: failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+config.MatrixAccessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("matrix: HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("matrix: API returned status %d", resp.StatusCode) + } + + return nil +} + +// buildHTMLBody converts a plain-text notification message into Matrix-compatible HTML. +// The first line becomes a colored bold heading based on alert severity; subsequent +// detail lines are rendered as a bullet list. Clients that don't support HTML fall +// back to the plain Body field. +func (ms *MatrixService) buildHTMLBody(plainText string) string { + lines := strings.Split(strings.TrimSpace(plainText), "\n") + if len(lines) == 0 { + return "

" + html.EscapeString(plainText) + "

" + } + + title := strings.TrimSpace(lines[0]) + color := ms.statusColor(title) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("

%s

", color, html.EscapeString(title))) + + var items []string + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Strip leading bullet markers added by the plain-text generators + line = strings.TrimPrefix(line, "โ€ข") + line = strings.TrimPrefix(line, "-") + line = strings.TrimSpace(line) + if line != "" { + items = append(items, "
  • "+html.EscapeString(line)+"
  • ") + } + } + + if len(items) > 0 { + sb.WriteString("
      ") + for _, item := range items { + sb.WriteString(item) + } + sb.WriteString("
    ") + } + + sb.WriteString("

    CheckCle System Alert

    ") + return sb.String() +} + +// statusColor returns an HTML hex color string that reflects the alert severity +// detected from keywords and emoji in the message heading. +func (ms *MatrixService) statusColor(message string) string { + lower := strings.ToLower(message) + if strings.Contains(lower, "๐Ÿ”ด") || strings.Contains(lower, "down") || + strings.Contains(lower, "expired") || strings.Contains(lower, "failed") || + strings.Contains(lower, "critical") || strings.Contains(lower, "error") { + return "#E74C3C" // Red + } + if strings.Contains(lower, "๐ŸŸก") || strings.Contains(lower, "warning") || + strings.Contains(lower, "expiring") { + return "#F39C12" // Yellow + } + if strings.Contains(lower, "๐ŸŸ ") || strings.Contains(lower, "maintenance") || + strings.Contains(lower, "paused") { + return "#E67E22" // Orange + } + if strings.Contains(lower, "๐ŸŸข") || strings.Contains(lower, "up") || + strings.Contains(lower, "resolved") || strings.Contains(lower, "restored") || + strings.Contains(lower, "success") { + return "#2ECC71" // Green + } + return "#3498DB" // Blue โ€” info / default +} + +// SendServerNotification sends a server-specific notification via Matrix +func (ms *MatrixService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := ms.generateServerMessage(payload, template, resourceType) + return ms.SendNotification(config, message) +} + +// SendServiceNotification sends a service-specific notification via Matrix +func (ms *MatrixService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := ms.generateServiceMessage(payload, template) + return ms.SendNotification(config, message) +} + +func (ms *MatrixService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + if templateMessage == "" { + templateMessage = ms.generateDefaultServerMessage(payload, resourceType) + } + + return ms.replacePlaceholders(templateMessage, payload) +} + +func (ms *MatrixService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + if templateMessage == "" { + templateMessage = ms.generateDefaultUptimeMessage(payload) + } + + return ms.replacePlaceholders(templateMessage, payload) +} + +func (ms *MatrixService) replacePlaceholders(message string, payload *NotificationPayload) string { + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", ms.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", ms.safeString(payload.Hostname)) + + u := ms.safeString(payload.URL) + if u == "N/A" && payload.Host != "" { + u = payload.Host + } + message = strings.ReplaceAll(message, "${url}", u) + message = strings.ReplaceAll(message, "${domain}", ms.safeString(payload.Domain)) + + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + message = strings.ReplaceAll(message, "${region_name}", ms.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", ms.safeString(payload.AgentID)) + + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + message = strings.ReplaceAll(message, "${cpu_usage}", ms.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", ms.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", ms.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", ms.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", ms.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", ms.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", ms.safeString(payload.Threshold)) + message = strings.ReplaceAll(message, "${error_message}", ms.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", ms.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} + +func (ms *MatrixService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +func (ms *MatrixService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + case "maintenance", "paused": + statusEmoji = "๐ŸŸ " + } + + message := fmt.Sprintf("%s Service %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status)) + + details := []string{} + if payload.URL != "" { + details = append(details, fmt.Sprintf(" - Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf(" - Host: %s", payload.Host)) + } + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf(" - Type: %s", strings.ToUpper(payload.ServiceType))) + } + if payload.Port > 0 { + details = append(details, fmt.Sprintf(" - Port: %d", payload.Port)) + } + if payload.Domain != "" { + details = append(details, fmt.Sprintf(" - Domain: %s", payload.Domain)) + } + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf(" - Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, " - Response time: N/A") + } + if payload.RegionName != "" { + details = append(details, fmt.Sprintf(" - Region: %s", payload.RegionName)) + } + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf(" - Uptime: %d%%", payload.Uptime)) + } + details = append(details, fmt.Sprintf(" - Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + return message +} + +func (ms *MatrixService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + } + resource := strings.ToUpper(resourceType) + if resource == "" { + resource = "GENERAL" + } + return fmt.Sprintf("%s ๐Ÿ–ฅ๏ธ Server %s (%s) [%s] status: %s", statusEmoji, payload.ServiceName, payload.Hostname, resource, strings.ToUpper(payload.Status)) +} diff --git a/server/service-operation/notification/notifiarr.go b/server/service-operation/notification/notifiarr.go new file mode 100644 index 0000000..21a6538 --- /dev/null +++ b/server/service-operation/notification/notifiarr.go @@ -0,0 +1,416 @@ +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +// NotifiarrService handles Notifiarr notifications +type NotifiarrService struct{} + +// NewNotifiarrService creates a new Notifiarr notification service +func NewNotifiarrService() *NotifiarrService { + return &NotifiarrService{} +} + +// NotifiarrPayload represents the payload for Notifiarr API +type NotifiarrPayload struct { + Notification NotifiarrNotification `json:"notification"` + Discord NotifiarrDiscord `json:"discord"` +} + +type NotifiarrNotification struct { + Update bool `json:"update"` + Name string `json:"name"` + Event string `json:"event,omitempty"` +} + +type NotifiarrDiscord struct { + Color string `json:"color,omitempty"` + Ping NotifiarrPing `json:"ping,omitempty"` + Images NotifiarrImages `json:"images,omitempty"` + Text NotifiarrText `json:"text"` + IDs NotifiarrIDs `json:"ids,omitempty"` +} + +type NotifiarrPing struct { + PingUser int64 `json:"pingUser,omitempty"` + PingRole int64 `json:"pingRole,omitempty"` +} + +type NotifiarrImages struct { + Thumbnail string `json:"thumbnail,omitempty"` + Image string `json:"image,omitempty"` +} + +type NotifiarrText struct { + Title string `json:"title"` + Icon string `json:"icon,omitempty"` + Content string `json:"content,omitempty"` + Description string `json:"description"` + Fields []any `json:"fields,omitempty"` + Footer string `json:"footer,omitempty"` +} + +type NotifiarrIDs struct { + Channel int64 `json:"channel,omitempty"` +} + +// SendNotification sends a notification via Notifiarr +func (ns *NotifiarrService) SendNotification(config *AlertConfiguration, message string) error { + // fmt.Printf("๐Ÿ“ก [NOTIFIARR] Attempting to send notification...\n") + // fmt.Printf("๐Ÿ“ก [NOTIFIARR] Config - API Token present: %v\n", config.APIToken != "") + // fmt.Printf("๐Ÿ“ก [NOTIFIARR] Message: %s\n", message) + + if config.APIToken == "" { + return fmt.Errorf("notifiarr API token is required") + } + + // Parse channel ID if provided + var channelID int64 + if config.ChannelID != "" { + if parsed, err := strconv.ParseInt(config.ChannelID, 10, 64); err == nil { + channelID = parsed + } + } + + url := fmt.Sprintf("https://notifiarr.com/api/v1/notification/passthrough/%s", config.APIToken) + // fmt.Printf("๐Ÿ“ก [NOTIFIARR] API URL: %s\n", strings.Replace(url, config.APIToken, "[REDACTED]", 1)) + + // Create status-based color + color := ns.getStatusColor(message) + + payload := NotifiarrPayload{ + Notification: NotifiarrNotification{ + Update: false, + Name: "This is an automated notification from CheckCle System", + Event: "", + }, + Discord: NotifiarrDiscord{ + Color: color, + Text: NotifiarrText{ + Title: "Service Alert", + Description: message, + Footer: "CheckCle Monitoring System", + }, + IDs: NotifiarrIDs{ + Channel: channelID, + }, + }, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + // fmt.Printf("๐Ÿ“ก [NOTIFIARR] Sending POST request...\n") + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + // fmt.Printf("โŒ [NOTIFIARR] HTTP error: %v\n", err) + return err + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + // fmt.Printf("โŒ [NOTIFIARR] HTTP status error: %d\n", resp.StatusCode) + return fmt.Errorf("notifiarr API error: status code %d", resp.StatusCode) + } + + // fmt.Printf("โœ… [NOTIFIARR] Message sent successfully!\n") + return nil +} + +// SendServerNotification sends a server-specific notification via Notifiarr +func (ns *NotifiarrService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := ns.generateServerMessage(payload, template, resourceType) + return ns.SendNotification(config, message) +} + +// SendServiceNotification sends a service-specific notification via Notifiarr +func (ns *NotifiarrService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := ns.generateServiceMessage(payload, template) + return ns.SendNotification(config, message) +} + +// generateServerMessage creates a message for server notifications using server template +func (ns *NotifiarrService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + // Select appropriate template message based on status and resource type + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + // Handle resource-specific messages + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ns.generateDefaultServerMessage(payload, resourceType) + } + + return ns.replacePlaceholders(templateMessage, payload) +} + +// generateServiceMessage creates a message for service notifications using service template +func (ns *NotifiarrService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + // Select appropriate template message based on status + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ns.generateDefaultUptimeMessage(payload) + } + + return ns.replacePlaceholders(templateMessage, payload) +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (ns *NotifiarrService) replacePlaceholders(message string, payload *NotificationPayload) string { + // Replace basic placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", ns.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", ns.safeString(payload.Hostname)) + + // Replace URL with fallback to host + url := ns.safeString(payload.URL) + if url == "N/A" && payload.Host != "" { + url = payload.Host + } + message = strings.ReplaceAll(message, "${url}", url) + + // Replace domain + message = strings.ReplaceAll(message, "${domain}", ns.safeString(payload.Domain)) + + // Replace service type + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + // Replace region and agent info + message = strings.ReplaceAll(message, "${region_name}", ns.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", ns.safeString(payload.AgentID)) + + // Handle numeric fields safely + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + // Replace server monitoring fields + message = strings.ReplaceAll(message, "${cpu_usage}", ns.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", ns.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", ns.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", ns.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", ns.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", ns.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", ns.safeString(payload.Threshold)) + + // Replace error message - important for uptime services + message = strings.ReplaceAll(message, "${error_message}", ns.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", ns.safeString(payload.ErrorMessage)) + + // Replace time placeholders + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} + +// safeString returns the string value or "N/A" if empty +func (ns *NotifiarrService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// getStatusColor returns appropriate color for status +func (ns *NotifiarrService) getStatusColor(message string) string { + messageLower := strings.ToLower(message) + + if strings.Contains(messageLower, "down") || strings.Contains(messageLower, "error") || strings.Contains(messageLower, "failed") { + return "FF0000" // Red + } else if strings.Contains(messageLower, "up") || strings.Contains(messageLower, "restored") || strings.Contains(messageLower, "resolved") { + return "00FF00" // Green + } else if strings.Contains(messageLower, "warning") || strings.Contains(messageLower, "alert") { + return "FFA500" // Orange + } + + return "0099FF" // Blue (default) +} + +// generateDefaultUptimeMessage creates a default uptime message with proper formatting +func (ns *NotifiarrService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + // Status emoji mapping + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + case "maintenance", "paused": + statusEmoji = "๐ŸŸ " + } + + message := fmt.Sprintf("%s Service %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status)) + + // Build formatted details + details := []string{} + + // Add URL or host + if payload.URL != "" { + details = append(details, fmt.Sprintf(" - Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf(" - Host: %s", payload.Host)) + } + + // Add service type + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf(" - Type: %s", strings.ToUpper(payload.ServiceType))) + } + + // Add port if available + if payload.Port > 0 { + details = append(details, fmt.Sprintf(" - Port: %d", payload.Port)) + } + + // Add domain if available + if payload.Domain != "" { + details = append(details, fmt.Sprintf(" - Domain: %s", payload.Domain)) + } + + // Add response time + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf(" - Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, " - Response time: N/A") + } + + // Add region info + if payload.RegionName != "" { + details = append(details, fmt.Sprintf(" - Region: %s", payload.RegionName)) + } + + // Add agent info + if payload.AgentID != "" { + details = append(details, fmt.Sprintf(" - Agent: %s", payload.AgentID)) + } + + // Add uptime if available + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf(" - Uptime: %d%%", payload.Uptime)) + } + + // Add timestamp + details = append(details, fmt.Sprintf(" - Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + // Combine message with details + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + + return message +} + +// generateDefaultServerMessage creates a default server message +func (ns *NotifiarrService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + } + + return fmt.Sprintf("%s ๐Ÿ–ฅ๏ธ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} \ No newline at end of file diff --git a/server/service-operation/notification/ntfy.go b/server/service-operation/notification/ntfy.go new file mode 100644 index 0000000..9674445 --- /dev/null +++ b/server/service-operation/notification/ntfy.go @@ -0,0 +1,400 @@ + +package notification + +import ( + "fmt" + "net/http" + "strings" +) + +// NtfyService handles NTFY push notifications +type NtfyService struct{} + +// NewNtfyService creates a new NTFY notification service +func NewNtfyService() *NtfyService { + return &NtfyService{} +} + +// SendNotification sends a notification via NTFY +func (ns *NtfyService) SendNotification(config *AlertConfiguration, message string) error { + if config.NtfyEndpoint == "" { + return fmt.Errorf("ntfy endpoint is required") + } + + // Create HTTP request with plain text message + req, err := http.NewRequest("POST", config.NtfyEndpoint, strings.NewReader(message)) + if err != nil { + return fmt.Errorf("failed to create NTFY request: %v", err) + } + + // Set headers for NTFY + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + req.Header.Set("Title", "๐Ÿ”” CheckCle Service Alert") + req.Header.Set("Tags", "monitoring") + req.Header.Set("Priority", "default") + + // Add API token authentication if provided + if config.APIToken != "" { + req.Header.Set("Authorization", "Bearer "+config.APIToken) + } + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send NTFY notification: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("ntfy API error, status: %d", resp.StatusCode) + } + + return nil +} + +// SendServerNotification sends a server-specific notification via NTFY +func (ns *NtfyService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := ns.generateServerMessage(payload, template, resourceType) + return ns.SendNotificationWithDetails(config, message, "๐Ÿ”” Server Alert", resourceType) +} + +// SendServiceNotification sends a service-specific notification via NTFY +func (ns *NtfyService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := ns.generateServiceMessage(payload, template) + return ns.SendNotificationWithDetails(config, message, "๐Ÿ”” Service Alert", "service") +} + +// SendNotificationWithDetails sends a notification with custom title and tags +func (ns *NtfyService) SendNotificationWithDetails(config *AlertConfiguration, message, title, alertType string) error { + if config.NtfyEndpoint == "" { + return fmt.Errorf("ntfy endpoint is required") + } + + // Create HTTP request with plain text message + req, err := http.NewRequest("POST", config.NtfyEndpoint, strings.NewReader(message)) + if err != nil { + return fmt.Errorf("failed to create NTFY request: %v", err) + } + + // Set headers for NTFY with dynamic values + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + req.Header.Set("Title", title) + + // Set appropriate tags and priority based on alert type and message content + tags := "monitoring" + priority := "default" + + // Determine priority and tags based on message content + messageLower := strings.ToLower(message) + if strings.Contains(messageLower, "down") || strings.Contains(messageLower, "critical") || strings.Contains(messageLower, "expired") { + priority = "high" + tags = "rotating_light,warning" + } else if strings.Contains(messageLower, "warning") || strings.Contains(messageLower, "expiring") { + priority = "default" + tags = "warning" + } else if strings.Contains(messageLower, "up") || strings.Contains(messageLower, "restored") || strings.Contains(messageLower, "resolved") { + priority = "default" + tags = "white_check_mark" + } + + // Add resource type specific tags + switch alertType { + case "cpu": + tags += ",cpu" + case "ram", "memory": + tags += ",memory" + case "disk": + tags += ",floppy_disk" + case "network": + tags += ",globe_with_meridians" + case "service": + tags += ",gear" + case "ssl": + tags += ",lock" + } + + req.Header.Set("Tags", tags) + req.Header.Set("Priority", priority) + + // Add API token authentication if provided + if config.APIToken != "" { + req.Header.Set("Authorization", "Bearer "+config.APIToken) + } + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send NTFY notification: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("ntfy API error, status: %d", resp.StatusCode) + } + + return nil +} + +// generateServerMessage creates a message for server notifications using server template +func (ns *NtfyService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + // Select appropriate template message based on status and resource type + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + // Handle resource-specific messages + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ns.generateDefaultServerMessage(payload, resourceType) + } + + return ns.replacePlaceholders(templateMessage, payload) +} + +// generateServiceMessage creates a message for service notifications using service template +func (ns *NtfyService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + // Select appropriate template message based on status + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ns.generateDefaultUptimeMessage(payload) + } + + return ns.replacePlaceholders(templateMessage, payload) +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (ns *NtfyService) replacePlaceholders(message string, payload *NotificationPayload) string { + // Replace basic placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", ns.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", ns.safeString(payload.Hostname)) + + // Replace URL with fallback to host + url := ns.safeString(payload.URL) + if url == "N/A" && payload.Host != "" { + url = payload.Host + } + message = strings.ReplaceAll(message, "${url}", url) + + // Replace domain + message = strings.ReplaceAll(message, "${domain}", ns.safeString(payload.Domain)) + + // Replace service type + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + // Replace region and agent info + message = strings.ReplaceAll(message, "${region_name}", ns.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", ns.safeString(payload.AgentID)) + + // Handle numeric fields safely + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + // Replace server monitoring fields + message = strings.ReplaceAll(message, "${cpu_usage}", ns.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", ns.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", ns.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", ns.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", ns.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", ns.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", ns.safeString(payload.Threshold)) + + // Replace error message - important for uptime services + message = strings.ReplaceAll(message, "${error_message}", ns.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", ns.safeString(payload.ErrorMessage)) + + // Replace time placeholders + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} + +// safeString returns the string value or "N/A" if empty +func (ns *NtfyService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// generateDefaultUptimeMessage creates a default uptime message with proper formatting +func (ns *NtfyService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + // Status emoji mapping for NTFY + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + case "maintenance", "paused": + statusEmoji = "๐ŸŸ " + } + + message := fmt.Sprintf("%s Service %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status)) + + // Build formatted details + details := []string{} + + // Add URL or host + if payload.URL != "" { + details = append(details, fmt.Sprintf("Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf("Host: %s", payload.Host)) + } + + // Add service type + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf("Type: %s", strings.ToUpper(payload.ServiceType))) + } + + // Add port if available + if payload.Port > 0 { + details = append(details, fmt.Sprintf("Port: %d", payload.Port)) + } + + // Add domain if available + if payload.Domain != "" { + details = append(details, fmt.Sprintf("Domain: %s", payload.Domain)) + } + + // Add response time + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf("Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, "Response time: N/A") + } + + // Add region info + if payload.RegionName != "" { + details = append(details, fmt.Sprintf("Region: %s", payload.RegionName)) + } + + // Add agent info + if payload.AgentID != "" { + details = append(details, fmt.Sprintf("Agent: %s", payload.AgentID)) + } + + // Add uptime if available + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf("Uptime: %d%%", payload.Uptime)) + } + + // Add timestamp + details = append(details, fmt.Sprintf("Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + // Combine message with details + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + + return message +} + +// generateDefaultServerMessage creates a default server message +func (ns *NtfyService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + } + + return fmt.Sprintf("%s๐Ÿ–ฅ๏ธ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} \ No newline at end of file diff --git a/server/service-operation/notification/pushover.go b/server/service-operation/notification/pushover.go new file mode 100644 index 0000000..f7d737e --- /dev/null +++ b/server/service-operation/notification/pushover.go @@ -0,0 +1,360 @@ +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// PushoverService handles Pushover notifications +type PushoverService struct{} + +// NewPushoverService creates a new Pushover notification service +func NewPushoverService() *PushoverService { + return &PushoverService{} +} + +// PushoverPayload represents the payload for Pushover API +type PushoverPayload struct { + Token string `json:"token"` + User string `json:"user"` + Message string `json:"message"` + Title string `json:"title,omitempty"` +} + +// PushoverResponse represents the response from Pushover API +type PushoverResponse struct { + Status int `json:"status"` + Request string `json:"request"` + User string `json:"user,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +// SendNotification sends a notification via Pushover +func (ps *PushoverService) SendNotification(config *AlertConfiguration, message string) error { + // fmt.Printf("๐Ÿ“ฑ [PUSHOVER] Attempting to send notification...\n") + // fmt.Printf("๐Ÿ“ฑ [PUSHOVER] Config - API Token: %s, User Key: %s, Notify Name: %s\n", + // maskToken(config.APIToken), maskToken(config.UserKey), config.NotifyName) + // fmt.Printf("๐Ÿ“ฑ [PUSHOVER] Message: %s\n", message) + + if config.APIToken == "" || config.UserKey == "" { + return fmt.Errorf("Pushover API token and user key are required") + } + + // Prepare the payload + payload := PushoverPayload{ + Token: config.APIToken, + User: config.UserKey, + Message: message, + Title: config.NotifyName, // Use notify_name as the title + } + + jsonData, err := json.Marshal(payload) + if err != nil { + // fmt.Printf("โŒ [PUSHOVER] JSON marshal error: %v\n", err) + return err + } + + // fmt.Printf("๐Ÿ“ฑ [PUSHOVER] Payload: %s\n", string(jsonData)) + // fmt.Printf("๐Ÿ“ฑ [PUSHOVER] Sending POST request to Pushover API...\n") + + // Send the request to Pushover API + resp, err := http.Post("https://api.pushover.net/1/messages.json", "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + // fmt.Printf("โŒ [PUSHOVER] HTTP error: %v\n", err) + return err + } + defer resp.Body.Close() + + // Parse the response + var pushoverResp PushoverResponse + if err := json.NewDecoder(resp.Body).Decode(&pushoverResp); err != nil { + // fmt.Printf("โŒ [PUSHOVER] Response decode error: %v\n", err) + return err + } + + // fmt.Printf("๐Ÿ“ฑ [PUSHOVER] API response: %+v\n", pushoverResp) + + // Check if the request was successful + if pushoverResp.Status != 1 { + errorMsg := "Unknown error" + if len(pushoverResp.Errors) > 0 { + errorMsg = strings.Join(pushoverResp.Errors, ", ") + } + // fmt.Printf("โŒ [PUSHOVER] API error: %s\n", errorMsg) + return fmt.Errorf("Pushover API error: %s", errorMsg) + } + + // fmt.Printf("โœ… [PUSHOVER] Message sent successfully!\n") + return nil +} + +// SendServerNotification sends a server-specific notification via Pushover +func (ps *PushoverService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := ps.generateServerMessage(payload, template, resourceType) + return ps.SendNotification(config, message) +} + +// SendServiceNotification sends a service-specific notification via Pushover +func (ps *PushoverService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := ps.generateServiceMessage(payload, template) + return ps.SendNotification(config, message) +} + +// generateServerMessage creates a message for server notifications using server template +func (ps *PushoverService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + // Select appropriate template message based on status and resource type + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + // Handle resource-specific messages + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ps.generateDefaultServerMessage(payload, resourceType) + } + + return ps.replacePlaceholders(templateMessage, payload) +} + +// generateServiceMessage creates a message for service notifications using service template +func (ps *PushoverService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + // Select appropriate template message based on status + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ps.generateDefaultUptimeMessage(payload) + } + + return ps.replacePlaceholders(templateMessage, payload) +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (ps *PushoverService) replacePlaceholders(message string, payload *NotificationPayload) string { + // Replace basic placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${server_name}", payload.ServiceName) // server_name maps to service_name + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", ps.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", ps.safeString(payload.Hostname)) + + // Replace URL with fallback to host + url := ps.safeString(payload.URL) + if url == "N/A" && payload.Host != "" { + url = payload.Host + } + message = strings.ReplaceAll(message, "${url}", url) + + // Replace domain + message = strings.ReplaceAll(message, "${domain}", ps.safeString(payload.Domain)) + + // Replace service type + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + // Replace region and agent info + message = strings.ReplaceAll(message, "${region_name}", ps.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", ps.safeString(payload.AgentID)) + + // Handle numeric fields safely + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + // Replace server monitoring fields + message = strings.ReplaceAll(message, "${cpu_usage}", ps.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", ps.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", ps.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", ps.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", ps.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", ps.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", ps.safeString(payload.Threshold)) + + // Replace SSL certificate fields + message = strings.ReplaceAll(message, "${certificate_name}", ps.safeString(payload.CertificateName)) + message = strings.ReplaceAll(message, "${expiry_date}", ps.safeString(payload.ExpiryDate)) + message = strings.ReplaceAll(message, "${days_left}", ps.safeString(payload.DaysLeft)) + message = strings.ReplaceAll(message, "${issuer_cn}", ps.safeString(payload.IssuerCN)) + message = strings.ReplaceAll(message, "${issuer}", ps.safeString(payload.IssuerCN)) // alias + + // Replace error message - important for uptime services + message = strings.ReplaceAll(message, "${error_message}", ps.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", ps.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${message}", ps.safeString(payload.Message)) + + // Replace time placeholders + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} + +// safeString returns the string value or "N/A" if empty +func (ps *PushoverService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// generateDefaultUptimeMessage creates a default uptime message with proper formatting +func (ps *PushoverService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + message := fmt.Sprintf("Service %s is %s.", payload.ServiceName, strings.ToUpper(payload.Status)) + + // Build formatted details + details := []string{} + + // Add URL or host + if payload.URL != "" { + details = append(details, fmt.Sprintf("Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf("Host: %s", payload.Host)) + } + + // Add service type + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf("Type: %s", strings.ToUpper(payload.ServiceType))) + } + + // Add port if available + if payload.Port > 0 { + details = append(details, fmt.Sprintf("Port: %d", payload.Port)) + } + + // Add domain if available + if payload.Domain != "" { + details = append(details, fmt.Sprintf("Domain: %s", payload.Domain)) + } + + // Add response time + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf("Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, "Response time: N/A") + } + + // Add region info + if payload.RegionName != "" { + details = append(details, fmt.Sprintf("Region: %s", payload.RegionName)) + } + + // Add agent info + if payload.AgentID != "" { + details = append(details, fmt.Sprintf("Agent: %s", payload.AgentID)) + } + + // Add uptime if available + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf("Uptime: %d%%", payload.Uptime)) + } + + // Add timestamp + details = append(details, fmt.Sprintf("Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + // Combine message with details + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + + return message +} + +// generateDefaultServerMessage creates a default server message +func (ps *PushoverService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + return fmt.Sprintf("Server %s (%s) status: %s", payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} + +// maskToken masks a token for logging purposes +func maskToken(token string) string { + if len(token) <= 8 { + return strings.Repeat("*", len(token)) + } + return token[:4] + strings.Repeat("*", len(token)-8) + token[len(token)-4:] +} \ No newline at end of file diff --git a/server/service-operation/notification/server_manager.go b/server/service-operation/notification/server_manager.go new file mode 100644 index 0000000..55d64ed --- /dev/null +++ b/server/service-operation/notification/server_manager.go @@ -0,0 +1,480 @@ + +package notification + +import ( + "fmt" + "strings" + + "service-operation/pocketbase" +) + +// ServerNotificationManager handles server-specific notifications +type ServerNotificationManager struct { + pbClient *pocketbase.PocketBaseClient + services map[string]NotificationService +} + +// NewServerNotificationManager creates a new server notification manager +func NewServerNotificationManager(pbClient *pocketbase.PocketBaseClient, services map[string]NotificationService) *ServerNotificationManager { + return &ServerNotificationManager{ + pbClient: pbClient, + services: services, + } +} + +// SendServiceNotification sends notification for a service based on its configuration +func (snm *ServerNotificationManager) SendServiceNotification(payload *NotificationPayload, notificationID, templateID string) error { + // log.Printf("๐Ÿ“จ SendServiceNotification called with notification_id: %s, template_id: %s", notificationID, templateID) + // log.Printf("๐Ÿ“จ Payload: %+v", payload) + + if notificationID == "" { + // log.Printf("โŒ Notification ID is required but was empty") + return fmt.Errorf("notification ID is required") + } + + // Parse multiple notification IDs + notificationIDs := parseNotificationIDs(notificationID) + if len(notificationIDs) == 0 { + // log.Printf("โŒ No valid notification IDs found") + return fmt.Errorf("no valid notification IDs found") + } + + var errors []string + successCount := 0 + + // Send notification to each channel + for _, id := range notificationIDs { + // log.Printf("๐Ÿ“ค Processing notification ID: %s", id) + + // Check if notification is enabled + if !isNotificationEnabled(snm.pbClient, id) { + // log.Printf("โš ๏ธ Notification %s is disabled, skipping", id) + continue + } + + // Get alert configuration + alertConfig, err := getAlertConfiguration(snm.pbClient, id) + if err != nil { + // log.Printf("โŒ Failed to get alert configuration for %s: %v", id, err) + errors = append(errors, fmt.Sprintf("failed to get config for %s: %v", id, err)) + continue + } + + // Get template if provided + var template *ServerNotificationTemplate + if templateID != "" { + template, err = getNotificationTemplate(snm.pbClient, templateID) + if err != nil { + // log.Printf("โš ๏ธ Warning: failed to get template %s for notification %s: %v", templateID, id, err) + } + } + + // Generate message from template or use default + message := snm.generateMessage(payload, template) + // log.Printf("๐Ÿ“ Generated message for %s: %s", id, message) + + // Send notification using appropriate service + service, exists := snm.services[alertConfig.NotificationType] + if !exists { + // log.Printf("โŒ Unsupported notification type for %s: %s", id, alertConfig.NotificationType) + errors = append(errors, fmt.Sprintf("unsupported notification type for %s: %s", id, alertConfig.NotificationType)) + continue + } + + err = service.SendNotification(alertConfig, message) + if err != nil { + // log.Printf("โŒ Failed to send notification via %s for %s: %v", alertConfig.NotificationType, id, err) + errors = append(errors, fmt.Sprintf("failed to send via %s for %s: %v", alertConfig.NotificationType, id, err)) + } else { + // log.Printf("โœ… Successfully sent notification via %s for %s", alertConfig.NotificationType, id) + successCount++ + } + } + + // Report results + if successCount > 0 { + // log.Printf("โœ… Successfully sent %d out of %d notifications", successCount, len(notificationIDs)) + } + + if len(errors) > 0 { + // log.Printf("โŒ Errors occurred: %v", errors) + if successCount == 0 { + return fmt.Errorf("all notifications failed: %v", errors) + } + // If some succeeded, just log errors but don't return error + // log.Printf("โš ๏ธ Some notifications failed but %d succeeded", successCount) + } + + return nil +} + +// SendResourceNotification sends notification for specific resource alerts (CPU, RAM, Disk, etc.) +func (snm *ServerNotificationManager) SendResourceNotification(payload *NotificationPayload, notificationID, templateID, resourceType string) error { + // log.Printf("๐Ÿ“จ SendResourceNotification called for resource: %s", resourceType) + // log.Printf("๐Ÿ“จ Notification ID: %s, Template ID: %s", notificationID, templateID) + // log.Printf("๐Ÿ“จ Payload: %+v", payload) + + if notificationID == "" { + // log.Printf("โŒ Notification ID is required but was empty") + return fmt.Errorf("notification ID is required") + } + + // Parse multiple notification IDs + notificationIDs := parseNotificationIDs(notificationID) + if len(notificationIDs) == 0 { + // log.Printf("โŒ No valid notification IDs found") + return fmt.Errorf("no valid notification IDs found") + } + + var errors []string + successCount := 0 + + // Send notification to each channel + for _, id := range notificationIDs { + // log.Printf("๐Ÿ“ค Processing resource notification ID: %s", id) + + // Check if notification is enabled + if !isNotificationEnabled(snm.pbClient, id) { + // log.Printf("โš ๏ธ Notification %s is disabled, skipping", id) + continue + } + + // Get alert configuration + alertConfig, err := getAlertConfiguration(snm.pbClient, id) + if err != nil { + // log.Printf("โŒ Failed to get alert configuration for %s: %v", id, err) + errors = append(errors, fmt.Sprintf("failed to get config for %s: %v", id, err)) + continue + } + + // Get template if provided + var template *ServerNotificationTemplate + if templateID != "" { + template, err = getNotificationTemplate(snm.pbClient, templateID) + if err != nil { + // log.Printf("โš ๏ธ Warning: failed to get template %s for notification %s: %v", templateID, id, err) + } + } + + // Generate resource-specific message + message := snm.generateResourceMessage(payload, template, resourceType) + // log.Printf("๐Ÿ“ Generated resource message for %s: %s", id, message) + + // Send notification + service, exists := snm.services[alertConfig.NotificationType] + if !exists { + // log.Printf("โŒ Unsupported notification type for %s: %s", id, alertConfig.NotificationType) + errors = append(errors, fmt.Sprintf("unsupported notification type for %s: %s", id, alertConfig.NotificationType)) + continue + } + + err = service.SendNotification(alertConfig, message) + if err != nil { + // log.Printf("โŒ Failed to send resource notification via %s for %s: %v", alertConfig.NotificationType, id, err) + errors = append(errors, fmt.Sprintf("failed to send via %s for %s: %v", alertConfig.NotificationType, id, err)) + } else { + // log.Printf("โœ… Successfully sent resource notification via %s for %s", alertConfig.NotificationType, id) + successCount++ + } + } + + // Report results + if successCount > 0 { + // log.Printf("โœ… Successfully sent %d out of %d resource notifications", successCount, len(notificationIDs)) + } + + if len(errors) > 0 { + // log.Printf("โŒ Resource notification errors occurred: %v", errors) + if successCount == 0 { + return fmt.Errorf("all resource notifications failed: %v", errors) + } + // If some succeeded, just log errors but don't return error + // log.Printf("โš ๏ธ Some resource notifications failed but %d succeeded", successCount) + } + + return nil +} + +// generateResourceMessage creates notification message for specific resource alerts +func (snm *ServerNotificationManager) generateResourceMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var baseMessage string + + // Select appropriate resource message from template based on status + if template != nil { + // log.Printf("๐Ÿ”ง Using template for resource type: %s with status: %s", resourceType, payload.Status) + + // Check if this is a recovery/restore notification + if payload.Status == "up" { + // Use restore messages for recovery notifications + switch strings.ToLower(resourceType) { + case "cpu": + baseMessage = template.RestoreCPUMessage + case "ram", "memory": + baseMessage = template.RestoreRAMMessage + case "disk": + baseMessage = template.RestoreDiskMessage + case "network": + baseMessage = template.RestoreNetworkMessage + case "cpu_temp", "cpu_temperature": + baseMessage = template.RestoreCPUTempMessage + case "disk_io": + baseMessage = template.RestoreDiskIOMessage + default: + baseMessage = template.UpMessage // fallback to general up message + } + } else { + // Use regular alert messages for warning/down notifications + switch strings.ToLower(resourceType) { + case "cpu": + baseMessage = template.CPUMessage + case "ram", "memory": + baseMessage = template.RAMMessage + case "disk": + baseMessage = template.DiskMessage + case "network": + baseMessage = template.NetworkMessage + case "cpu_temp", "cpu_temperature": + baseMessage = template.CPUTempMessage + case "disk_io": + baseMessage = template.DiskIOMessage + default: + baseMessage = template.WarningMessage // fallback to warning message + } + } + + // log.Printf("๐Ÿ“ Template message selected for %s (status: %s): %s", resourceType, payload.Status, baseMessage) + } + + // If no template or empty message, use default + if baseMessage == "" { + // log.Printf("๐Ÿ”ง Using default message for resource %s", resourceType) + baseMessage = snm.getDefaultResourceMessage(payload, resourceType) + } + + // Replace placeholders with actual values + message := snm.replacePlaceholders(baseMessage, payload) + + // log.Printf("๐Ÿ“ Final resource message after placeholder replacement: %s", message) + return message +} + +// getDefaultResourceMessage provides default messages for resource alerts +func (snm *ServerNotificationManager) getDefaultResourceMessage(payload *NotificationPayload, resourceType string) string { + statusEmoji := "โš ๏ธ" + statusText := "Alert" + + if payload.Status == "up" { + statusEmoji = "โœ…" + statusText = "Recovery" + } else if payload.Status == "down" { + statusEmoji = "โŒ" + statusText = "Critical" + } + + switch strings.ToLower(resourceType) { + case "cpu": + if payload.Status == "up" { + return fmt.Sprintf("%s CPU %s: Server %s CPU usage has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.CPUUsage) + } + return fmt.Sprintf("%s CPU %s: Server %s CPU usage is %s", statusEmoji, statusText, payload.ServiceName, payload.CPUUsage) + case "ram", "memory": + if payload.Status == "up" { + return fmt.Sprintf("%s RAM %s: Server %s RAM usage has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.RAMUsage) + } + return fmt.Sprintf("%s RAM %s: Server %s RAM usage is %s", statusEmoji, statusText, payload.ServiceName, payload.RAMUsage) + case "disk": + if payload.Status == "up" { + return fmt.Sprintf("%s Disk %s: Server %s disk usage has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.DiskUsage) + } + return fmt.Sprintf("%s Disk %s: Server %s disk usage is %s", statusEmoji, statusText, payload.ServiceName, payload.DiskUsage) + case "network": + if payload.Status == "up" { + return fmt.Sprintf("%s Network %s: Server %s network usage has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.NetworkUsage) + } + return fmt.Sprintf("%s Network %s: Server %s network usage is %s", statusEmoji, statusText, payload.ServiceName, payload.NetworkUsage) + case "cpu_temp", "cpu_temperature": + if payload.Status == "up" { + return fmt.Sprintf("%s CPU Temperature %s: Server %s CPU temperature has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.CPUTemp) + } + return fmt.Sprintf("%s CPU Temperature %s: Server %s CPU temperature is %s", statusEmoji, statusText, payload.ServiceName, payload.CPUTemp) + case "disk_io": + if payload.Status == "up" { + return fmt.Sprintf("%s Disk I/O %s: Server %s disk I/O has returned to normal: %s", statusEmoji, statusText, payload.ServiceName, payload.DiskIO) + } + return fmt.Sprintf("%s Disk I/O %s: Server %s disk I/O is %s", statusEmoji, statusText, payload.ServiceName, payload.DiskIO) + default: + if payload.Status == "up" { + return fmt.Sprintf("%s Resource %s: Server %s %s has recovered", statusEmoji, statusText, payload.ServiceName, payload.Message) + } + return fmt.Sprintf("%s Resource %s: Server %s %s", statusEmoji, statusText, payload.ServiceName, payload.Message) + } +} + +// generateMessage creates notification message from template or default +func (snm *ServerNotificationManager) generateMessage(payload *NotificationPayload, template *ServerNotificationTemplate) string { + var baseMessage string + + // Select appropriate message based on status and template + if template != nil { + // log.Printf("๐Ÿ”ง Using template for status: %s", strings.ToLower(payload.Status)) + switch strings.ToLower(payload.Status) { + case "up": + baseMessage = template.UpMessage + case "down": + baseMessage = template.DownMessage + case "warning": + baseMessage = template.WarningMessage + case "paused": + baseMessage = template.PausedMessage + default: + baseMessage = template.UpMessage // fallback + } + // log.Printf("๐Ÿ“ Template message selected: %s", baseMessage) + } + + // If no template or empty message, use default + if baseMessage == "" { + // log.Printf("๐Ÿ”ง Using default message (no template or empty template message)") + baseMessage = snm.getDefaultMessage(payload) + } + + // Replace placeholders with actual values + message := snm.replacePlaceholders(baseMessage, payload) + + // log.Printf("๐Ÿ“ Final message after placeholder replacement: %s", message) + return message +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (snm *ServerNotificationManager) replacePlaceholders(message string, payload *NotificationPayload) string { + // Service/Server name placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${server_name}", payload.ServiceName) // For server notifications + + // Status placeholder + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + + // Host/IP placeholders + message = strings.ReplaceAll(message, "${host}", payload.Host) + message = strings.ReplaceAll(message, "${ip}", payload.Host) // Alternative placeholder + message = strings.ReplaceAll(message, "${ip_address}", payload.Host) // New server monitoring placeholder + + // Hostname placeholder + message = strings.ReplaceAll(message, "${hostname}", payload.Hostname) + + // Port placeholder + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + // Response time placeholder + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + // Timestamp placeholders + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${date}", payload.Timestamp.Format("2006-01-02")) + + // Service type placeholder + message = strings.ReplaceAll(message, "${service_type}", payload.ServiceType) + + // Error message placeholder + if payload.ErrorMessage != "" { + message = strings.ReplaceAll(message, "${error}", payload.ErrorMessage) + message = strings.ReplaceAll(message, "${error_message}", payload.ErrorMessage) + } else { + message = strings.ReplaceAll(message, "${error}", "") + message = strings.ReplaceAll(message, "${error_message}", "") + } + + // Custom message placeholder + if payload.Message != "" { + message = strings.ReplaceAll(message, "${message}", payload.Message) + } + + // Server monitoring specific placeholders + if payload.CPUUsage != "" { + message = strings.ReplaceAll(message, "${cpu_usage}", payload.CPUUsage) + } else { + message = strings.ReplaceAll(message, "${cpu_usage}", "N/A") + } + + if payload.RAMUsage != "" { + message = strings.ReplaceAll(message, "${ram_usage}", payload.RAMUsage) + } else { + message = strings.ReplaceAll(message, "${ram_usage}", "N/A") + } + + if payload.DiskUsage != "" { + message = strings.ReplaceAll(message, "${disk_usage}", payload.DiskUsage) + } else { + message = strings.ReplaceAll(message, "${disk_usage}", "N/A") + } + + if payload.NetworkUsage != "" { + message = strings.ReplaceAll(message, "${network_usage}", payload.NetworkUsage) + } else { + message = strings.ReplaceAll(message, "${network_usage}", "N/A") + } + + if payload.CPUTemp != "" { + message = strings.ReplaceAll(message, "${cpu_temp}", payload.CPUTemp) + } else { + message = strings.ReplaceAll(message, "${cpu_temp}", "N/A") + } + + if payload.DiskIO != "" { + message = strings.ReplaceAll(message, "${disk_io}", payload.DiskIO) + } else { + message = strings.ReplaceAll(message, "${disk_io}", "N/A") + } + + if payload.Threshold != "" { + message = strings.ReplaceAll(message, "${threshold}", payload.Threshold) + } else { + message = strings.ReplaceAll(message, "${threshold}", "N/A") + } + + return message +} + +// getDefaultMessage provides a default notification message +func (snm *ServerNotificationManager) getDefaultMessage(payload *NotificationPayload) string { + statusEmoji := "โœ…" + if payload.Status == "down" { + statusEmoji = "โŒ" + } else if payload.Status == "warning" { + statusEmoji = "โš ๏ธ" + } + + message := fmt.Sprintf("%s Server Alert\n\n", statusEmoji) + message += fmt.Sprintf("Server: %s\n", payload.ServiceName) + message += fmt.Sprintf("Status: %s\n", strings.ToUpper(payload.Status)) + message += fmt.Sprintf("IP Address: %s\n", payload.Host) + + if payload.CPUUsage != "" { + message += fmt.Sprintf("CPU Usage: %s\n", payload.CPUUsage) + } + + if payload.RAMUsage != "" { + message += fmt.Sprintf("RAM Usage: %s\n", payload.RAMUsage) + } + + if payload.DiskUsage != "" { + message += fmt.Sprintf("Disk Usage: %s\n", payload.DiskUsage) + } + + message += fmt.Sprintf("Time: %s\n", payload.Timestamp.Format("2006-01-02 15:04:05")) + + if payload.ErrorMessage != "" { + message += fmt.Sprintf("Error: %s\n", payload.ErrorMessage) + } + + return message +} \ No newline at end of file diff --git a/server/service-operation/notification/shared.go b/server/service-operation/notification/shared.go new file mode 100644 index 0000000..1ef89ee --- /dev/null +++ b/server/service-operation/notification/shared.go @@ -0,0 +1,141 @@ + +package notification + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + + "service-operation/pocketbase" +) + +// parseNotificationIDs parses comma-separated notification IDs +func parseNotificationIDs(notificationID string) []string { + if notificationID == "" { + return []string{} + } + + // Split by comma and trim whitespace + ids := strings.Split(notificationID, ",") + var cleanIDs []string + for _, id := range ids { + cleanID := strings.TrimSpace(id) + if cleanID != "" { + cleanIDs = append(cleanIDs, cleanID) + } + } + + //log.Printf("๐Ÿ“‹ Parsed notification IDs: %v", cleanIDs) + return cleanIDs +} + +// isNotificationEnabled checks if the notification is enabled +func isNotificationEnabled(pbClient *pocketbase.PocketBaseClient, notificationID string) bool { + config, err := getAlertConfiguration(pbClient, notificationID) + if err != nil { + //log.Printf("โŒ Error getting alert configuration for enabled check: %v", err) + return false + } + + enabled, err := strconv.ParseBool(config.Enabled) + if err != nil { + //log.Printf("โŒ Error parsing enabled field: %v", err) + return false + } + + //log.Printf("โ„น๏ธ Notification %s enabled status: %t", notificationID, enabled) + return enabled +} + +// getAlertConfiguration fetches alert configuration from PocketBase +func getAlertConfiguration(pbClient *pocketbase.PocketBaseClient, notificationID string) (*AlertConfiguration, error) { + url := fmt.Sprintf("%s/api/collections/alert_configurations/records/%s", pbClient.GetBaseURL(), notificationID) + //log.Printf("๐ŸŒ Fetching alert configuration from: %s", url) + + resp, err := http.Get(url) + if err != nil { + //log.Printf("โŒ HTTP error fetching alert configuration: %v", err) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("โŒ Failed to fetch alert configuration, status: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch alert configuration, status: %d", resp.StatusCode) + } + + var config AlertConfiguration + if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { + //log.Printf("โŒ Error decoding alert configuration JSON: %v", err) + return nil, err + } + + //log.Printf("โœ… Successfully fetched alert configuration: %+v", config) + return &config, nil +} + +// getNotificationTemplate fetches server notification template from PocketBase +func getNotificationTemplate(pbClient *pocketbase.PocketBaseClient, templateID string) (*ServerNotificationTemplate, error) { + url := fmt.Sprintf("%s/api/collections/server_notification_templates/records/%s", pbClient.GetBaseURL(), templateID) + //log.Printf("๐ŸŒ Fetching notification template from: %s", url) + + resp, err := http.Get(url) + if err != nil { + //log.Printf("โŒ HTTP error fetching notification template: %v", err) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("โŒ Failed to fetch notification template, status: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch notification template, status: %d", resp.StatusCode) + } + + var template ServerNotificationTemplate + if err := json.NewDecoder(resp.Body).Decode(&template); err != nil { + //log.Printf("โŒ Error decoding notification template JSON: %v", err) + return nil, err + } + + //log.Printf("โœ… Successfully fetched notification template: %+v", template) + return &template, nil +} + +// getServiceNotificationTemplate fetches service notification template from PocketBase +func getServiceNotificationTemplate(pbClient *pocketbase.PocketBaseClient, templateID string) (*ServiceNotificationTemplate, error) { + url := fmt.Sprintf("%s/api/collections/service_notification_templates/records/%s", pbClient.GetBaseURL(), templateID) + //log.Printf("๐ŸŒ Fetching service notification template from: %s", url) + + resp, err := http.Get(url) + if err != nil { + //log.Printf("โŒ HTTP error fetching service notification template: %v", err) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + //log.Printf("โŒ Failed to fetch service notification template, status: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch service notification template, status: %d", resp.StatusCode) + } + + var template ServiceNotificationTemplate + if err := json.NewDecoder(resp.Body).Decode(&template); err != nil { + //log.Printf("โŒ Error decoding service notification template JSON: %v", err) + return nil, err + } + + //log.Printf("โœ… Successfully fetched service notification template: %+v", template) + return &template, nil +} + +// Helper function to get map keys +func getKeys(m map[string]NotificationService) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} \ No newline at end of file diff --git a/server/service-operation/notification/signal.go b/server/service-operation/notification/signal.go new file mode 100644 index 0000000..1eb02bb --- /dev/null +++ b/server/service-operation/notification/signal.go @@ -0,0 +1,371 @@ + +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// SignalService handles Signal notifications +type SignalService struct{} + +// NewSignalService creates a new Signal notification service +func NewSignalService() *SignalService { + return &SignalService{} +} + +// SignalPayload represents the payload for Signal REST API +type SignalPayload struct { + Number string `json:"number"` + Recipients []string `json:"recipients"` + Message string `json:"message"` +} + +// SendNotification sends a notification via Signal REST API +func (ss *SignalService) SendNotification(config *AlertConfiguration, message string) error { + // fmt.Printf("๐Ÿ“ฑ [SIGNAL] Attempting to send notification...\n") + // fmt.Printf("๐Ÿ“ฑ [SIGNAL] Config - Phone Number: %s, API Endpoint: %s\n", config.SignalNumber, config.SignalAPIEndpoint) + // fmt.Printf("๐Ÿ“ฑ [SIGNAL] Message: %s\n", message) + + if config.SignalNumber == "" { + return fmt.Errorf("signal phone number is required") + } + + if config.SignalAPIEndpoint == "" { + return fmt.Errorf("signal API endpoint is required") + } + + // Use the configured endpoint directly (it should already include the full path like /v2/send) + apiURL := config.SignalAPIEndpoint + // fmt.Printf("๐Ÿ“ฑ [SIGNAL] API URL: %s\n", apiURL) + + // Prepare payload for Signal REST API + payload := SignalPayload{ + Number: config.SignalNumber, // This should be the sender's number (registered with signal-cli) + Recipients: []string{config.SignalNumber}, // Send to the same number for now + Message: message, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + // fmt.Printf("โŒ [SIGNAL] JSON marshal error: %v\n", err) + return fmt.Errorf("failed to marshal signal payload: %v", err) + } + + // fmt.Printf("๐Ÿ“ฑ [SIGNAL] Payload: %s\n", string(jsonData)) + // fmt.Printf("๐Ÿ“ฑ [SIGNAL] Sending POST request...\n") + + // Send POST request to Signal REST API + resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + // fmt.Printf("โŒ [SIGNAL] HTTP error: %v\n", err) + return fmt.Errorf("failed to send signal request: %v", err) + } + defer resp.Body.Close() + + // fmt.Printf("๐Ÿ“ฑ [SIGNAL] Response Status: %d\n", resp.StatusCode) + + // Check if the request was successful + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + // fmt.Printf("โŒ [SIGNAL] HTTP Status error: %d\n", resp.StatusCode) + + // Try to read error response + var errorResponse map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err == nil { + // fmt.Printf("โŒ [SIGNAL] Error response: %+v\n", errorResponse) + return fmt.Errorf("signal API error (status %d): %v", resp.StatusCode, errorResponse) + } + + return fmt.Errorf("signal API returned status %d", resp.StatusCode) + } + + // Parse response + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + // fmt.Printf("โŒ [SIGNAL] JSON decode error: %v\n", err) + // Don't fail if we can't decode response but got success status + // fmt.Printf("โœ… [SIGNAL] Message sent successfully (response decode failed but status was OK)!\n") + return nil + } + + // fmt.Printf("๐Ÿ“ฑ [SIGNAL] Response: %+v\n", result) + // fmt.Printf("โœ… [SIGNAL] Message sent successfully!\n") + return nil +} + +// SendServerNotification sends a server-specific notification via Signal +func (ss *SignalService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := ss.generateServerMessage(payload, template, resourceType) + return ss.SendNotification(config, message) +} + +// SendServiceNotification sends a service-specific notification via Signal +func (ss *SignalService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := ss.generateServiceMessage(payload, template) + return ss.SendNotification(config, message) +} + +// generateServerMessage creates a message for server notifications using server template +func (ss *SignalService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + // Select appropriate template message based on status and resource type + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + // Handle resource-specific messages + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ss.generateDefaultServerMessage(payload, resourceType) + } + + return ss.replacePlaceholders(templateMessage, payload) +} + +// generateServiceMessage creates a message for service notifications using service template +func (ss *SignalService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + // Select appropriate template message based on status + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ss.generateDefaultUptimeMessage(payload) + } + + return ss.replacePlaceholders(templateMessage, payload) +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (ss *SignalService) replacePlaceholders(message string, payload *NotificationPayload) string { + // Replace basic placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", ss.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", ss.safeString(payload.Hostname)) + + // Replace URL with fallback to host + url := ss.safeString(payload.URL) + if url == "N/A" && payload.Host != "" { + url = payload.Host + } + message = strings.ReplaceAll(message, "${url}", url) + + // Replace domain + message = strings.ReplaceAll(message, "${domain}", ss.safeString(payload.Domain)) + + // Replace service type + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + // Replace region and agent info + message = strings.ReplaceAll(message, "${region_name}", ss.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", ss.safeString(payload.AgentID)) + + // Handle numeric fields safely + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + // Replace server monitoring fields + message = strings.ReplaceAll(message, "${cpu_usage}", ss.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", ss.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", ss.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", ss.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", ss.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", ss.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", ss.safeString(payload.Threshold)) + + // Replace error message - important for uptime services + message = strings.ReplaceAll(message, "${error_message}", ss.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", ss.safeString(payload.ErrorMessage)) + + // Replace time placeholders + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} + +// safeString returns the string value or "N/A" if empty +func (ss *SignalService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// generateDefaultUptimeMessage creates a default uptime message with proper formatting +func (ss *SignalService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + // Status emoji mapping + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + case "maintenance", "paused": + statusEmoji = "๐ŸŸ " + } + + message := fmt.Sprintf("%s Service %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status)) + + // Build formatted details + details := []string{} + + // Add URL or host + if payload.URL != "" { + details = append(details, fmt.Sprintf(" - Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf(" - Host: %s", payload.Host)) + } + + // Add service type + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf(" - Type: %s", strings.ToUpper(payload.ServiceType))) + } + + // Add port if available + if payload.Port > 0 { + details = append(details, fmt.Sprintf(" - Port: %d", payload.Port)) + } + + // Add domain if available + if payload.Domain != "" { + details = append(details, fmt.Sprintf(" - Domain: %s", payload.Domain)) + } + + // Add response time + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf(" - Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, " - Response time: N/A") + } + + // Add region info + if payload.RegionName != "" { + details = append(details, fmt.Sprintf(" - Region: %s", payload.RegionName)) + } + + // Add agent info + if payload.AgentID != "" { + details = append(details, fmt.Sprintf(" - Agent: %s", payload.AgentID)) + } + + // Add uptime if available + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf(" - Uptime: %d%%", payload.Uptime)) + } + + // Add timestamp + details = append(details, fmt.Sprintf(" - Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + // Combine message with details + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + + return message +} + +// generateDefaultServerMessage creates a default server message +func (ss *SignalService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + } + + return fmt.Sprintf("%s ๐Ÿ–ฅ๏ธ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} \ No newline at end of file diff --git a/server/service-operation/notification/slack.go b/server/service-operation/notification/slack.go new file mode 100644 index 0000000..3a9d825 --- /dev/null +++ b/server/service-operation/notification/slack.go @@ -0,0 +1,538 @@ +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// SlackService handles Slack notifications +type SlackService struct{} + +// NewSlackService creates a new Slack notification service +func NewSlackService() *SlackService { + return &SlackService{} +} + +// SlackPayload represents the payload for Slack webhook +type SlackPayload struct { + Text string `json:"text"` + Username string `json:"username,omitempty"` + Channel string `json:"channel,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + Attachments []SlackAttachment `json:"attachments,omitempty"` + Blocks []SlackBlock `json:"blocks,omitempty"` +} + +// SlackAttachment represents a Slack attachment +type SlackAttachment struct { + Color string `json:"color,omitempty"` + Title string `json:"title,omitempty"` + Text string `json:"text,omitempty"` + Timestamp int64 `json:"ts,omitempty"` + Fields []SlackField `json:"fields,omitempty"` +} + +// SlackField represents a field in Slack attachment +type SlackField struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +// SlackBlock represents a Slack block element +type SlackBlock struct { + Type string `json:"type"` + Text *SlackText `json:"text,omitempty"` +} + +// SlackText represents text in Slack blocks +type SlackText struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// SendNotification sends a notification via Slack webhook +func (ss *SlackService) SendNotification(config *AlertConfiguration, message string) error { + if config.SlackWebhookURL == "" { + return fmt.Errorf("slack webhook URL is required") + } + + // Parse and format the message for better Slack presentation + formattedPayload := ss.createSlackPayload(config, message) + + jsonData, err := json.Marshal(formattedPayload) + if err != nil { + return err + } + + resp, err := http.Post(config.SlackWebhookURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("slack webhook error, status: %d", resp.StatusCode) + } + + return nil +} + +// SendServerNotification sends a server-specific notification via Slack +func (ss *SlackService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := ss.generateServerMessage(payload, template, resourceType) + return ss.SendNotification(config, message) +} + +// SendServiceNotification sends a service-specific notification via Slack +func (ss *SlackService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := ss.generateServiceMessage(payload, template) + return ss.SendNotification(config, message) +} + +// createSlackPayload creates a properly formatted Slack payload with rich formatting +func (ss *SlackService) createSlackPayload(config *AlertConfiguration, message string) SlackPayload { + // Determine color based on message content + color := ss.getColorFromMessage(message) + + // Parse message for structured presentation + title, fields := ss.parseMessageForSlack(message) + + payload := SlackPayload{ + Username: config.NotifyName, + IconEmoji: ss.getEmojiFromMessage(message), + Text: title, + } + + // Create attachment for better formatting + if len(fields) > 0 { + attachment := SlackAttachment{ + Color: color, + Title: title, + Timestamp: time.Now().Unix(), + Fields: fields, + } + payload.Attachments = []SlackAttachment{attachment} + payload.Text = "" // Clear text when using attachments + } + + return payload +} + +// parseMessageForSlack parses the message to extract title and create structured fields +func (ss *SlackService) parseMessageForSlack(message string) (string, []SlackField) { + lines := strings.Split(message, "\n") + if len(lines) == 0 { + return message, nil + } + + title := strings.TrimSpace(lines[0]) + var fields []SlackField + + // Process bullet points and key-value pairs + for i, line := range lines { + if i == 0 { + continue // Skip title + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Handle bullet points (โ€ข, -, *) + if strings.HasPrefix(line, "โ€ข") || strings.HasPrefix(line, "-") || strings.HasPrefix(line, "*") { + // Remove bullet and clean up + cleaned := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(line, "โ€ข"), "-"), "*")) + + // Try to split on colon for key-value pairs + if strings.Contains(cleaned, ":") { + parts := strings.SplitN(cleaned, ":", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + fields = append(fields, SlackField{ + Title: key, + Value: value, + Short: len(value) < 30, // Short fields for compact display + }) + } + } else { + // Add as a single field + fields = append(fields, SlackField{ + Title: "Info", + Value: cleaned, + Short: false, + }) + } + } else if strings.Contains(line, ":") { + // Direct key-value pairs + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + fields = append(fields, SlackField{ + Title: key, + Value: value, + Short: len(value) < 30, + }) + } + } + } + + return title, fields +} + +// getColorFromMessage determines the appropriate color based on message content +func (ss *SlackService) getColorFromMessage(message string) string { + lowerMsg := strings.ToLower(message) + + // Critical/Error states - Red + if strings.Contains(lowerMsg, "down") || strings.Contains(lowerMsg, "failed") || + strings.Contains(lowerMsg, "error") || strings.Contains(lowerMsg, "expired") || + strings.Contains(lowerMsg, "๐Ÿ”ด") || strings.Contains(lowerMsg, "๐Ÿšจ") { + return "#FF0000" // Red + } + + // Warning states - Yellow/Orange + if strings.Contains(lowerMsg, "warning") || strings.Contains(lowerMsg, "expiring") || + strings.Contains(lowerMsg, "๐ŸŸก") || strings.Contains(lowerMsg, "โš ๏ธ") { + return "#FFA500" // Orange + } + + // Success states - Green + if strings.Contains(lowerMsg, "up") || strings.Contains(lowerMsg, "restored") || + strings.Contains(lowerMsg, "resolved") || strings.Contains(lowerMsg, "๐ŸŸข") || + strings.Contains(lowerMsg, "โœ…") { + return "#00FF00" // Green + } + + // Maintenance/Info states - Blue + if strings.Contains(lowerMsg, "maintenance") || strings.Contains(lowerMsg, "paused") || + strings.Contains(lowerMsg, "๐ŸŸ ") || strings.Contains(lowerMsg, "๐Ÿ”ต") { + return "#0080FF" // Blue + } + + return "#808080" // Default gray +} + +// getEmojiFromMessage extracts or determines appropriate emoji for the message +func (ss *SlackService) getEmojiFromMessage(message string) string { + lowerMsg := strings.ToLower(message) + + // Check for specific service types first + if strings.Contains(lowerMsg, "server") { + if strings.Contains(lowerMsg, "down") || strings.Contains(lowerMsg, "failed") { + return ":red_circle:" + } else if strings.Contains(lowerMsg, "warning") { + return ":warning:" + } else if strings.Contains(lowerMsg, "up") || strings.Contains(lowerMsg, "restored") { + return ":white_check_mark:" + } + return ":desktop_computer:" + } + + // SSL Certificate notifications + if strings.Contains(lowerMsg, "certificate") || strings.Contains(lowerMsg, "ssl") { + if strings.Contains(lowerMsg, "expired") { + return ":no_entry:" + } else if strings.Contains(lowerMsg, "expiring") { + return ":warning:" + } + return ":lock:" + } + + // General status-based emojis + if strings.Contains(lowerMsg, "down") || strings.Contains(lowerMsg, "failed") { + return ":red_circle:" + } else if strings.Contains(lowerMsg, "warning") { + return ":warning:" + } else if strings.Contains(lowerMsg, "up") || strings.Contains(lowerMsg, "restored") { + return ":white_check_mark:" + } else if strings.Contains(lowerMsg, "maintenance") { + return ":construction:" + } + + return ":information_source:" // Default info emoji +} + +// generateServerMessage creates a message for server notifications using server template +func (ss *SlackService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + // Select appropriate template message based on status and resource type + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + // Handle resource-specific messages + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ss.generateDefaultServerMessage(payload, resourceType) + } + + return ss.replacePlaceholders(templateMessage, payload) +} + +// generateServiceMessage creates a message for service notifications using service template +func (ss *SlackService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + // Select appropriate template message based on status + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ss.generateDefaultUptimeMessage(payload) + } + + return ss.replacePlaceholders(templateMessage, payload) +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (ss *SlackService) replacePlaceholders(message string, payload *NotificationPayload) string { + // Replace basic placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", ss.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", ss.safeString(payload.Hostname)) + + // Replace URL with fallback to host + url := ss.safeString(payload.URL) + if url == "N/A" && payload.Host != "" { + url = payload.Host + } + message = strings.ReplaceAll(message, "${url}", url) + + // Replace domain + message = strings.ReplaceAll(message, "${domain}", ss.safeString(payload.Domain)) + + // Replace service type + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + // Replace region and agent info + message = strings.ReplaceAll(message, "${region_name}", ss.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", ss.safeString(payload.AgentID)) + + // Handle numeric fields safely + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + // Replace server monitoring fields + message = strings.ReplaceAll(message, "${cpu_usage}", ss.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", ss.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", ss.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", ss.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", ss.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", ss.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", ss.safeString(payload.Threshold)) + + // Replace SSL certificate fields + message = strings.ReplaceAll(message, "${certificate_name}", ss.safeString(payload.CertificateName)) + message = strings.ReplaceAll(message, "${expiry_date}", ss.safeString(payload.ExpiryDate)) + message = strings.ReplaceAll(message, "${days_left}", ss.safeString(payload.DaysLeft)) + message = strings.ReplaceAll(message, "${issuer_cn}", ss.safeString(payload.IssuerCN)) + message = strings.ReplaceAll(message, "${serial_number}", ss.safeString(payload.SerialNumber)) + + // Replace error message - important for uptime services + message = strings.ReplaceAll(message, "${error_message}", ss.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", ss.safeString(payload.ErrorMessage)) + + // Replace time placeholders + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${date}", payload.Timestamp.Format("2006-01-02")) + + // Replace message placeholder + if payload.Message != "" { + message = strings.ReplaceAll(message, "${message}", payload.Message) + } else { + message = strings.ReplaceAll(message, "${message}", "N/A") + } + + return message +} + +// safeString returns the string value or "N/A" if empty +func (ss *SlackService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// generateDefaultUptimeMessage creates a default uptime message with proper formatting +func (ss *SlackService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + // Status emoji mapping + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + case "maintenance", "paused": + statusEmoji = "๐ŸŸ " + } + + message := fmt.Sprintf("%s Service %s is %s", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status)) + + // Build formatted details + details := []string{} + + // Add URL or host + if payload.URL != "" { + details = append(details, fmt.Sprintf("โ€ข Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf("โ€ข Host: %s", payload.Host)) + } + + // Add service type + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf("โ€ข Type: %s", strings.ToUpper(payload.ServiceType))) + } + + // Add port if available + if payload.Port > 0 { + details = append(details, fmt.Sprintf("โ€ข Port: %d", payload.Port)) + } + + // Add domain if available + if payload.Domain != "" { + details = append(details, fmt.Sprintf("โ€ข Domain: %s", payload.Domain)) + } + + // Add response time + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf("โ€ข Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, "โ€ข Response time: N/A") + } + + // Add region info + if payload.RegionName != "" { + details = append(details, fmt.Sprintf("โ€ข Region: %s", payload.RegionName)) + } + + // Add agent info + if payload.AgentID != "" { + details = append(details, fmt.Sprintf("โ€ข Agent: %s", payload.AgentID)) + } + + // Add uptime if available + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf("โ€ข Uptime: %d%%", payload.Uptime)) + } + + // Add timestamp + details = append(details, fmt.Sprintf("โ€ข Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + // Combine message with details + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + + return message +} + +// generateDefaultServerMessage creates a default server message +func (ss *SlackService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + } + + return fmt.Sprintf("%s ๐Ÿ–ฅ๏ธ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} \ No newline at end of file diff --git a/server/service-operation/notification/ssl_manager.go b/server/service-operation/notification/ssl_manager.go new file mode 100644 index 0000000..c8dcf22 --- /dev/null +++ b/server/service-operation/notification/ssl_manager.go @@ -0,0 +1,285 @@ + +package notification + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "service-operation/pocketbase" +) + +// SSLNotificationTemplate represents an SSL notification template +type SSLNotificationTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + Expired string `json:"expired"` + ExpiringSoon string `json:"exiring_soon"` + Warning string `json:"warning"` + Placeholder string `json:"placeholder"` +} + +// SSLNotificationManager handles SSL certificate notifications +type SSLNotificationManager struct { + pbClient *pocketbase.PocketBaseClient + services map[string]NotificationService +} + +// NewSSLNotificationManager creates a new SSL notification manager +func NewSSLNotificationManager(pbClient *pocketbase.PocketBaseClient, services map[string]NotificationService) *SSLNotificationManager { + return &SSLNotificationManager{ + pbClient: pbClient, + services: services, + } +} + +// SendSSLNotification sends notification for SSL certificate status +func (snm *SSLNotificationManager) SendSSLNotification(payload *NotificationPayload, notificationID, templateID string) error { + // log.Printf("๐Ÿ“จ [SSL-MANAGER] IMMEDIATE send for certificate: %s, status: %s", payload.Domain, payload.Status) + // log.Printf("๐Ÿ“จ [SSL-MANAGER] Notification ID: %s, Template ID: %s", notificationID, templateID) + + if notificationID == "" { + return fmt.Errorf("notification ID required for SSL certificate: %s", payload.Domain) + } + + // Parse notification IDs + notificationIDs := parseNotificationIDs(notificationID) + if len(notificationIDs) == 0 { + return fmt.Errorf("no valid notification IDs for SSL certificate: %s", payload.Domain) + } + + var errors []string + successCount := 0 + + // Send to each notification channel IMMEDIATELY + for _, id := range notificationIDs { + // log.Printf("๐Ÿ“ค [SSL-SEND] Processing notification ID: %s for certificate %s", id, payload.Domain) + + // Check if enabled + if !isNotificationEnabled(snm.pbClient, id) { + // log.Printf("โš ๏ธ Notification %s disabled for SSL certificate %s, skipping", id, payload.Domain) + _ = id + continue + } + + // Get alert configuration + alertConfig, err := getAlertConfiguration(snm.pbClient, id) + if err != nil { + // log.Printf("โŒ Failed to get alert config for %s (certificate: %s): %v", id, payload.Domain, err) + errors = append(errors, fmt.Sprintf("config error %s: %v", id, err)) + continue + } + + // log.Printf("๐Ÿ“‹ [SSL-CONFIG] Alert config for %s: Type=%s, ChatID=%s, Token present=%v", + // id, alertConfig.NotificationType, alertConfig.TelegramChatID, alertConfig.BotToken != "") + + // Get SSL template + var sslTemplate *SSLNotificationTemplate + if templateID != "" { + sslTemplate, err = snm.getSSLNotificationTemplate(templateID) + if err != nil { + // log.Printf("โš ๏ธ Template error for %s (certificate: %s): %v", templateID, payload.Domain, err) + _ = err + } else { + // log.Printf("๐Ÿ“„ [SSL-TEMPLATE] Retrieved template: Expired=%s, ExpiringSoon=%s, Warning=%s", + // sslTemplate.Expired, sslTemplate.ExpiringSoon, sslTemplate.Warning) + _ = sslTemplate + } + } + + // Generate message + message := snm.generateSSLMessage(payload, sslTemplate) + // log.Printf("๐Ÿ“ [SSL-MESSAGE] Generated for %s (%s): %s", payload.Domain, id, message) + + // Get notification service + service, exists := snm.services[alertConfig.NotificationType] + if !exists { + // log.Printf("โŒ Unsupported notification type for SSL: %s", alertConfig.NotificationType) + errors = append(errors, fmt.Sprintf("unsupported type %s", alertConfig.NotificationType)) + continue + } + + // SEND IMMEDIATELY - NO DELAYS + // log.Printf("โšก [SSL-TELEGRAM] Sending via %s for SSL certificate %s", alertConfig.NotificationType, payload.Domain) + err = service.SendNotification(alertConfig, message) + if err != nil { + // log.Printf("โŒ [SSL-FAILED] Failed to send via %s for %s: %v", alertConfig.NotificationType, payload.Domain, err) + errors = append(errors, fmt.Sprintf("send failed %s: %v", alertConfig.NotificationType, err)) + } else { + // log.Printf("โœ… [SSL-SUCCESS] Successfully sent via %s for %s", alertConfig.NotificationType, payload.Domain) + successCount++ + } + + _ = alertConfig + _ = message + } + + // Report results + if successCount > 0 { + // log.Printf("โœ… [SSL-FINAL] Sent %d/%d SSL notifications for %s", successCount, len(notificationIDs), payload.Domain) + _ = successCount + _ = notificationIDs + } + + if len(errors) > 0 && successCount == 0 { + return fmt.Errorf("all SSL notifications failed for %s: %v", payload.Domain, errors) + } + + return nil +} + +// getSSLNotificationTemplate fetches SSL notification template from PocketBase +func (snm *SSLNotificationManager) getSSLNotificationTemplate(templateID string) (*SSLNotificationTemplate, error) { + url := fmt.Sprintf("%s/api/collections/ssl_notification_templates/records/%s", snm.pbClient.GetBaseURL(), templateID) + // log.Printf("๐ŸŒ Fetching SSL notification template from: %s", url) + + resp, err := http.Get(url) + if err != nil { + // log.Printf("โŒ HTTP error fetching SSL notification template: %v", err) + _ = err + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // log.Printf("โŒ Failed to fetch SSL notification template, status: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch SSL notification template, status: %d", resp.StatusCode) + } + + var template SSLNotificationTemplate + if err := json.NewDecoder(resp.Body).Decode(&template); err != nil { + // log.Printf("โŒ Error decoding SSL notification template JSON: %v", err) + _ = err + return nil, err + } + + // log.Printf("โœ… Successfully fetched SSL notification template: %+v", template) + _ = url + _ = template + return &template, nil +} + +// generateSSLMessage creates notification message for SSL certificates +func (snm *SSLNotificationManager) generateSSLMessage(payload *NotificationPayload, template *SSLNotificationTemplate) string { + var baseMessage string + + // Use template if available + if template != nil { + // log.Printf("๐Ÿ”ง Using SSL template for status: %s", strings.ToLower(payload.Status)) + // log.Printf("๐Ÿ”ง [SSL-TEMPLATE-DEBUG] Available templates - Expired: '%s', ExpiringSoon: '%s', Warning: '%s'", + // template.Expired, template.ExpiringSoon, template.Warning) + + switch strings.ToLower(payload.Status) { + case "expired": + baseMessage = template.Expired + // log.Printf("๐Ÿ”ง [SSL-EXPIRED] Selected expired template: '%s'", baseMessage) + case "expiring_soon": + baseMessage = template.ExpiringSoon + // log.Printf("๐Ÿ”ง [SSL-EXPIRING] Selected expiring soon template: '%s'", baseMessage) + case "warning": + baseMessage = template.Warning + // log.Printf("๐Ÿ”ง [SSL-WARNING] Selected warning template: '%s'", baseMessage) + default: + baseMessage = template.Warning + // log.Printf("๐Ÿ”ง [SSL-DEFAULT] Using warning template for status '%s': '%s'", payload.Status, baseMessage) + } + + _ = baseMessage + _ = template + } + + // Use default if no template or template message is empty + if baseMessage == "" { + // log.Printf("๐Ÿ”ง Using default SSL message (no template or empty template)") + baseMessage = snm.getDefaultSSLMessage(payload) + } + + // Replace placeholders + message := snm.replaceSSLPlaceholders(baseMessage, payload) + + // log.Printf("๐Ÿ“ Final SSL message: %s", message) + _ = message + return message +} + +// replaceSSLPlaceholders replaces all placeholders in the SSL message +func (snm *SSLNotificationManager) replaceSSLPlaceholders(message string, payload *NotificationPayload) string { + // log.Printf("๐Ÿ”„ [SSL-REPLACE] Before replacement: %s", message) + // log.Printf("๐Ÿ”„ [SSL-DATA] Payload data - Domain: %s, ExpiryDate: %s, DaysLeft: %s, IssuerCN: %s", + // payload.Domain, payload.ExpiryDate, payload.DaysLeft, payload.IssuerCN) + + // SSL Certificate specific placeholders - ensure all are replaced + message = strings.ReplaceAll(message, "${domain}", snm.safeString(payload.Domain)) + message = strings.ReplaceAll(message, "${certificate_name}", snm.safeString(payload.CertificateName)) + message = strings.ReplaceAll(message, "${expiry_date}", snm.safeString(payload.ExpiryDate)) + message = strings.ReplaceAll(message, "${days_left}", snm.safeString(payload.DaysLeft)) + message = strings.ReplaceAll(message, "${issuer_cn}", snm.safeString(payload.IssuerCN)) + message = strings.ReplaceAll(message, "${serial_number}", snm.safeString(payload.SerialNumber)) + + // Basic placeholders + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${service_name}", snm.safeString(payload.ServiceName)) + message = strings.ReplaceAll(message, "${host}", snm.safeString(payload.Host)) + + // Time placeholders + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("15:04:05")) + message = strings.ReplaceAll(message, "${date}", payload.Timestamp.Format("2006-01-02")) + + // Message placeholder + if payload.Message != "" { + message = strings.ReplaceAll(message, "${message}", payload.Message) + } else { + message = strings.ReplaceAll(message, "${message}", "N/A") + } + + // log.Printf("๐Ÿ”„ [SSL-REPLACE] After replacement: %s", message) + _ = payload + return message +} + +// safeString returns the string value or "N/A" if empty +func (snm *SSLNotificationManager) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// getDefaultSSLMessage provides a default notification message for SSL certificates +func (snm *SSLNotificationManager) getDefaultSSLMessage(payload *NotificationPayload) string { + statusEmoji := "๐Ÿ”’" + if payload.Status == "expired" { + statusEmoji = "๐Ÿšจ" + } else if payload.Status == "expiring_soon" { + statusEmoji = "โš ๏ธ" + } else if payload.Status == "warning" { + statusEmoji = "๐Ÿ””" + } + + // Create the default message with all SSL details + message := fmt.Sprintf("%s SSL certificate for %s has %s", statusEmoji, payload.Domain, strings.ToUpper(payload.Status)) + + // Add certificate details if available + if payload.CertificateName != "" && payload.CertificateName != payload.Domain { + message += fmt.Sprintf("\n โ€ข Certs Name: %s", payload.CertificateName) + } + + if payload.ExpiryDate != "" { + message += fmt.Sprintf("\n โ€ข Expiry Date: %s", payload.ExpiryDate) + } + + if payload.DaysLeft != "" { + message += fmt.Sprintf("\n โ€ข Days Left: %s", payload.DaysLeft) + } + + if payload.IssuerCN != "" { + message += fmt.Sprintf("\n โ€ข Issuer: %s", payload.IssuerCN) + } + + // Add timestamp + message += fmt.Sprintf("\n โ€ข Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} \ No newline at end of file diff --git a/server/service-operation/notification/telegram.go b/server/service-operation/notification/telegram.go new file mode 100644 index 0000000..838d61c --- /dev/null +++ b/server/service-operation/notification/telegram.go @@ -0,0 +1,347 @@ + +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// TelegramService handles Telegram notifications +type TelegramService struct{} + +// NewTelegramService creates a new Telegram notification service +func NewTelegramService() *TelegramService { + return &TelegramService{} +} + +// TelegramPayload represents the payload for Telegram API +type TelegramPayload struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode,omitempty"` +} + +// SendNotification sends a notification via Telegram +func (ts *TelegramService) SendNotification(config *AlertConfiguration, message string) error { + // fmt.Printf("๐Ÿ“ฑ [TELEGRAM] Attempting to send notification...\n") + // fmt.Printf("๐Ÿ“ฑ [TELEGRAM] Config - Chat ID: %s, Bot Token present: %v\n", config.TelegramChatID, config.BotToken != "") + // fmt.Printf("๐Ÿ“ฑ [TELEGRAM] Message: %s\n", message) + + if config.BotToken == "" || config.TelegramChatID == "" { + return fmt.Errorf("telegram bot token and chat ID are required") + } + + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", config.BotToken) + // fmt.Printf("๐Ÿ“ฑ [TELEGRAM] API URL: %s\n", strings.Replace(url, config.BotToken, "[REDACTED]", 1)) + + payload := TelegramPayload{ + ChatID: config.TelegramChatID, + Text: message, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + // fmt.Printf("๐Ÿ“ฑ [TELEGRAM] Sending POST request...\n") + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + // fmt.Printf("โŒ [TELEGRAM] HTTP error: %v\n", err) + return err + } + defer resp.Body.Close() + + var result NotificationResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + // fmt.Printf("โŒ [TELEGRAM] JSON decode error: %v\n", err) + return err + } + + // fmt.Printf("๐Ÿ“ฑ [TELEGRAM] Response - OK: %v, Description: %s\n", result.OK, result.Description) + + if !result.OK { + // fmt.Printf("โŒ [TELEGRAM] API error: %s\n", result.Description) + return fmt.Errorf("telegram API error: %s", result.Description) + } + + // fmt.Printf("โœ… [TELEGRAM] Message sent successfully!\n") + return nil +} + +// SendServerNotification sends a server-specific notification via Telegram +func (ts *TelegramService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := ts.generateServerMessage(payload, template, resourceType) + return ts.SendNotification(config, message) +} + +// SendServiceNotification sends a service-specific notification via Telegram +func (ts *TelegramService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := ts.generateServiceMessage(payload, template) + return ts.SendNotification(config, message) +} + +// generateServerMessage creates a message for server notifications using server template +func (ts *TelegramService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + // Select appropriate template message based on status and resource type + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + // Handle resource-specific messages + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ts.generateDefaultServerMessage(payload, resourceType) + } + + return ts.replacePlaceholders(templateMessage, payload) +} + +// generateServiceMessage creates a message for service notifications using service template +func (ts *TelegramService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + // Select appropriate template message based on status + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ts.generateDefaultUptimeMessage(payload) + } + + return ts.replacePlaceholders(templateMessage, payload) +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (ts *TelegramService) replacePlaceholders(message string, payload *NotificationPayload) string { + // Replace basic placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", ts.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", ts.safeString(payload.Hostname)) + + // Replace URL with fallback to host + url := ts.safeString(payload.URL) + if url == "N/A" && payload.Host != "" { + url = payload.Host + } + message = strings.ReplaceAll(message, "${url}", url) + + // Replace domain + message = strings.ReplaceAll(message, "${domain}", ts.safeString(payload.Domain)) + + // Replace service type + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + // Replace region and agent info + message = strings.ReplaceAll(message, "${region_name}", ts.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", ts.safeString(payload.AgentID)) + + // Handle numeric fields safely + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + // Replace server monitoring fields + message = strings.ReplaceAll(message, "${cpu_usage}", ts.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", ts.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", ts.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", ts.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", ts.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", ts.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", ts.safeString(payload.Threshold)) + + // Replace error message - important for uptime services + message = strings.ReplaceAll(message, "${error_message}", ts.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", ts.safeString(payload.ErrorMessage)) + + // Replace time placeholders + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} + +// safeString returns the string value or "N/A" if empty +func (ts *TelegramService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// generateDefaultUptimeMessage creates a default uptime message with proper formatting +func (ts *TelegramService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + // Status emoji mapping + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + case "maintenance", "paused": + statusEmoji = "๐ŸŸ " + } + + message := fmt.Sprintf("%sService %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status)) + + // Build formatted details + details := []string{} + + // Add URL or host + if payload.URL != "" { + details = append(details, fmt.Sprintf(" - Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf(" - Host: %s", payload.Host)) + } + + // Add service type + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf(" - Type: %s", strings.ToUpper(payload.ServiceType))) + } + + // Add port if available + if payload.Port > 0 { + details = append(details, fmt.Sprintf(" - Port: %d", payload.Port)) + } + + // Add domain if available + if payload.Domain != "" { + details = append(details, fmt.Sprintf(" - Domain: %s", payload.Domain)) + } + + // Add response time + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf(" - Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, " - Response time: N/A") + } + + // Add region info + if payload.RegionName != "" { + details = append(details, fmt.Sprintf(" - Region: %s", payload.RegionName)) + } + + // Add agent info + if payload.AgentID != "" { + details = append(details, fmt.Sprintf(" - Agent: %s", payload.AgentID)) + } + + // Add uptime if available + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf(" - Uptime: %d%%", payload.Uptime)) + } + + // Add timestamp + details = append(details, fmt.Sprintf(" - Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + // Combine message with details + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + + return message +} + +// generateDefaultServerMessage creates a default server message +func (ts *TelegramService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + } + + return fmt.Sprintf("%s๐Ÿ–ฅ๏ธ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} \ No newline at end of file diff --git a/server/service-operation/notification/types.go b/server/service-operation/notification/types.go new file mode 100644 index 0000000..0d5846e --- /dev/null +++ b/server/service-operation/notification/types.go @@ -0,0 +1,124 @@ + +package notification + +import "time" + +// NotificationPayload represents the data sent in notifications +type NotificationPayload struct { + ServiceName string `json:"service_name"` + Status string `json:"status"` + Host string `json:"host"` + Hostname string `json:"hostname"` + Port int `json:"port"` + ServiceType string `json:"service_type"` + ResponseTime int64 `json:"response_time"` + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` + ErrorMessage string `json:"error_message,omitempty"` + + // Service-specific fields + URL string `json:"url,omitempty"` + Domain string `json:"domain,omitempty"` + RegionName string `json:"region_name,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Uptime int `json:"uptime,omitempty"` + + // Server monitoring specific fields + CPUUsage string `json:"cpu_usage,omitempty"` + RAMUsage string `json:"ram_usage,omitempty"` + DiskUsage string `json:"disk_usage,omitempty"` + NetworkUsage string `json:"network_usage,omitempty"` + CPUTemp string `json:"cpu_temp,omitempty"` + DiskIO string `json:"disk_io,omitempty"` + Threshold string `json:"threshold,omitempty"` + + // SSL Certificate specific fields + CertificateName string `json:"certificate_name,omitempty"` + ExpiryDate string `json:"expiry_date,omitempty"` + DaysLeft string `json:"days_left,omitempty"` + IssuerCN string `json:"issuer_cn,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` +} + +// AlertConfiguration represents an alert configuration from PocketBase +type AlertConfiguration struct { + ID string `json:"id"` + NotificationType string `json:"notification_type"` + TelegramChatID string `json:"telegram_chat_id"` + DiscordWebhookURL string `json:"discord_webhook_url"` + SignalNumber string `json:"signal_number"` + SignalAPIEndpoint string `json:"signal_api_endpoint"` + NotifyName string `json:"notify_name"` + BotToken string `json:"bot_token"` + TemplateID string `json:"template_id"` + SlackWebhookURL string `json:"slack_webhook_url"` + GoogleChatWebhookURL string `json:"google_chat_webhook_url"` + Enabled string `json:"enabled"` + EmailAddress string `json:"email_address"` + EmailSenderName string `json:"email_sender_name"` + SMTPServer string `json:"smtp_server"` + SMTPPassword string `json:"smtp_password,omitempty"` + SMTPPort string `json:"smtp_port"` + WebhookID string `json:"webhook_id"` + ChannelID string `json:"channel_id"` + WebhookURL string `json:"webhook_url"` + WebhookPayloadTemplate string `json:"webhook_payload_template"` + NtfyEndpoint string `json:"ntfy_endpoint"` + APIToken string `json:"api_token"` + UserKey string `json:"user_key"` + ServerURL string `json:"server_url"` + MatrixHomeserver string `json:"matrix_homeserver"` + MatrixRoomID string `json:"matrix_room_id"` + MatrixAccessToken string `json:"matrix_access_token"` +} + +// ServerNotificationTemplate represents a server notification template +type ServerNotificationTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + UpMessage string `json:"up_message"` + DownMessage string `json:"down_message"` + WarningMessage string `json:"warning_message"` + PausedMessage string `json:"paused_message"` + // Resource-specific messages + RAMMessage string `json:"ram_message"` + CPUMessage string `json:"cpu_message"` + DiskMessage string `json:"disk_message"` + NetworkMessage string `json:"network_message"` + CPUTempMessage string `json:"cpu_temp_message"` + DiskIOMessage string `json:"disk_io_message"` + // Resource restore messages + RestoreRAMMessage string `json:"restore_ram_message"` + RestoreCPUMessage string `json:"restore_cpu_message"` + RestoreDiskMessage string `json:"restore_disk_message"` + RestoreNetworkMessage string `json:"restore_network_message"` + RestoreCPUTempMessage string `json:"restore_cpu_temp_message"` + RestoreDiskIOMessage string `json:"restore_disk_io_message"` + Placeholder string `json:"placeholder"` +} + +// ServiceNotificationTemplate represents a service notification template +type ServiceNotificationTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + UpMessage string `json:"up_message"` + DownMessage string `json:"down_message"` + MaintenanceMessage string `json:"maintenance_message"` + IncidentMessage string `json:"incident_message"` + ResolvedMessage string `json:"resolved_message"` + WarningMessage string `json:"warning_message"` + Placeholder string `json:"placeholder"` +} + +// NotificationResponse represents the response from notification APIs (like Telegram) +type NotificationResponse struct { + OK bool `json:"ok"` + Description string `json:"description,omitempty"` + ErrorCode int `json:"error_code,omitempty"` + Result interface{} `json:"result,omitempty"` +} + +// NotificationService interface for different notification services +type NotificationService interface { + SendNotification(config *AlertConfiguration, message string) error +} \ No newline at end of file diff --git a/server/service-operation/notification/uptime_manager.go b/server/service-operation/notification/uptime_manager.go new file mode 100644 index 0000000..710a386 --- /dev/null +++ b/server/service-operation/notification/uptime_manager.go @@ -0,0 +1,244 @@ +package notification + +import ( + "fmt" + // "log" + "strings" + + "service-operation/pocketbase" +) + +// UptimeNotificationManager handles uptime service notifications with IMMEDIATE sending +type UptimeNotificationManager struct { + pbClient *pocketbase.PocketBaseClient + services map[string]NotificationService +} + +// NewUptimeNotificationManager creates a new uptime notification manager +func NewUptimeNotificationManager(pbClient *pocketbase.PocketBaseClient, services map[string]NotificationService) *UptimeNotificationManager { + return &UptimeNotificationManager{ + pbClient: pbClient, + services: services, + } +} + +// SendUptimeServiceNotification sends notification IMMEDIATELY - NO QUEUING +func (unm *UptimeNotificationManager) SendUptimeServiceNotification(payload *NotificationPayload, notificationID, templateID string) error { + // log.Printf("๐Ÿ“จ [UPTIME-MANAGER] IMMEDIATE send for service: %s, status: %s", payload.ServiceName, payload.Status) + // log.Printf("๐Ÿ“จ [UPTIME-MANAGER] Notification ID: %s, Template ID: %s", notificationID, templateID) + + if notificationID == "" { + return fmt.Errorf("notification ID required for uptime service: %s", payload.ServiceName) + } + + // Parse notification IDs + notificationIDs := parseNotificationIDs(notificationID) + if len(notificationIDs) == 0 { + return fmt.Errorf("no valid notification IDs for uptime service: %s", payload.ServiceName) + } + + var errors []string + successCount := 0 + + // Send to each notification channel IMMEDIATELY + for _, id := range notificationIDs { + // log.Printf("๐Ÿ“ค [UPTIME-SEND] Processing notification ID: %s for service %s", id, payload.ServiceName) + + // Check if enabled + if !isNotificationEnabled(unm.pbClient, id) { + // log.Printf("โš ๏ธ Notification %s disabled for uptime service %s, skipping", id, payload.ServiceName) + continue + } + + // Get alert configuration + alertConfig, err := getAlertConfiguration(unm.pbClient, id) + if err != nil { + // log.Printf("โŒ Failed to get alert config for %s (service: %s): %v", id, payload.ServiceName, err) + errors = append(errors, fmt.Sprintf("config error %s: %v", id, err)) + continue + } + + // log.Printf("๐Ÿ“‹ [UPTIME-CONFIG] Alert config for %s: Type=%s, ChatID=%s, Token present=%v", + // id, alertConfig.NotificationType, alertConfig.TelegramChatID, alertConfig.BotToken != "") + + // Get service template + var serviceTemplate *ServiceNotificationTemplate + if templateID != "" { + serviceTemplate, err = getServiceNotificationTemplate(unm.pbClient, templateID) + if err != nil { + // log.Printf("โš ๏ธ Template error for %s (service: %s): %v", templateID, payload.ServiceName, err) + } + } + + // Generate message + message := unm.generateUptimeMessage(payload, serviceTemplate) + // log.Printf("๐Ÿ“ [UPTIME-MESSAGE] Generated for %s (%s): %s", payload.ServiceName, id, message) + + // Get notification service + service, exists := unm.services[alertConfig.NotificationType] + if !exists { + // log.Printf("โŒ Unsupported notification type for uptime: %s", alertConfig.NotificationType) + errors = append(errors, fmt.Sprintf("unsupported type %s", alertConfig.NotificationType)) + continue + } + + // SEND IMMEDIATELY - NO DELAYS + // log.Printf("โšก [UPTIME-TELEGRAM] Sending via %s for uptime service %s", alertConfig.NotificationType, payload.ServiceName) + err = service.SendNotification(alertConfig, message) + if err != nil { + // log.Printf("โŒ [UPTIME-FAILED] Failed to send via %s for %s: %v", alertConfig.NotificationType, payload.ServiceName, err) + errors = append(errors, fmt.Sprintf("send failed %s: %v", alertConfig.NotificationType, err)) + } else { + // log.Printf("โœ… [UPTIME-SUCCESS] Successfully sent via %s for %s", alertConfig.NotificationType, payload.ServiceName) + successCount++ + } + } + + // Report results + if successCount > 0 { + // log.Printf("โœ… [UPTIME-FINAL] Sent %d/%d uptime notifications for %s", successCount, len(notificationIDs), payload.ServiceName) + } + + if len(errors) > 0 && successCount == 0 { + return fmt.Errorf("all uptime notifications failed for %s: %v", payload.ServiceName, errors) + } + + return nil +} + +// generateUptimeMessage creates notification message for uptime services +func (unm *UptimeNotificationManager) generateUptimeMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var baseMessage string + + // Use template if available + if template != nil { + // log.Printf("๐Ÿ”ง Using uptime template for status: %s", strings.ToLower(payload.Status)) + switch strings.ToLower(payload.Status) { + case "up": + baseMessage = template.UpMessage + case "down": + baseMessage = template.DownMessage + case "maintenance": + baseMessage = template.MaintenanceMessage + case "incident": + baseMessage = template.IncidentMessage + case "resolved": + baseMessage = template.ResolvedMessage + case "warning": + baseMessage = template.WarningMessage + default: + baseMessage = template.WarningMessage + } + } + + // Use default if no template + if baseMessage == "" { + // log.Printf("๐Ÿ”ง Using default uptime message (no template)") + baseMessage = unm.getDefaultUptimeMessage(payload) + } + + // Replace placeholders + message := unm.replacePlaceholders(baseMessage, payload) + + // log.Printf("๐Ÿ“ Final uptime message: %s", message) + return message +} + +// replacePlaceholders replaces all placeholders in the message +func (unm *UptimeNotificationManager) replacePlaceholders(message string, payload *NotificationPayload) string { + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", payload.Host) + message = strings.ReplaceAll(message, "${ip}", payload.Host) + + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.URL != "" { + message = strings.ReplaceAll(message, "${url}", payload.URL) + } else { + message = strings.ReplaceAll(message, "${url}", "N/A") + } + + if payload.Domain != "" { + message = strings.ReplaceAll(message, "${domain}", payload.Domain) + } else { + message = strings.ReplaceAll(message, "${domain}", "N/A") + } + + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + if payload.RegionName != "" { + message = strings.ReplaceAll(message, "${region_name}", payload.RegionName) + } else { + message = strings.ReplaceAll(message, "${region_name}", "N/A") + } + + if payload.AgentID != "" { + message = strings.ReplaceAll(message, "${agent_id}", payload.AgentID) + } else { + message = strings.ReplaceAll(message, "${agent_id}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${date}", payload.Timestamp.Format("2006-01-02")) + + // Handle error message placeholder - critical for uptime services + if payload.ErrorMessage != "" { + message = strings.ReplaceAll(message, "${error}", payload.ErrorMessage) + message = strings.ReplaceAll(message, "${error_message}", payload.ErrorMessage) + } else { + message = strings.ReplaceAll(message, "${error}", "") + message = strings.ReplaceAll(message, "${error_message}", "") + } + + if payload.Message != "" { + message = strings.ReplaceAll(message, "${message}", payload.Message) + } + + return message +} + +// getDefaultUptimeMessage provides a default notification message for uptime services +func (unm *UptimeNotificationManager) getDefaultUptimeMessage(payload *NotificationPayload) string { + statusEmoji := "โœ…" + if payload.Status == "down" { + statusEmoji = "โŒ" + } else if payload.Status == "warning" { + statusEmoji = "โš ๏ธ" + } + + message := fmt.Sprintf("%s [UPTIME] %s is %s", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status)) + + if payload.Host != "" { + message += fmt.Sprintf(" | Host: %s", payload.Host) + } + + if payload.ResponseTime > 0 { + message += fmt.Sprintf(" | %dms", payload.ResponseTime) + } + + message += fmt.Sprintf(" | %s", payload.Timestamp.Format("15:04:05")) + + return message +} \ No newline at end of file diff --git a/server/service-operation/notification/webhook.go b/server/service-operation/notification/webhook.go new file mode 100644 index 0000000..706df57 --- /dev/null +++ b/server/service-operation/notification/webhook.go @@ -0,0 +1,440 @@ + +package notification + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "text/template" + "time" +) + +// WebhookService handles webhook notifications +type WebhookService struct{} + +// NewWebhookService creates a new webhook notification service +func NewWebhookService() *WebhookService { + return &WebhookService{} +} + +// WebhookPayload represents the payload for webhook +type WebhookPayload struct { + Message string `json:"message"` + NotifyName string `json:"notify_name"` + Timestamp string `json:"timestamp"` + ServiceName string `json:"service_name,omitempty"` + Status string `json:"status,omitempty"` + Host string `json:"host,omitempty"` + URL string `json:"url,omitempty"` + Port int `json:"port,omitempty"` + ServiceType string `json:"service_type,omitempty"` + ResponseTime int64 `json:"response_time,omitempty"` + // Server monitoring fields + CPUUsage string `json:"cpu_usage,omitempty"` + RAMUsage string `json:"ram_usage,omitempty"` + DiskUsage string `json:"disk_usage,omitempty"` + NetworkUsage string `json:"network_usage,omitempty"` + CPUTemp string `json:"cpu_temp,omitempty"` + DiskIO string `json:"disk_io,omitempty"` + Threshold string `json:"threshold,omitempty"` + // SSL fields + CertificateName string `json:"certificate_name,omitempty"` + ExpiryDate string `json:"expiry_date,omitempty"` + DaysLeft string `json:"days_left,omitempty"` + IssuerCN string `json:"issuer_cn,omitempty"` +} + +// DiscordWebhookPayload represents Discord-specific webhook payload +type DiscordWebhookPayload struct { + Content string `json:"content"` +} + +// SendNotification sends a notification via webhook +func (ws *WebhookService) SendNotification(config *AlertConfiguration, message string) error { + // fmt.Printf("๐Ÿ“ก [WEBHOOK] Attempting to send notification...\n") + // fmt.Printf("๐Ÿ“ก [WEBHOOK] Config - URL: %s, Notify Name: %s\n", config.WebhookURL, config.NotifyName) + // fmt.Printf("๐Ÿ“ก [WEBHOOK] Message: %s\n", message) + + // Use the webhook_url field from alert_configurations + if config.WebhookURL == "" { + return fmt.Errorf("webhook URL is required") + } + + // Create payload using webhook_payload_template if available + var jsonData []byte + var err error + + if config.WebhookPayloadTemplate != "" && strings.TrimSpace(config.WebhookPayloadTemplate) != "" { + // Use custom payload template + jsonData, err = ws.generateCustomPayload(config, message) + if err != nil { + // fmt.Printf("โŒ [WEBHOOK] Custom payload generation error: %v\n", err) + return err + } + } else { + // Detect Discord webhook and use appropriate payload + if strings.Contains(strings.ToLower(config.WebhookURL), "discord") { + jsonData, err = ws.generateDiscordPayload(message) + } else { + // Use default payload structure for other webhooks + jsonData, err = ws.generateDefaultPayload(config, message) + } + + if err != nil { + // fmt.Printf("โŒ [WEBHOOK] JSON marshal error: %v\n", err) + return err + } + } + + // fmt.Printf("๐Ÿ“ก [WEBHOOK] Payload: %s\n", string(jsonData)) + // fmt.Printf("๐Ÿ“ก [WEBHOOK] Sending POST request...\n") + + resp, err := http.Post(config.WebhookURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + // fmt.Printf("โŒ [WEBHOOK] HTTP error: %v\n", err) + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // fmt.Printf("โŒ [WEBHOOK] API error, status: %d\n", resp.StatusCode) + return fmt.Errorf("webhook error, status: %d", resp.StatusCode) + } + + // fmt.Printf("โœ… [WEBHOOK] Message sent successfully!\n") + return nil +} + +// generateDiscordPayload generates a Discord-compatible webhook payload +func (ws *WebhookService) generateDiscordPayload(message string) ([]byte, error) { + payload := DiscordWebhookPayload{ + Content: message, + } + return json.Marshal(payload) +} + +// generateDefaultPayload generates the default webhook payload +func (ws *WebhookService) generateDefaultPayload(config *AlertConfiguration, message string) ([]byte, error) { + payload := WebhookPayload{ + Message: message, + NotifyName: config.NotifyName, + Timestamp: time.Now().Format(time.RFC3339), + } + return json.Marshal(payload) +} + +// generateCustomPayload generates payload using the custom template +func (ws *WebhookService) generateCustomPayload(config *AlertConfiguration, message string) ([]byte, error) { + // Parse the template + tmpl, err := template.New("webhook").Parse(config.WebhookPayloadTemplate) + if err != nil { + return nil, fmt.Errorf("failed to parse webhook payload template: %v", err) + } + + // Create template data + templateData := map[string]interface{}{ + "message": message, + "notify_name": config.NotifyName, + "timestamp": time.Now().Format(time.RFC3339), + } + + // Execute template + var buf bytes.Buffer + err = tmpl.Execute(&buf, templateData) + if err != nil { + return nil, fmt.Errorf("failed to execute webhook payload template: %v", err) + } + + // Validate that the result is valid JSON + var jsonCheck interface{} + if err := json.Unmarshal(buf.Bytes(), &jsonCheck); err != nil { + return nil, fmt.Errorf("webhook payload template did not generate valid JSON: %v", err) + } + + return buf.Bytes(), nil +} + +// SendServerNotification sends a server-specific notification via webhook +func (ws *WebhookService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error { + message := ws.generateServerMessage(payload, template, resourceType) + return ws.SendNotification(config, message) +} + +// SendServiceNotification sends a service-specific notification via webhook +func (ws *WebhookService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error { + message := ws.generateServiceMessage(payload, template) + return ws.SendNotification(config, message) +} + +// generateServerMessage creates a message for server notifications using server template +func (ws *WebhookService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string { + var templateMessage string + + // Select appropriate template message based on status and resource type + switch strings.ToLower(payload.Status) { + case "down": + templateMessage = template.DownMessage + case "up": + templateMessage = template.UpMessage + case "warning": + templateMessage = template.WarningMessage + case "paused": + templateMessage = template.PausedMessage + default: + // Handle resource-specific messages + switch resourceType { + case "cpu": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUMessage + } else { + templateMessage = template.CPUMessage + } + case "ram", "memory": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreRAMMessage + } else { + templateMessage = template.RAMMessage + } + case "disk": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskMessage + } else { + templateMessage = template.DiskMessage + } + case "network": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreNetworkMessage + } else { + templateMessage = template.NetworkMessage + } + case "cpu_temp", "cpu_temperature": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreCPUTempMessage + } else { + templateMessage = template.CPUTempMessage + } + case "disk_io": + if strings.Contains(strings.ToLower(payload.Status), "restore") { + templateMessage = template.RestoreDiskIOMessage + } else { + templateMessage = template.DiskIOMessage + } + default: + templateMessage = template.WarningMessage + } + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ws.generateDefaultServerMessage(payload, resourceType) + } + + return ws.replacePlaceholders(templateMessage, payload) +} + +// generateServiceMessage creates a message for service notifications using service template +func (ws *WebhookService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string { + var templateMessage string + + // Select appropriate template message based on status + switch strings.ToLower(payload.Status) { + case "up": + templateMessage = template.UpMessage + case "down": + templateMessage = template.DownMessage + case "maintenance": + templateMessage = template.MaintenanceMessage + case "incident": + templateMessage = template.IncidentMessage + case "resolved": + templateMessage = template.ResolvedMessage + case "warning": + templateMessage = template.WarningMessage + default: + templateMessage = template.WarningMessage + } + + // If no template message found, use a default + if templateMessage == "" { + templateMessage = ws.generateDefaultUptimeMessage(payload) + } + + return ws.replacePlaceholders(templateMessage, payload) +} + +// replacePlaceholders replaces all placeholders in the message with actual values +func (ws *WebhookService) replacePlaceholders(message string, payload *NotificationPayload) string { + // Replace basic placeholders + message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName) + message = strings.ReplaceAll(message, "${server_name}", payload.ServiceName) // server_name maps to service_name + message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status)) + message = strings.ReplaceAll(message, "${host}", ws.safeString(payload.Host)) + message = strings.ReplaceAll(message, "${hostname}", ws.safeString(payload.Hostname)) + + // Replace URL with fallback to host + url := ws.safeString(payload.URL) + if url == "N/A" && payload.Host != "" { + url = payload.Host + } + message = strings.ReplaceAll(message, "${url}", url) + + // Replace domain + message = strings.ReplaceAll(message, "${domain}", ws.safeString(payload.Domain)) + + // Replace service type + if payload.ServiceType != "" { + message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType)) + } else { + message = strings.ReplaceAll(message, "${service_type}", "N/A") + } + + // Replace region and agent info + message = strings.ReplaceAll(message, "${region_name}", ws.safeString(payload.RegionName)) + message = strings.ReplaceAll(message, "${agent_id}", ws.safeString(payload.AgentID)) + + // Handle numeric fields safely + if payload.Port > 0 { + message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port)) + } else { + message = strings.ReplaceAll(message, "${port}", "N/A") + } + + if payload.ResponseTime > 0 { + message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime)) + } else { + message = strings.ReplaceAll(message, "${response_time}", "N/A") + } + + if payload.Uptime > 0 { + message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime)) + } else { + message = strings.ReplaceAll(message, "${uptime}", "N/A") + } + + // Replace server monitoring fields + message = strings.ReplaceAll(message, "${cpu_usage}", ws.safeString(payload.CPUUsage)) + message = strings.ReplaceAll(message, "${ram_usage}", ws.safeString(payload.RAMUsage)) + message = strings.ReplaceAll(message, "${disk_usage}", ws.safeString(payload.DiskUsage)) + message = strings.ReplaceAll(message, "${network_usage}", ws.safeString(payload.NetworkUsage)) + message = strings.ReplaceAll(message, "${cpu_temp}", ws.safeString(payload.CPUTemp)) + message = strings.ReplaceAll(message, "${disk_io}", ws.safeString(payload.DiskIO)) + message = strings.ReplaceAll(message, "${threshold}", ws.safeString(payload.Threshold)) + + // Replace SSL certificate fields + message = strings.ReplaceAll(message, "${certificate_name}", ws.safeString(payload.CertificateName)) + message = strings.ReplaceAll(message, "${expiry_date}", ws.safeString(payload.ExpiryDate)) + message = strings.ReplaceAll(message, "${days_left}", ws.safeString(payload.DaysLeft)) + message = strings.ReplaceAll(message, "${issuer_cn}", ws.safeString(payload.IssuerCN)) + message = strings.ReplaceAll(message, "${issuer}", ws.safeString(payload.IssuerCN)) // alias + + // Replace error message - important for uptime services + message = strings.ReplaceAll(message, "${error_message}", ws.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${error}", ws.safeString(payload.ErrorMessage)) + message = strings.ReplaceAll(message, "${message}", ws.safeString(payload.Message)) + + // Replace time placeholders + message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05")) + message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05")) + + return message +} + +// safeString returns the string value or "N/A" if empty +func (ws *WebhookService) safeString(value string) string { + if value == "" { + return "N/A" + } + return value +} + +// generateDefaultUptimeMessage creates a default uptime message with proper formatting +func (ws *WebhookService) generateDefaultUptimeMessage(payload *NotificationPayload) string { + // Status emoji mapping + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + case "maintenance", "paused": + statusEmoji = "๐ŸŸ " + } + + message := fmt.Sprintf("%s Service %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status)) + + // Build formatted details + details := []string{} + + // Add URL or host + if payload.URL != "" { + details = append(details, fmt.Sprintf(" - Host URL: %s", payload.URL)) + } else if payload.Host != "" { + details = append(details, fmt.Sprintf(" - Host: %s", payload.Host)) + } + + // Add service type + if payload.ServiceType != "" { + details = append(details, fmt.Sprintf(" - Type: %s", strings.ToUpper(payload.ServiceType))) + } + + // Add port if available + if payload.Port > 0 { + details = append(details, fmt.Sprintf(" - Port: %d", payload.Port)) + } + + // Add domain if available + if payload.Domain != "" { + details = append(details, fmt.Sprintf(" - Domain: %s", payload.Domain)) + } + + // Add response time + if payload.ResponseTime > 0 { + details = append(details, fmt.Sprintf(" - Response time: %dms", payload.ResponseTime)) + } else { + details = append(details, " - Response time: N/A") + } + + // Add region info + if payload.RegionName != "" { + details = append(details, fmt.Sprintf(" - Region: %s", payload.RegionName)) + } + + // Add agent info + if payload.AgentID != "" { + details = append(details, fmt.Sprintf(" - Agent: %s", payload.AgentID)) + } + + // Add uptime if available + if payload.Uptime > 0 { + details = append(details, fmt.Sprintf(" - Uptime: %d%%", payload.Uptime)) + } + + // Add timestamp + details = append(details, fmt.Sprintf(" - Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05"))) + + // Combine message with details + if len(details) > 0 { + message += "\n" + strings.Join(details, "\n") + } + + return message +} + +// generateDefaultServerMessage creates a default server message +func (ws *WebhookService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string { + statusEmoji := "๐Ÿ”ต" + switch strings.ToLower(payload.Status) { + case "up": + statusEmoji = "๐ŸŸข" + case "down": + statusEmoji = "๐Ÿ”ด" + case "warning": + statusEmoji = "๐ŸŸก" + } + + return fmt.Sprintf("%s ๐Ÿ–ฅ๏ธ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status)) +} \ No newline at end of file diff --git a/server/service-operation/operations/dns.go b/server/service-operation/operations/dns.go new file mode 100644 index 0000000..e78862e --- /dev/null +++ b/server/service-operation/operations/dns.go @@ -0,0 +1,254 @@ +package operations + +import ( + "fmt" + "net" + "strings" + "time" + + "service-operation/types" +) + +type DNSOperation struct { + timeout time.Duration +} + +func NewDNSOperation(timeout time.Duration) *DNSOperation { + return &DNSOperation{timeout: timeout} +} + +func (d *DNSOperation) Execute(host, query string) (*types.OperationResult, error) { + // Validate inputs + if host == "" { + return nil, fmt.Errorf("host cannot be empty") + } + + if query == "" { + query = "A" // Default to A record + } + + result := &types.OperationResult{ + Type: types.OperationDNS, + Host: host, + DNSType: query, + StartTime: time.Now(), + } + + start := time.Now() + + // Resolve the host first to get detailed info + var resolvedIPs []string + var err error + + switch strings.ToUpper(query) { + case "A": + resolvedIPs, err = d.performARecordLookup(host) + + case "AAAA": + resolvedIPs, err = d.performAAAARecordLookup(host) + + case "MX": + resolvedIPs, err = d.performMXRecordLookup(host) + + case "TXT": + resolvedIPs, err = d.performTXTRecordLookup(host) + + case "CNAME": + resolvedIPs, err = d.performCNAMERecordLookup(host) + + case "NS": + resolvedIPs, err = d.performNSRecordLookup(host) + + default: + // Default to A record lookup for unknown types + resolvedIPs, err = d.performARecordLookup(host) + } + + result.ResponseTime = time.Since(start) + result.EndTime = time.Now() + + if err != nil { + result.Error = err.Error() + result.Success = false + result.Details = d.createDetailedErrorMessage(err.Error(), host, query) + } else { + result.DNSRecords = resolvedIPs + result.Success = len(resolvedIPs) > 0 + + if result.Success { + result.Details = d.createDetailedSuccessMessage(result, host, query, resolvedIPs) + } else { + result.Error = "No DNS records found" + result.Details = d.createDetailedErrorMessage("no records found", host, query) + } + } + + return result, nil +} + +func (d *DNSOperation) performARecordLookup(host string) ([]string, error) { + ips, err := net.LookupIP(host) + if err != nil { + return nil, err + } + + var ipv4Records []string + for _, ip := range ips { + if ipv4 := ip.To4(); ipv4 != nil { + ipv4Records = append(ipv4Records, ipv4.String()) + } + } + + return ipv4Records, nil +} + +func (d *DNSOperation) performAAAARecordLookup(host string) ([]string, error) { + ips, err := net.LookupIP(host) + if err != nil { + return nil, err + } + + var ipv6Records []string + for _, ip := range ips { + if ipv6 := ip.To16(); ipv6 != nil && ip.To4() == nil { + ipv6Records = append(ipv6Records, ipv6.String()) + } + } + + return ipv6Records, nil +} + +func (d *DNSOperation) performMXRecordLookup(host string) ([]string, error) { + mxRecords, err := net.LookupMX(host) + if err != nil { + return nil, err + } + + var records []string + for _, mx := range mxRecords { + records = append(records, fmt.Sprintf("%s (priority: %d)", mx.Host, mx.Pref)) + } + + return records, nil +} + +func (d *DNSOperation) performTXTRecordLookup(host string) ([]string, error) { + txtRecords, err := net.LookupTXT(host) + if err != nil { + return nil, err + } + + return txtRecords, nil +} + +func (d *DNSOperation) performCNAMERecordLookup(host string) ([]string, error) { + cname, err := net.LookupCNAME(host) + if err != nil { + return nil, err + } + + return []string{cname}, nil +} + +func (d *DNSOperation) performNSRecordLookup(host string) ([]string, error) { + nsRecords, err := net.LookupNS(host) + if err != nil { + return nil, err + } + + var records []string + for _, ns := range nsRecords { + records = append(records, ns.Host) + } + + return records, nil +} + +func (d *DNSOperation) createDetailedSuccessMessage(result *types.OperationResult, host, queryType string, records []string) string { + var details strings.Builder + + // Success indicator with basic info + details.WriteString(fmt.Sprintf("๐ŸŸข DNS SUCCESS - %s query for %s", + strings.ToUpper(queryType), host)) + + // Response time + details.WriteString(fmt.Sprintf(" | Response time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000)) + + // Record count + details.WriteString(fmt.Sprintf(" | Records found: %d", len(records))) + + // Show first few records for context + if len(records) > 0 { + details.WriteString(" | ") + if len(records) <= 3 { + details.WriteString(fmt.Sprintf("Results: %s", strings.Join(records, ", "))) + } else { + details.WriteString(fmt.Sprintf("Results: %s... (+%d more)", + strings.Join(records[:3], ", "), len(records)-3)) + } + } + + return details.String() +} + +func (d *DNSOperation) createDetailedErrorMessage(errorMsg, host, queryType string) string { + var details strings.Builder + + errorLower := strings.ToLower(errorMsg) + + if strings.Contains(errorLower, "timeout") { + details.WriteString("โฑ๏ธ DNS TIMEOUT - Query timed out") + } else if strings.Contains(errorLower, "no such host") || strings.Contains(errorLower, "host not found") { + details.WriteString("๐Ÿ” HOST NOT FOUND - DNS resolution failed") + } else if strings.Contains(errorLower, "no answer") || strings.Contains(errorLower, "no records found") { + details.WriteString("๐Ÿ“ NO RECORDS - No DNS records of requested type") + } else if strings.Contains(errorLower, "server failure") { + details.WriteString("๐Ÿ”ง SERVER FAILURE - DNS server error") + } else if strings.Contains(errorLower, "refused") { + details.WriteString("๐Ÿšซ QUERY REFUSED - DNS server refused query") + } else { + details.WriteString("โŒ DNS FAILED - Query error") + } + + // Add query details + details.WriteString(fmt.Sprintf(" | Query: %s %s", + strings.ToUpper(queryType), host)) + + // Add specific error details + if errorMsg != "" { + details.WriteString(fmt.Sprintf(" | Error: %s", d.getShortErrorMessage(errorMsg))) + } + + return details.String() +} + +func (d *DNSOperation) getShortErrorMessage(errorMessage string) string { + if errorMessage == "" { + return "Unknown error" + } + + errorLower := strings.ToLower(errorMessage) + + if strings.Contains(errorLower, "timeout") { + return "Query timeout" + } else if strings.Contains(errorLower, "no such host") { + return "Host not found" + } else if strings.Contains(errorLower, "no answer") { + return "No records found" + } else if strings.Contains(errorLower, "server failure") { + return "DNS server failure" + } else if strings.Contains(errorLower, "refused") { + return "Query refused" + } else if strings.Contains(errorLower, "network unreachable") { + return "Network unreachable" + } + + // For other errors, take first 50 characters and clean it up + shortMsg := errorMessage + if len(shortMsg) > 50 { + shortMsg = shortMsg[:50] + "..." + } + + return shortMsg +} \ No newline at end of file diff --git a/server/service-operation/operations/http.go b/server/service-operation/operations/http.go new file mode 100644 index 0000000..887350c --- /dev/null +++ b/server/service-operation/operations/http.go @@ -0,0 +1,136 @@ + +package operations + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "service-operation/types" +) + +type HTTPOperation struct { + timeout time.Duration + client *http.Client +} + +func NewHTTPOperation(timeout time.Duration) *HTTPOperation { + return &HTTPOperation{ + timeout: timeout, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +func (h *HTTPOperation) Execute(url, method string) (*types.OperationResult, error) { + result := &types.OperationResult{ + Type: types.OperationHTTP, + StartTime: time.Now(), + HTTPMethod: method, + } + + // Default to GET if no method specified + if method == "" { + method = "GET" + result.HTTPMethod = "GET" + } + + // Ensure URL has protocol + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + url = "https://" + url + } + + start := time.Now() + + req, err := http.NewRequest(method, url, nil) + if err != nil { + result.Error = fmt.Sprintf("Failed to create request: %v", err) + result.Success = false + result.EndTime = time.Now() + return result, nil + } + + // Set a user agent + req.Header.Set("User-Agent", "ServiceOperation/1.0") + + resp, err := h.client.Do(req) + + result.ResponseTime = time.Since(start) + result.EndTime = time.Now() + + if err != nil { + // More detailed error messages + if strings.Contains(err.Error(), "timeout") { + result.Error = fmt.Sprintf("๐Ÿ• Request timeout after %.2fs - Server did not respond within the expected time", h.timeout.Seconds()) + } else if strings.Contains(err.Error(), "connection refused") { + result.Error = "๐Ÿšซ Connection refused - Server is not accepting connections on this port" + } else if strings.Contains(err.Error(), "no such host") { + result.Error = "๐ŸŒ DNS resolution failed - Host not found" + } else if strings.Contains(err.Error(), "certificate") { + result.Error = "๐Ÿ”’ SSL/TLS certificate error - Certificate verification failed" + } else { + result.Error = fmt.Sprintf("๐Ÿ”Œ Connection error: %v", err) + } + result.Success = false + return result, nil + } + defer resp.Body.Close() + + result.HTTPStatusCode = resp.StatusCode + result.ContentLength = resp.ContentLength + result.Success = resp.StatusCode >= 200 && resp.StatusCode < 400 + + // Capture important headers + result.HTTPHeaders = make(map[string]string) + for key, values := range resp.Header { + if len(values) > 0 { + switch strings.ToLower(key) { + case "content-type", "server", "cache-control", "content-encoding", "x-powered-by": + result.HTTPHeaders[key] = values[0] + } + } + } + + // Read response body for keyword checking and additional details + body, err := io.ReadAll(resp.Body) + if err == nil && len(body) > 0 { + result.ResponseBody = string(body) + // Update content length if not set by server + if result.ContentLength <= 0 { + result.ContentLength = int64(len(body)) + } + } + + // Create detailed status message with emoji + if !result.Success { + switch { + case resp.StatusCode >= 500: + result.Error = fmt.Sprintf("๐Ÿ”ฅ Server Error (HTTP %d): %s - The server encountered an internal error", resp.StatusCode, resp.Status) + case resp.StatusCode >= 400: + result.Error = fmt.Sprintf("โŒ Client Error (HTTP %d): %s - The request was invalid or unauthorized", resp.StatusCode, resp.Status) + case resp.StatusCode >= 300: + result.Error = fmt.Sprintf("โ†ฉ๏ธ Redirect (HTTP %d): %s - Resource has moved", resp.StatusCode, resp.Status) + default: + result.Error = fmt.Sprintf("โš ๏ธ Unexpected Status (HTTP %d): %s", resp.StatusCode, resp.Status) + } + } else { + // Success message with emoji + switch resp.StatusCode { + case 200: + result.Error = "โœ… OK - Request successful" + case 201: + result.Error = "๐Ÿ†• Created - Resource created successfully" + case 202: + result.Error = "โณ Accepted - Request accepted for processing" + case 204: + result.Error = "๐Ÿ“ญ No Content - Request successful, no content returned" + default: + result.Error = fmt.Sprintf("โœ… Success (HTTP %d): %s", resp.StatusCode, resp.Status) + } + } + + return result, nil +} \ No newline at end of file diff --git a/server/service-operation/operations/ping.go b/server/service-operation/operations/ping.go new file mode 100644 index 0000000..9b79923 --- /dev/null +++ b/server/service-operation/operations/ping.go @@ -0,0 +1,460 @@ +package operations + +import ( + "fmt" + "net" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "service-operation/types" +) + +type PingOperation struct { + timeout time.Duration +} + +func NewPingOperation(timeout time.Duration) *PingOperation { + return &PingOperation{timeout: timeout} +} + +func (p *PingOperation) Execute(host string, count int) (*types.OperationResult, error) { + // Validate host/IP + if host == "" { + return nil, fmt.Errorf("host cannot be empty") + } + + // Always try system ping first for better reliability + result, err := p.executeSystemPing(host, count) + if err == nil && result.PacketsRecv > 0 { + return result, nil + } + + // If system ping fails, try raw ICMP as fallback + fmt.Printf("System ping failed (%v), trying raw ICMP\n", err) + rawResult, rawErr := p.executeRawICMP(host, count) + if rawErr == nil && rawResult.PacketsRecv > 0 { + return rawResult, nil + } + + // If both fail, return the system ping result with error info + if result != nil { + return result, nil + } + + return nil, fmt.Errorf("both system ping and raw ICMP failed: system_err=%v, raw_err=%v", err, rawErr) +} + +func (p *PingOperation) executeSystemPing(host string, count int) (*types.OperationResult, error) { + result := &types.OperationResult{ + Type: types.OperationPing, + Host: host, + PacketsSent: count, + StartTime: time.Now(), + } + + // Resolve host to get IP address for better details + ips, err := net.LookupIP(host) + var resolvedIP string + if err == nil && len(ips) > 0 { + resolvedIP = ips[0].String() + } + + // Build ping command based on OS + var cmd *exec.Cmd + timeoutSeconds := int(p.timeout.Seconds()) + if timeoutSeconds < 1 { + timeoutSeconds = 10 // Minimum 10 seconds timeout + } + + switch runtime.GOOS { + case "linux": + // Linux ping: -c count -W timeout_in_seconds + cmd = exec.Command("ping", "-c", fmt.Sprintf("%d", count), "-W", fmt.Sprintf("%d", timeoutSeconds), host) + case "darwin": + // macOS ping: -c count -W timeout_in_milliseconds + cmd = exec.Command("ping", "-c", fmt.Sprintf("%d", count), "-W", fmt.Sprintf("%d", timeoutSeconds*1000), host) + case "windows": + // Windows ping: -n count -w timeout_in_milliseconds + cmd = exec.Command("ping", "-n", fmt.Sprintf("%d", count), "-w", fmt.Sprintf("%d", timeoutSeconds*1000), host) + default: + // Default to Linux-style + cmd = exec.Command("ping", "-c", fmt.Sprintf("%d", count), "-W", fmt.Sprintf("%d", timeoutSeconds), host) + } + + // Set command timeout slightly longer than ping timeout + cmdTimeout := time.Duration(timeoutSeconds+5) * time.Second + done := make(chan error, 1) + var output []byte + var cmdErr error + + go func() { + output, cmdErr = cmd.Output() + done <- cmdErr + }() + + select { + case err := <-done: + result.EndTime = time.Now() + + if err != nil { + // Even if command fails, try to parse partial output + result.PacketLoss = 100.0 + if len(output) > 0 { + p.parseSystemPingOutput(result, string(output), count) + } + // Set detailed error information + result.Error = p.createDetailedErrorMessage(err.Error(), host, resolvedIP) + return result, nil // Don't return error, return result with failure info + } + + // Parse successful output + if len(output) > 0 { + p.parseSystemPingOutput(result, string(output), count) + } + + // Create detailed success message + if result.Success { + result.Details = p.createDetailedSuccessMessage(result, host, resolvedIP) + } + + return result, nil + + case <-time.After(cmdTimeout): + // Command timed out + if cmd.Process != nil { + cmd.Process.Kill() + } + result.EndTime = time.Now() + result.PacketLoss = 100.0 + result.Error = fmt.Sprintf("Ping request timed out after %v", cmdTimeout) + result.Details = p.createDetailedErrorMessage("timeout", host, resolvedIP) + return result, fmt.Errorf("ping command timed out after %v", cmdTimeout) + } +} + +func (p *PingOperation) createDetailedSuccessMessage(result *types.OperationResult, host, resolvedIP string) string { + var details strings.Builder + + // Success indicator with basic info + details.WriteString(fmt.Sprintf("๐ŸŸข PING SUCCESS - %d/%d packets received", + result.PacketsRecv, result.PacketsSent)) + + // Host and IP information + if resolvedIP != "" && resolvedIP != host { + details.WriteString(fmt.Sprintf(" | Host: %s (%s)", host, resolvedIP)) + } else { + details.WriteString(fmt.Sprintf(" | Host: %s", host)) + } + + // Timing information + if result.AvgRTT > 0 { + details.WriteString(fmt.Sprintf(" | Avg RTT: %.2fms", + float64(result.AvgRTT.Nanoseconds())/1000000)) + } + + if result.MinRTT > 0 && result.MaxRTT > 0 { + details.WriteString(fmt.Sprintf(" | Range: %.2f-%.2fms", + float64(result.MinRTT.Nanoseconds())/1000000, + float64(result.MaxRTT.Nanoseconds())/1000000)) + } + + // Packet loss information + if result.PacketLoss > 0 { + details.WriteString(fmt.Sprintf(" | Packet Loss: %.1f%%", result.PacketLoss)) + } else { + details.WriteString(" | No packet loss") + } + + return details.String() +} + +func (p *PingOperation) createDetailedErrorMessage(errorMsg, host, resolvedIP string) string { + var details strings.Builder + + errorLower := strings.ToLower(errorMsg) + + if strings.Contains(errorLower, "timeout") { + details.WriteString("โฑ๏ธ PING TIMEOUT - Host did not respond within timeout period") + } else if strings.Contains(errorLower, "unreachable") { + details.WriteString("๐Ÿšซ HOST UNREACHABLE - Network path to host is blocked") + } else if strings.Contains(errorLower, "no route") { + details.WriteString("๐Ÿ›ค๏ธ NO ROUTE - No network route to destination") + } else if strings.Contains(errorLower, "name resolution") || strings.Contains(errorLower, "unknown host") { + details.WriteString("๐Ÿ” DNS RESOLUTION FAILED - Unable to resolve hostname") + } else if strings.Contains(errorLower, "permission denied") { + details.WriteString("๐Ÿ” PERMISSION DENIED - Insufficient privileges for ICMP") + } else { + details.WriteString("โŒ PING FAILED - Connection error") + } + + // Add host information + if resolvedIP != "" && resolvedIP != host { + details.WriteString(fmt.Sprintf(" | Target: %s (%s)", host, resolvedIP)) + } else { + details.WriteString(fmt.Sprintf(" | Target: %s", host)) + } + + // Add specific error details + if errorMsg != "" { + details.WriteString(fmt.Sprintf(" | Error: %s", p.getShortErrorMessage(errorMsg))) + } + + return details.String() +} + +func (p *PingOperation) getShortErrorMessage(errorMessage string) string { + if errorMessage == "" { + return "Unknown error" + } + + errorLower := strings.ToLower(errorMessage) + + if strings.Contains(errorLower, "timeout") { + return "Request timeout" + } else if strings.Contains(errorLower, "unreachable") { + return "Host unreachable" + } else if strings.Contains(errorLower, "no route") { + return "No route to host" + } else if strings.Contains(errorLower, "name resolution") || strings.Contains(errorLower, "unknown host") { + return "DNS resolution failed" + } else if strings.Contains(errorLower, "permission denied") { + return "Permission denied" + } else if strings.Contains(errorLower, "network is down") { + return "Network is down" + } + + // For other errors, take first 50 characters and clean it up + shortMsg := errorMessage + if len(shortMsg) > 50 { + shortMsg = shortMsg[:50] + "..." + } + + return shortMsg +} + +func (p *PingOperation) parseSystemPingOutput(result *types.OperationResult, output string, expectedCount int) { + // Extract packet loss + lossRegex := regexp.MustCompile(`(\d+(?:\.\d+)?)%.*packet.*loss`) + if matches := lossRegex.FindStringSubmatch(output); len(matches) > 1 { + if loss, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.PacketLoss = loss + } + } + + // Extract timing information (Linux/macOS format) + timingRegex := regexp.MustCompile(`rtt min/avg/max/(?:mdev|stddev) = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+) ms`) + if matches := timingRegex.FindStringSubmatch(output); len(matches) > 4 { + if min, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.MinRTT = time.Duration(min * float64(time.Millisecond)) + } + if avg, err := strconv.ParseFloat(matches[2], 64); err == nil { + result.AvgRTT = time.Duration(avg * float64(time.Millisecond)) + result.ResponseTime = result.AvgRTT + } + if max, err := strconv.ParseFloat(matches[3], 64); err == nil { + result.MaxRTT = time.Duration(max * float64(time.Millisecond)) + } + } + + // Extract individual ping times and count successful pings + timeRegex := regexp.MustCompile(`time[<=]([\d.]+) ?ms`) + timeMatches := timeRegex.FindAllStringSubmatch(output, -1) + result.PacketsRecv = len(timeMatches) + + for _, match := range timeMatches { + if len(match) > 1 { + if t, err := strconv.ParseFloat(match[1], 64); err == nil { + rtt := time.Duration(t * float64(time.Millisecond)) + result.RTTs = append(result.RTTs, rtt) + + // Update min/max if not set by summary stats + if result.MinRTT == 0 || rtt < result.MinRTT { + result.MinRTT = rtt + } + if result.MaxRTT == 0 || rtt > result.MaxRTT { + result.MaxRTT = rtt + } + } + } + } + + // Calculate average RTT if not set by summary stats + if result.AvgRTT == 0 && len(result.RTTs) > 0 { + var total time.Duration + for _, rtt := range result.RTTs { + total += rtt + } + result.AvgRTT = total / time.Duration(len(result.RTTs)) + result.ResponseTime = result.AvgRTT + } + + // Recalculate packet loss if we have individual pings + if expectedCount > 0 { + result.PacketLoss = float64(expectedCount-result.PacketsRecv) / float64(expectedCount) * 100 + } + + // Mark as successful if we got any responses + if result.PacketsRecv > 0 { + result.Success = true + } + + // Handle Windows output format if Linux/macOS parsing didn't work + if result.PacketsRecv == 0 && strings.Contains(strings.ToLower(output), "reply from") { + p.parseWindowsPingOutput(result, output, expectedCount) + } +} + +func (p *PingOperation) parseWindowsPingOutput(result *types.OperationResult, output string, expectedCount int) { + // Windows: "Reply from 1.1.1.1: bytes=32 time=14ms TTL=56" + timeRegex := regexp.MustCompile(`time[<=]([\d.]+)ms`) + timeMatches := timeRegex.FindAllStringSubmatch(output, -1) + result.PacketsRecv = len(timeMatches) + + for _, match := range timeMatches { + if len(match) > 1 { + if t, err := strconv.ParseFloat(match[1], 64); err == nil { + rtt := time.Duration(t * float64(time.Millisecond)) + result.RTTs = append(result.RTTs, rtt) + + if result.MinRTT == 0 || rtt < result.MinRTT { + result.MinRTT = rtt + } + if result.MaxRTT == 0 || rtt > result.MaxRTT { + result.MaxRTT = rtt + } + } + } + } + + if len(result.RTTs) > 0 { + var total time.Duration + for _, rtt := range result.RTTs { + total += rtt + } + result.AvgRTT = total / time.Duration(len(result.RTTs)) + result.ResponseTime = result.AvgRTT + result.Success = true + } + + if expectedCount > 0 { + result.PacketLoss = float64(expectedCount-result.PacketsRecv) / float64(expectedCount) * 100 + } +} + +func (p *PingOperation) executeRawICMP(host string, count int) (*types.OperationResult, error) { + dst, err := net.ResolveIPAddr("ip4", host) + if err != nil { + return nil, fmt.Errorf("failed to resolve host %s: %v", host, err) + } + + // Try to create ICMP connection with better error handling + conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") + if err != nil { + return nil, fmt.Errorf("failed to create ICMP connection (requires root/admin privileges): %v", err) + } + defer conn.Close() + + result := &types.OperationResult{ + Type: types.OperationPing, + Host: host, + PacketsSent: count, + StartTime: time.Now(), + } + + var totalRTT time.Duration + var minRTT, maxRTT time.Duration + pid := os.Getpid() & 0xffff + + for i := 0; i < count; i++ { + message := &icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ + ID: pid, + Seq: i + 1, + Data: []byte(fmt.Sprintf("Hello, World! %d", i)), + }, + } + + data, err := message.Marshal(nil) + if err != nil { + continue + } + + start := time.Now() + _, err = conn.WriteTo(data, dst) + if err != nil { + continue + } + + // Set read deadline + err = conn.SetReadDeadline(time.Now().Add(p.timeout)) + if err != nil { + continue + } + + reply := make([]byte, 1500) + _, peer, err := conn.ReadFrom(reply) + if err != nil { + continue + } + + rtt := time.Since(start) + + // Parse the reply with better error handling - fix the type conversion + rm, err := icmp.ParseMessage(int(ipv4.ICMPTypeEchoReply), reply) + if err != nil { + continue + } + + switch rm.Type { + case ipv4.ICMPTypeEchoReply: + if peer.String() == dst.String() { + echoReply, ok := rm.Body.(*icmp.Echo) + if ok && echoReply.ID == pid { + result.PacketsRecv++ + totalRTT += rtt + + if result.PacketsRecv == 1 || rtt < minRTT { + minRTT = rtt + } + if result.PacketsRecv == 1 || rtt > maxRTT { + maxRTT = rtt + } + + result.RTTs = append(result.RTTs, rtt) + } + } + } + + // Sleep between pings except for the last one + if i < count-1 { + time.Sleep(1 * time.Second) + } + } + + result.EndTime = time.Now() + result.PacketLoss = float64(count-result.PacketsRecv) / float64(count) * 100 + + if result.PacketsRecv > 0 { + result.MinRTT = minRTT + result.MaxRTT = maxRTT + result.AvgRTT = totalRTT / time.Duration(result.PacketsRecv) + result.ResponseTime = result.AvgRTT + result.Success = true + // Create detailed success message for raw ICMP + result.Details = p.createDetailedSuccessMessage(result, host, dst.String()) + } else { + result.Error = "No ICMP echo replies received" + result.Details = p.createDetailedErrorMessage("no replies", host, dst.String()) + } + + return result, nil +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_extractor.go b/server/service-operation/operations/ssl_extractor.go new file mode 100644 index 0000000..3a1ad77 --- /dev/null +++ b/server/service-operation/operations/ssl_extractor.go @@ -0,0 +1,57 @@ + +package operations + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "fmt" + "net" +) + +// extractSANs extracts Subject Alternative Names from certificate +func (op *SSLOperation) extractSANs(cert *x509.Certificate) []string { + sans := make([]string, 0) + + // Add DNS names + sans = append(sans, cert.DNSNames...) + + // Add IP addresses + for _, ip := range cert.IPAddresses { + sans = append(sans, ip.String()) + } + + // Add email addresses + sans = append(sans, cert.EmailAddresses...) + + // Add URIs + for _, uri := range cert.URIs { + sans = append(sans, uri.String()) + } + + return sans +} + +// getResolvedIP resolves the domain to its IP address +func (op *SSLOperation) getResolvedIP(hostname string) string { + ips, err := net.LookupIP(hostname) + if err != nil || len(ips) == 0 { + return "" + } + return ips[0].String() +} + +// getCertificateAlgorithm returns detailed algorithm information +func (op *SSLOperation) getCertificateAlgorithm(cert *x509.Certificate) string { + algorithm := cert.SignatureAlgorithm.String() + + // Add key size information if available + switch pub := cert.PublicKey.(type) { + case *rsa.PublicKey: + algorithm += fmt.Sprintf(" (RSA %d-bit)", pub.N.BitLen()) + case *ecdsa.PublicKey: + algorithm += fmt.Sprintf(" (ECDSA %d-bit)", pub.Curve.Params().BitSize) + } + + return algorithm +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_operation.go b/server/service-operation/operations/ssl_operation.go new file mode 100644 index 0000000..398adbe --- /dev/null +++ b/server/service-operation/operations/ssl_operation.go @@ -0,0 +1,159 @@ + +package operations + +import ( + "crypto/tls" + "fmt" + "net" + "strings" + "time" + + "service-operation/types" +) + +type SSLOperation struct { + timeout time.Duration +} + +func NewSSLOperation(timeout time.Duration) *SSLOperation { + return &SSLOperation{ + timeout: timeout, + } +} + +func (op *SSLOperation) Execute(domain string) (*types.OperationResult, error) { + startTime := time.Now() + + // Clean and normalize domain + domain = op.normalizeDomain(domain) + + // Validate domain format + if domain == "" { + return op.createErrorResult(domain, startTime, "domain cannot be empty") + } + + // Add port if not present + host := domain + if !strings.Contains(host, ":") { + host = host + ":443" + } + + // Set up TLS connection with timeout + dialer := &net.Dialer{ + Timeout: op.timeout, + } + + // Create TLS config with proper verification + tlsConfig := &tls.Config{ + ServerName: strings.Split(host, ":")[0], + InsecureSkipVerify: false, + MinVersion: tls.VersionTLS12, + } + + // Attempt TLS connection + conn, err := tls.DialWithDialer(dialer, "tcp", host, tlsConfig) + + endTime := time.Now() + responseTime := endTime.Sub(startTime) + + if err != nil { + return &types.OperationResult{ + Type: types.OperationSSL, + Host: strings.Split(host, ":")[0], + Success: false, + ResponseTime: responseTime, + Error: fmt.Sprintf("TLS connection failed: %v", err), + StartTime: startTime, + EndTime: endTime, + }, nil + } + defer conn.Close() + + // Get certificate chain information + state := conn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return &types.OperationResult{ + Type: types.OperationSSL, + Host: strings.Split(host, ":")[0], + Success: false, + ResponseTime: responseTime, + Error: "No certificates found in chain", + StartTime: startTime, + EndTime: endTime, + }, nil + } + + cert := state.PeerCertificates[0] + hostname := strings.Split(host, ":")[0] + + // Perform comprehensive certificate validation + validationError := op.validateCertificate(cert, hostname) + + // Calculate days left until expiration + daysLeft := int(time.Until(cert.NotAfter).Hours() / 24) + + // Extract Subject Alternative Names + sans := op.extractSANs(cert) + + // Get resolved IP address + resolvedIP := op.getResolvedIP(hostname) + + // Extract certificate algorithm information + algorithm := op.getCertificateAlgorithm(cert) + + // Extract issuer organization (O=) instead of full distinguished name + issuerOrganization := op.extractIssuerOrganization(cert.Issuer) + + // Extract subject organization (O=) instead of full distinguished name + subjectOrganization := op.extractSubjectOrganization(cert.Subject) + + // Check if certificate is valid (not expired and passes validation) + isValid := validationError == nil && time.Now().Before(cert.NotAfter) && time.Now().After(cert.NotBefore) + + // Build detailed error message if validation failed + errorMsg := "" + if validationError != nil { + errorMsg = validationError.Error() + } else if time.Now().After(cert.NotAfter) { + errorMsg = "Certificate has expired" + } else if time.Now().Before(cert.NotBefore) { + errorMsg = "Certificate is not yet valid" + } + + // Create comprehensive result + result := &types.OperationResult{ + Type: types.OperationSSL, + Host: hostname, + Success: isValid, + ResponseTime: responseTime, + Error: errorMsg, + StartTime: startTime, + EndTime: endTime, + + // SSL specific fields - using organization instead of full DN + SSLValidFrom: cert.NotBefore, + SSLValidTill: cert.NotAfter, + SSLDaysLeft: daysLeft, + SSLIssuer: issuerOrganization, // Now shows "Google Trust Services" instead of full DN + SSLSubject: subjectOrganization, // Now shows organization instead of full DN + SSLSerialNumber: cert.SerialNumber.String(), + SSLAlgorithm: algorithm, + SSLSANs: strings.Join(sans, ","), + SSLResolvedIP: resolvedIP, + } + + return result, nil +} + +// createErrorResult creates a standardized error result +func (op *SSLOperation) createErrorResult(domain string, startTime time.Time, errorMsg string) (*types.OperationResult, error) { + return &types.OperationResult{ + Type: types.OperationSSL, + Host: domain, + Success: false, + ResponseTime: time.Since(startTime), + Error: errorMsg, + StartTime: startTime, + EndTime: time.Now(), + }, nil +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_utils.go b/server/service-operation/operations/ssl_utils.go new file mode 100644 index 0000000..555b4b2 --- /dev/null +++ b/server/service-operation/operations/ssl_utils.go @@ -0,0 +1,88 @@ + +package operations + +import ( + "crypto/x509/pkix" + "fmt" + "strings" +) + +// normalizeDomain cleans and normalizes the domain input +func (op *SSLOperation) normalizeDomain(domain string) string { + // Remove protocol prefixes + domain = strings.Replace(domain, "https://", "", 1) + domain = strings.Replace(domain, "http://", "", 1) + + // Remove trailing slash and path + if idx := strings.Index(domain, "/"); idx != -1 { + domain = domain[:idx] + } + + // Trim whitespace + domain = strings.TrimSpace(domain) + + return domain +} + +// formatDistinguishedName formats the certificate distinguished name +func (op *SSLOperation) formatDistinguishedName(name pkix.Name) string { + var parts []string + + if name.CommonName != "" { + parts = append(parts, fmt.Sprintf("CN=%s", name.CommonName)) + } + + for _, org := range name.Organization { + parts = append(parts, fmt.Sprintf("O=%s", org)) + } + + for _, orgUnit := range name.OrganizationalUnit { + parts = append(parts, fmt.Sprintf("OU=%s", orgUnit)) + } + + for _, country := range name.Country { + parts = append(parts, fmt.Sprintf("C=%s", country)) + } + + for _, locality := range name.Locality { + parts = append(parts, fmt.Sprintf("L=%s", locality)) + } + + for _, province := range name.Province { + parts = append(parts, fmt.Sprintf("ST=%s", province)) + } + + return strings.Join(parts, ", ") +} + +// extractIssuerOrganization extracts only the organization (O=) from the issuer distinguished name +func (op *SSLOperation) extractIssuerOrganization(name pkix.Name) string { + // Return the first organization if available + if len(name.Organization) > 0 { + return name.Organization[0] + } + + // Fallback to Common Name if no organization + if name.CommonName != "" { + return name.CommonName + } + + // Last resort fallback + return "Unknown" +} + +// extractSubjectOrganization extracts only the organization (O=) from the subject distinguished name +func (op *SSLOperation) extractSubjectOrganization(name pkix.Name) string { + // Return the first organization if available + if len(name.Organization) > 0 { + return name.Organization[0] + } + + // Fallback to Common Name if no organization + if name.CommonName != "" { + return name.CommonName + } + + // Last resort fallback + return "Unknown" +} \ No newline at end of file diff --git a/server/service-operation/operations/ssl_validator.go b/server/service-operation/operations/ssl_validator.go new file mode 100644 index 0000000..602ea7f --- /dev/null +++ b/server/service-operation/operations/ssl_validator.go @@ -0,0 +1,65 @@ +package operations + +import ( + "crypto/x509" + "fmt" + "time" +) + +// validateCertificate performs comprehensive certificate validation +func (op *SSLOperation) validateCertificate(cert *x509.Certificate, hostname string) error { + now := time.Now() + + // Check if certificate is expired or not yet valid + if now.Before(cert.NotBefore) { + return fmt.Errorf("certificate is not yet valid (valid from: %v)", cert.NotBefore.Format("2006-01-02 15:04:05")) + } + if now.After(cert.NotAfter) { + return fmt.Errorf("certificate has expired (expired on: %v)", cert.NotAfter.Format("2006-01-02 15:04:05")) + } + + // Verify hostname matches certificate + if err := cert.VerifyHostname(hostname); err != nil { + return fmt.Errorf("hostname verification failed: %v", err) + } + + // Check key usage - certificates should have digital signature capability + if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return fmt.Errorf("certificate missing required digital signature key usage") + } + + // Check if certificate is self-signed (basic check) + if cert.Issuer.CommonName == cert.Subject.CommonName && len(cert.Subject.Organization) == 0 { + return fmt.Errorf("certificate appears to be self-signed") + } + + // Validate certificate chain if intermediate certificates are present + if err := op.validateCertificateChain(cert); err != nil { + return fmt.Errorf("certificate chain validation failed: %v", err) + } + + return nil +} + +// validateCertificateChain performs basic certificate chain validation +func (op *SSLOperation) validateCertificateChain(cert *x509.Certificate) error { + // Check if the certificate has proper extensions for SSL/TLS + hasServerAuth := false + for _, usage := range cert.ExtKeyUsage { + if usage == x509.ExtKeyUsageServerAuth { + hasServerAuth = true + break + } + } + + if !hasServerAuth { + return fmt.Errorf("certificate does not have server authentication extension") + } + + // Check certificate version (should be v3 for modern certificates) + if cert.Version < 3 { + return fmt.Errorf("certificate version %d is outdated (should be v3)", cert.Version) + } + + return nil +} \ No newline at end of file diff --git a/server/service-operation/operations/tcp.go b/server/service-operation/operations/tcp.go new file mode 100644 index 0000000..1d0f4af --- /dev/null +++ b/server/service-operation/operations/tcp.go @@ -0,0 +1,49 @@ + +package operations + +import ( + "fmt" + "net" + "time" + + "service-operation/types" +) + +type TCPOperation struct { + timeout time.Duration +} + +func NewTCPOperation(timeout time.Duration) *TCPOperation { + return &TCPOperation{timeout: timeout} +} + +func (t *TCPOperation) Execute(host string, port int) (*types.OperationResult, error) { + result := &types.OperationResult{ + Type: types.OperationTCP, + Host: host, + Port: port, + StartTime: time.Now(), + } + + start := time.Now() + + address := fmt.Sprintf("%s:%d", host, port) + conn, err := net.DialTimeout("tcp", address, t.timeout) + + result.ResponseTime = time.Since(start) + result.EndTime = time.Now() + + if err != nil { + result.Error = err.Error() + result.TCPConnected = false + result.Success = false + result.Details = fmt.Sprintf("Failed to connect to %s:%d - %s", host, port, err.Error()) + } else { + conn.Close() + result.TCPConnected = true + result.Success = true + result.Details = fmt.Sprintf("Successfully connected to %s:%d", host, port) + } + + return result, nil +} \ No newline at end of file diff --git a/server/service-operation/ping/icmp.go b/server/service-operation/ping/icmp.go new file mode 100644 index 0000000..3e6354d --- /dev/null +++ b/server/service-operation/ping/icmp.go @@ -0,0 +1,117 @@ + +package ping + +import ( + "fmt" + "net" + "os" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +type ICMPPinger struct { + timeout time.Duration +} + +func NewICMPPinger(timeout time.Duration) *ICMPPinger { + return &ICMPPinger{timeout: timeout} +} + +func (p *ICMPPinger) Ping(host string, count int) (*PingResult, error) { + dst, err := net.ResolveIPAddr("ip4", host) + if err != nil { + return nil, fmt.Errorf("failed to resolve host %s: %v", host, err) + } + + conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") + if err != nil { + return nil, fmt.Errorf("failed to create ICMP connection: %v", err) + } + defer conn.Close() + + result := &PingResult{ + Host: host, + PacketsSent: count, + StartTime: time.Now(), + } + + var totalRTT time.Duration + var minRTT, maxRTT time.Duration + + for i := 0; i < count; i++ { + message := &icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ + ID: os.Getpid() & 0xffff, + Seq: i + 1, + Data: []byte("Hello, World!"), + }, + } + + data, err := message.Marshal(nil) + if err != nil { + continue + } + + start := time.Now() + _, err = conn.WriteTo(data, dst) + if err != nil { + continue + } + + err = conn.SetReadDeadline(time.Now().Add(p.timeout)) + if err != nil { + continue + } + + reply := make([]byte, 1500) + _, peer, err := conn.ReadFrom(reply) + if err != nil { + continue + } + + rtt := time.Since(start) + + // Parse the reply + rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply, reply) + if err != nil { + continue + } + + switch rm.Type { + case ipv4.ICMPTypeEchoReply: + if peer.String() == dst.String() { + result.PacketsRecv++ + totalRTT += rtt + + if result.PacketsRecv == 1 || rtt < minRTT { + minRTT = rtt + } + if result.PacketsRecv == 1 || rtt > maxRTT { + maxRTT = rtt + } + + result.RTTs = append(result.RTTs, rtt) + } + } + + if i < count-1 { + time.Sleep(1 * time.Second) + } + } + + result.EndTime = time.Now() + result.PacketLoss = float64(count-result.PacketsRecv) / float64(count) * 100 + + if result.PacketsRecv > 0 { + result.MinRTT = minRTT + result.MaxRTT = maxRTT + result.AvgRTT = totalRTT / time.Duration(result.PacketsRecv) + result.Success = true + } + + return result, nil +} diff --git a/server/service-operation/ping/types.go b/server/service-operation/ping/types.go new file mode 100644 index 0000000..e89be4e --- /dev/null +++ b/server/service-operation/ping/types.go @@ -0,0 +1,25 @@ + +package ping + +import "time" + +type PingResult struct { + Host string `json:"host"` + Success bool `json:"success"` + PacketsSent int `json:"packets_sent"` + PacketsRecv int `json:"packets_recv"` + PacketLoss float64 `json:"packet_loss"` + MinRTT time.Duration `json:"min_rtt"` + MaxRTT time.Duration `json:"max_rtt"` + AvgRTT time.Duration `json:"avg_rtt"` + RTTs []time.Duration `json:"rtts"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Error string `json:"error,omitempty"` +} + +type PingRequest struct { + Host string `json:"host"` + Count int `json:"count,omitempty"` + Timeout int `json:"timeout,omitempty"` +} diff --git a/server/service-operation/pocketbase/client.go b/server/service-operation/pocketbase/client.go new file mode 100644 index 0000000..25bd76a --- /dev/null +++ b/server/service-operation/pocketbase/client.go @@ -0,0 +1,52 @@ + +package pocketbase + +import ( + "fmt" + "net/http" + "time" +) + +type PocketBaseClient struct { + baseURL string + httpClient *http.Client +} + +func NewPocketBaseClient(baseURL string) (*PocketBaseClient, error) { + // Use provided baseURL or default to localhost + if baseURL == "" { + baseURL = "http://localhost:8090" + } + + client := &PocketBaseClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } + + return client, nil +} + +func (c *PocketBaseClient) GetBaseURL() string { + return c.baseURL +} + +func (c *PocketBaseClient) TestConnection() error { + resp, err := c.httpClient.Get(fmt.Sprintf("%s/api/health", c.baseURL)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("PocketBase health check failed with status: %d", resp.StatusCode) + } + + return nil +} + +func (c *PocketBaseClient) IsAuthenticated() bool { + // Since we're using public access mode, always return true + return true +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/metrics.go b/server/service-operation/pocketbase/metrics.go new file mode 100644 index 0000000..244274d --- /dev/null +++ b/server/service-operation/pocketbase/metrics.go @@ -0,0 +1,22 @@ + +package pocketbase + +func (c *PocketBaseClient) SaveMetrics(metrics MetricsRecord) error { + return c.createRecord("services_metrics", metrics) +} + +func (c *PocketBaseClient) SavePingData(pingData PingDataRecord) error { + return c.createRecord("ping_data", pingData) +} + +func (c *PocketBaseClient) SaveUptimeData(uptimeData UptimeDataRecord) error { + return c.createRecord("uptime_data", uptimeData) +} + +func (c *PocketBaseClient) SaveDNSData(dnsData DNSDataRecord) error { + return c.createRecord("dns_data", dnsData) +} + +func (c *PocketBaseClient) SaveTCPData(tcpData TCPDataRecord) error { + return c.createRecord("tcp_data", tcpData) +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/regional.go b/server/service-operation/pocketbase/regional.go new file mode 100644 index 0000000..cfdadd0 --- /dev/null +++ b/server/service-operation/pocketbase/regional.go @@ -0,0 +1,117 @@ + +package pocketbase + +import ( + "fmt" + "net/http" + "time" +) + +func (c *PocketBaseClient) GetRegionalServices() ([]RegionalService, error) { + resp, err := c.httpClient.Get(fmt.Sprintf("%s/api/collections/regional_service/records", c.baseURL)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusForbidden { + // Collection might require authentication or doesn't exist + fmt.Printf("Warning: Cannot access regional_service collection (403). Regional monitoring will be disabled.\n") + return nil, fmt.Errorf("access denied to regional_service collection") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get regional services: status %d", resp.StatusCode) + } + + var response RegionalServicesResponse + if err := c.parseResponse(resp, &response); err != nil { + return nil, err + } + + return response.Items, nil +} + +func (c *PocketBaseClient) GetDefaultRegionalService() (*RegionalService, error) { + services, err := c.GetRegionalServices() + if err != nil { + // Try to create a default regional service if we can't get existing ones + return c.CreateDefaultRegionalService() + } + + // Look for a service with region_name "Default" + for _, service := range services { + if service.RegionName == "Default" { + return &service, nil + } + } + + // If no default service found, try to create one + return c.CreateDefaultRegionalService() +} + +func (c *PocketBaseClient) CreateDefaultRegionalService() (*RegionalService, error) { + defaultService := map[string]interface{}{ + "region_name": "Default", + "status": "active", + "agent_id": "1", + "agent_ip_address": "127.0.0.1", + "connection": "offline", + "token": fmt.Sprintf("default-%d", time.Now().Unix()), + } + + err := c.createRecord("regional_service", defaultService) + if err != nil { + fmt.Printf("Warning: Could not create default regional service: %v\n", err) + // Return a fallback service for local operations + return &RegionalService{ + ID: "default", + RegionName: "Default", + Status: "active", + AgentID: "1", + AgentIPAddress: "127.0.0.1", + Connection: "offline", + Token: "default-token", + }, nil + } + + // Try to fetch the created service + services, err := c.GetRegionalServices() + if err == nil { + for _, service := range services { + if service.RegionName == "Default" { + return &service, nil + } + } + } + + // Return fallback if still can't get the created service + return &RegionalService{ + ID: "default", + RegionName: "Default", + Status: "active", + AgentID: "1", + AgentIPAddress: "127.0.0.1", + Connection: "offline", + Token: "default-token", + }, nil +} + +func (c *PocketBaseClient) UpdateRegionalServiceConnection(serviceID, connection string) error { + // Determine status based on connection + status := "inactive" + if connection == "online" { + status = "active" + } + + data := map[string]interface{}{ + "connection": connection, + "status": status, + } + + return c.updateRecord("regional_service", serviceID, data) +} + +func (c *PocketBaseClient) SaveSSLData(sslData SSLDataRecord) error { + return c.createRecord("ssl_data", sslData) +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/services.go b/server/service-operation/pocketbase/services.go new file mode 100644 index 0000000..11ef4db --- /dev/null +++ b/server/service-operation/pocketbase/services.go @@ -0,0 +1,155 @@ + +package pocketbase + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +func (c *PocketBaseClient) GetServices() ([]Service, error) { + req, err := http.NewRequest("GET", + fmt.Sprintf("%s/api/collections/services/records", c.baseURL), nil) + if err != nil { + return nil, err + } + + // No authentication header needed for public access + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch services, status: %d", resp.StatusCode) + } + + var servicesResponse ServicesResponse + if err := json.NewDecoder(resp.Body).Decode(&servicesResponse); err != nil { + return nil, err + } + + return servicesResponse.Items, nil +} + +func (c *PocketBaseClient) GetService(serviceID string) (*Service, error) { + req, err := http.NewRequest("GET", + fmt.Sprintf("%s/api/collections/services/records/%s", c.baseURL, serviceID), nil) + if err != nil { + return nil, err + } + + // No authentication header needed for public access + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch service %s, status: %d", serviceID, resp.StatusCode) + } + + var service Service + if err := json.NewDecoder(resp.Body).Decode(&service); err != nil { + return nil, err + } + + return &service, nil +} + +func (c *PocketBaseClient) GetActiveServices() ([]Service, error) { + var allServices []Service + page := 1 + perPage := 30 // Use default pagination size + + for { + // Fetch services page by page with filter for non-paused services + req, err := http.NewRequest("GET", + fmt.Sprintf("%s/api/collections/services/records?page=%d&perPage=%d&filter=(status!='paused')", + c.baseURL, page, perPage), nil) + if err != nil { + return nil, err + } + + // No authentication header needed for public access + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch active services, status: %d", resp.StatusCode) + } + + var servicesResponse ServicesResponse + if err := json.NewDecoder(resp.Body).Decode(&servicesResponse); err != nil { + return nil, err + } + + // Add current page items to the result + allServices = append(allServices, servicesResponse.Items...) + + // Check if we've fetched all pages + if page >= servicesResponse.TotalPages || len(servicesResponse.Items) == 0 { + break + } + + page++ + } + + return allServices, nil +} + +func (c *PocketBaseClient) UpdateServiceStatus(serviceID string, status string, responseTime int64, errorMessage string) error { + // First check if the service is paused before updating + service, err := c.GetService(serviceID) + if err != nil { + return fmt.Errorf("failed to check service status before update: %v", err) + } + + if service.Status == "paused" { + return fmt.Errorf("service %s is paused, skipping status update", serviceID) + } + + updateData := map[string]interface{}{ + "status": status, + "response_time": responseTime, + "last_checked": time.Now().Format(time.RFC3339), + } + + if errorMessage != "" { + updateData["error_message"] = errorMessage + } + + jsonData, err := json.Marshal(updateData) + if err != nil { + return err + } + + req, err := http.NewRequest("PATCH", + fmt.Sprintf("%s/api/collections/services/records/%s", c.baseURL, serviceID), + bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + // No authentication header needed + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to update service status, status: %d", resp.StatusCode) + } + + return nil +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/ssl.go b/server/service-operation/pocketbase/ssl.go new file mode 100644 index 0000000..6e39bfd --- /dev/null +++ b/server/service-operation/pocketbase/ssl.go @@ -0,0 +1,104 @@ +package pocketbase + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "service-operation/types" +) + +type SSLCertificatesResponse struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Items []types.SSLCertificate `json:"items"` +} + +func (c *PocketBaseClient) GetSSLCertificates() ([]types.SSLCertificate, error) { + var allCertificates []types.SSLCertificate + page := 1 + perPage := 200 // You can increase up to 500 if needed + + for { + url := fmt.Sprintf( + "%s/api/collections/ssl_certificates/records?page=%d&perPage=%d", + c.baseURL, page, perPage, + ) + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch SSL certificates: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("PocketBase returned status %d", resp.StatusCode) + } + + var response SSLCertificatesResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode SSL certificates response: %v", err) + } + + allCertificates = append(allCertificates, response.Items...) + + if page >= response.TotalPages || len(response.Items) == 0 { + break + } + page++ + } + + return allCertificates, nil +} + +func (c *PocketBaseClient) UpdateSSLCertificate(id string, data map[string]interface{}) error { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records/%s", c.baseURL, id) + + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal SSL certificate data: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create SSL certificate update request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to update SSL certificate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to update SSL certificate, status: %d", resp.StatusCode) + } + + return nil +} + +func (c *PocketBaseClient) GetSSLCertificateByID(id string) (*types.SSLCertificate, error) { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records/%s", c.baseURL, id) + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch SSL certificate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("PocketBase returned status %d", resp.StatusCode) + } + + var cert types.SSLCertificate + if err := json.NewDecoder(resp.Body).Decode(&cert); err != nil { + return nil, fmt.Errorf("failed to decode SSL certificate response: %v", err) + } + + return &cert, nil +} diff --git a/server/service-operation/pocketbase/types.go b/server/service-operation/pocketbase/types.go new file mode 100644 index 0000000..77f1efd --- /dev/null +++ b/server/service-operation/pocketbase/types.go @@ -0,0 +1,169 @@ +package pocketbase + +import "time" + +type AuthResponse struct { + Token string `json:"token"` + Record interface{} `json:"record"` +} + +type MetricsRecord struct { + ServiceName string `json:"service_name"` + Host string `json:"host"` + Uptime float64 `json:"uptime"` + ResponseTime int64 `json:"response_time"` + LastChecked string `json:"last_checked"` + Port int `json:"port,omitempty"` + Domain string `json:"domain,omitempty"` + HeartbeatInterval int `json:"heartbeat_interval,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` + NotificationID string `json:"notification_id,omitempty"` + TemplateID string `json:"template_id,omitempty"` + ServiceType string `json:"service_type"` + Status string `json:"status"` + URL string `json:"url,omitempty"` + Alerts string `json:"alerts,omitempty"` + StatusCodes string `json:"status_codes,omitempty"` + Keyword string `json:"keyword,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Details string `json:"details,omitempty"` + CheckedAt string `json:"checked_at"` +} + +type PingDataRecord struct { + ServiceID string `json:"service_id"` + Timestamp time.Time `json:"timestamp"` + ResponseTime int64 `json:"response_time"` + Status string `json:"status"` + PacketLoss string `json:"packet_loss"` + Latency string `json:"latency"` + MaxRTT string `json:"max_rtt"` + MinRTT string `json:"min_rtt"` + PacketsSent string `json:"packets_sent"` + PacketsRecv string `json:"packets_recv"` + AvgRTT string `json:"avg_rtt"` + RTTs string `json:"rtts"` + Details string `json:"details,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + RegionName string `json:"region_name,omitempty"` + AgentID string `json:"agent_id,omitempty"` +} + +type UptimeDataRecord struct { + ServiceID string `json:"service_id"` + Timestamp time.Time `json:"timestamp"` + ResponseTime int64 `json:"response_time"` + Status string `json:"status"` + Packets string `json:"packets"` + Latency string `json:"latency"` + StatusCodes string `json:"status_codes"` + Keyword string `json:"keyword"` + ErrorMessage string `json:"error_message"` + Details string `json:"details"` + Region string `json:"region,omitempty"` + RegionID string `json:"region_id,omitempty"` + RegionName string `json:"region_name,omitempty"` + AgentID string `json:"agent_id,omitempty"` +} + +type DNSDataRecord struct { + ServiceID string `json:"service_id"` + Timestamp time.Time `json:"timestamp"` + ResponseTime int64 `json:"response_time"` + Status string `json:"status"` + QueryType string `json:"query_type"` + ResolveIP string `json:"resolve_ip"` + MsgSize string `json:"msg_size"` + Question string `json:"question"` + Answer string `json:"answer"` + Authority string `json:"authority"` + ErrorMessage string `json:"error_message,omitempty"` + Details string `json:"details,omitempty"` + RegionName string `json:"region_name,omitempty"` + AgentID string `json:"agent_id,omitempty"` +} + +type TCPDataRecord struct { + ServiceID string `json:"service_id"` + Timestamp time.Time `json:"timestamp"` + ResponseTime int64 `json:"response_time"` + Status string `json:"status"` + Connection string `json:"connection"` + Latency string `json:"latency"` + Port string `json:"port"` + ErrorMessage string `json:"error_message,omitempty"` + Details string `json:"details,omitempty"` + RegionName string `json:"region_name,omitempty"` + AgentID string `json:"agent_id,omitempty"` +} + +// SSL Data Record remains unchanged - no regional agent fields +type SSLDataRecord struct { + ServiceID string `json:"service_id"` + Timestamp time.Time `json:"timestamp"` + ResponseTime int64 `json:"response_time"` + Status string `json:"status"` + ValidFrom string `json:"valid_from"` + ValidTill string `json:"valid_till"` + DaysLeft int `json:"days_left"` + Issuer string `json:"issuer"` + Subject string `json:"subject"` + SerialNumber string `json:"serial_number"` + Algorithm string `json:"algorithm"` + SANs string `json:"sans"` + ResolvedIP string `json:"resolved_ip"` + ErrorMessage string `json:"error_message,omitempty"` + Details string `json:"details,omitempty"` +} + +// Regional Service record structure +type RegionalService struct { + ID string `json:"id"` + RegionName string `json:"region_name"` + Status string `json:"status"` + AgentID string `json:"agent_id"` + AgentIPAddress string `json:"agent_ip_address"` + Connection string `json:"connection"` + Token string `json:"token"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +type RegionalServicesResponse struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Items []RegionalService `json:"items"` +} + +type Service struct { + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + Uptime float64 `json:"uptime"` + ResponseTime int64 `json:"response_time"` + LastChecked string `json:"last_checked"` + Port int `json:"port"` + Domain string `json:"domain"` + HeartbeatInterval int `json:"heartbeat_interval"` + MaxRetries int `json:"max_retries"` + NotificationID string `json:"notification_id"` + TemplateID string `json:"template_id"` + ServiceType string `json:"service_type"` + Status string `json:"status"` + URL string `json:"url"` + Alerts string `json:"alerts"` + StatusCodes string `json:"status_codes"` + Keyword string `json:"keyword"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +type ServicesResponse struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Items []Service `json:"items"` +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/utils.go b/server/service-operation/pocketbase/utils.go new file mode 100644 index 0000000..d55e2af --- /dev/null +++ b/server/service-operation/pocketbase/utils.go @@ -0,0 +1,124 @@ + +package pocketbase + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +func (c *PocketBaseClient) parseResponse(resp *http.Response, target interface{}) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + return json.Unmarshal(body, target) +} + +func (c *PocketBaseClient) createRecord(collection string, data interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", + fmt.Sprintf("%s/api/collections/%s/records", c.baseURL, collection), + bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + // No authentication header needed + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + // Read response body for better error details + bodyBytes, _ := io.ReadAll(resp.Body) + bodyString := string(bodyBytes) + + fmt.Printf("Failed to create record in %s collection. Status: %d, Response: %s\n", + collection, resp.StatusCode, bodyString) + + return fmt.Errorf("failed to create record in %s, status: %d, response: %s", + collection, resp.StatusCode, bodyString) + } + + return nil +} + +func (c *PocketBaseClient) updateRecord(collection string, recordID string, data interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("PATCH", + fmt.Sprintf("%s/api/collections/%s/records/%s", c.baseURL, collection, recordID), + bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + bodyString := string(bodyBytes) + + fmt.Printf("Failed to update record in %s collection. Status: %d, Response: %s\n", + collection, resp.StatusCode, bodyString) + + return fmt.Errorf("failed to update record in %s, status: %d, response: %s", + collection, resp.StatusCode, bodyString) + } + + return nil +} + +// UpdateRecord is the public method for server monitoring that preserves other fields +func (c *PocketBaseClient) UpdateRecord(collection string, id string, data map[string]interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("PATCH", + fmt.Sprintf("%s/api/collections/%s/records/%s", c.baseURL, collection, id), + bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to update record, status: %d", resp.StatusCode) + } + + return nil +} + +func (c *PocketBaseClient) GetHTTPClient() *http.Client { + return c.httpClient +} \ No newline at end of file diff --git a/server/service-operation/server-monitoring/debug.go b/server/service-operation/server-monitoring/debug.go new file mode 100644 index 0000000..c7a2e93 --- /dev/null +++ b/server/service-operation/server-monitoring/debug.go @@ -0,0 +1,16 @@ + +package servermonitoring + +import ( + "time" +) + +// LogServerUpdateAttempt logs when we attempt to update a server record +func LogServerUpdateAttempt(serverID, operation, details string) { + // log.Printf("๐Ÿ”„ [UPDATE ATTEMPT] Server: %s | Operation: %s | Details: %s | Time: %s", + // serverID, operation, details, time.Now().Format("2006-01-02 15:04:05")) + _ = serverID + _ = operation + _ = details + _ = time.Now() +} \ No newline at end of file diff --git a/server/service-operation/server-monitoring/debug_helper.go b/server/service-operation/server-monitoring/debug_helper.go new file mode 100644 index 0000000..6f988ce --- /dev/null +++ b/server/service-operation/server-monitoring/debug_helper.go @@ -0,0 +1,46 @@ + +package servermonitoring + +// DebugServerFields helps debug server field preservation issues +func (c *ServerPocketBaseClient) DebugServerFields(serverID string, operation string) { + // log.Printf("๐Ÿ” === DEBUG: %s for server %s ===", operation, serverID) + + servers, err := c.GetAllServers() + if err != nil { + // log.Printf("โŒ Debug failed to fetch servers: %v", err) + _ = err + return + } + + for _, server := range servers { + if server.ID == serverID || server.ServerID == serverID { + // log.Printf("๐Ÿ“Š Server Debug Info:") + // log.Printf(" - Server ID: %s", server.ID) + // log.Printf(" - Server Name: %s", server.Name) + // log.Printf(" - Status: %s", server.Status) + // log.Printf(" - Notification ID: '%s'", server.NotificationID) + // log.Printf(" - Template ID: '%s'", server.TemplateID) + // log.Printf(" - Threshold ID: '%s'", server.ThresholdID) + // log.Printf(" - Agent Status: %s", server.AgentStatus) + // log.Printf(" - Check Interval: %d", server.CheckInterval) + + // Check for empty strings vs nil + if server.NotificationID == "" { + // log.Printf("โš ๏ธ WARNING: Notification ID is empty string") + } + if server.TemplateID == "" { + // log.Printf("โš ๏ธ WARNING: Template ID is empty string") + } + if server.ThresholdID == "" { + // log.Printf("โš ๏ธ WARNING: Threshold ID is empty string") + } + + _ = server + break + } + } + + // log.Printf("๐Ÿ” === END DEBUG: %s ===", operation) + _ = operation + _ = serverID +} \ No newline at end of file diff --git a/server/service-operation/server-monitoring/integration.go b/server/service-operation/server-monitoring/integration.go new file mode 100644 index 0000000..61c73f3 --- /dev/null +++ b/server/service-operation/server-monitoring/integration.go @@ -0,0 +1,40 @@ + +package servermonitoring + +import ( + "service-operation/pocketbase" +) + +// ServerMonitoringService provides an easy interface for server monitoring +type ServerMonitoringService struct { + monitor *ServerMonitor +} + +// NewServerMonitoringService creates a new server monitoring service +func NewServerMonitoringService(pbClient *pocketbase.PocketBaseClient) *ServerMonitoringService { + return &ServerMonitoringService{ + monitor: NewServerMonitor(pbClient), + } +} + +// Start starts the server monitoring service +func (sms *ServerMonitoringService) Start() { + sms.monitor.Start() +} + +// Stop stops the server monitoring service +func (sms *ServerMonitoringService) Stop() { + sms.monitor.Stop() +} + +// GetMonitor returns the underlying monitor for advanced usage +func (sms *ServerMonitoringService) GetMonitor() *ServerMonitor { + return sms.monitor +} + +// IsRunning returns whether the monitoring service is currently running +func (sms *ServerMonitoringService) IsRunning() bool { + sms.monitor.mu.RLock() + defer sms.monitor.mu.RUnlock() + return sms.monitor.isRunning +} \ No newline at end of file diff --git a/server/service-operation/server-monitoring/monitor.go b/server/service-operation/server-monitoring/monitor.go new file mode 100644 index 0000000..f384253 --- /dev/null +++ b/server/service-operation/server-monitoring/monitor.go @@ -0,0 +1,462 @@ + +package servermonitoring + +import ( + "fmt" + "strconv" + "sync" + "time" + + "service-operation/pocketbase" +) + +// StatusAlert tracks alert state for server status changes +type StatusAlert struct { + IsActive bool + Status string + LastAlerted time.Time + RetryCount int +} + +// ServerMonitor tracks server metrics and sends notifications +type ServerMonitor struct { + pbClient *ServerPocketBaseClient + notificationService *ServerNotificationService + thresholdMonitor *ThresholdMonitor + lastStatuses map[string]string // Track last known status for each server + activeStatusAlerts map[string]*StatusAlert // Track status alerts with retry counts + mu sync.RWMutex + stopChan chan bool + isRunning bool +} + +// NewServerMonitor creates a new server monitor +func NewServerMonitor(pbClient *pocketbase.PocketBaseClient) *ServerMonitor { + // log.Println("๐Ÿ”ง Creating new ServerMonitor instance") + + serverPBClient := NewServerPocketBaseClient(pbClient) + notificationService := NewServerNotificationService(pbClient) + thresholdMonitor := NewThresholdMonitor(serverPBClient, notificationService) + + return &ServerMonitor{ + pbClient: serverPBClient, + notificationService: notificationService, + thresholdMonitor: thresholdMonitor, + lastStatuses: make(map[string]string), + activeStatusAlerts: make(map[string]*StatusAlert), + stopChan: make(chan bool), + isRunning: false, + } +} + +// Start begins monitoring servers +func (sm *ServerMonitor) Start() { + sm.mu.Lock() + defer sm.mu.Unlock() + + if sm.isRunning { + // log.Println("โš ๏ธ Server monitor is already running") + return + } + + sm.isRunning = true + // log.Println("๐Ÿš€ Starting server monitoring service...") + + go sm.monitoringLoop() +} + +// Stop stops monitoring servers +func (sm *ServerMonitor) Stop() { + sm.mu.Lock() + defer sm.mu.Unlock() + + if !sm.isRunning { + // log.Println("โš ๏ธ Server monitor is not running") + return + } + + // log.Println("๐Ÿ›‘ Stopping server monitoring service...") + sm.isRunning = false + sm.stopChan <- true +} + +// monitoringLoop runs the main monitoring loop +func (sm *ServerMonitor) monitoringLoop() { + // log.Println("๐Ÿ”„ Starting monitoring loop with 30-second intervals") + ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds for more responsive monitoring + defer ticker.Stop() + + // Initial check + // log.Println("๐Ÿ” Performing initial server check...") + sm.checkAllServers() + + for { + select { + case <-ticker.C: + // log.Println("โฐ Timer triggered - checking all servers") + sm.checkAllServers() + case <-sm.stopChan: + // log.Println("๐Ÿ›‘ Stop signal received - exiting monitoring loop") + return + } + } +} + +// checkAllServers checks all servers for missing metrics +func (sm *ServerMonitor) checkAllServers() { + // log.Println("๐Ÿ“‹ Fetching all servers from database...") + + servers, err := sm.pbClient.GetAllServers() + if err != nil { + // log.Printf("โŒ CRITICAL ERROR: Failed to fetch servers: %v", err) + _ = err + return + } + + // log.Printf("โœ… Successfully fetched %d servers", len(servers)) + + if len(servers) == 0 { + // log.Println("โš ๏ธ No servers found in database") + return + } + + for i, server := range servers { + // log.Printf("๐Ÿ” [%d/%d] Processing server: %s (ID: %s)", i+1, len(servers), server.Name, server.ServerID) + sm.checkServerMetrics(server) + // log.Printf("โœ… [%d/%d] Completed processing server: %s", i+1, len(servers), server.Name) + _ = i + } + + // log.Println("๐Ÿ“‹ Completed checking all servers") +} + +// checkServerMetrics checks if a server has recent metrics and agent status +func (sm *ServerMonitor) checkServerMetrics(server Server) { + // log.Printf("๐Ÿ” === DETAILED SERVER CHECK FOR: %s ===", server.Name) + + // Debug server fields BEFORE any operations + sm.pbClient.DebugServerFields(server.ID, "BEFORE_CHECK") + + // log.Printf("๐Ÿ“Š Server Details:") + // log.Printf(" - ID: %s", server.ID) + // log.Printf(" - Server ID: %s", server.ServerID) + // log.Printf(" - Name: %s", server.Name) + // log.Printf(" - Current Status: %s", server.Status) + // log.Printf(" - Agent Status: %s", server.AgentStatus) + // log.Printf(" - Check Interval: %d minutes", server.CheckInterval) + // log.Printf(" - Notification ID: '%s'", server.NotificationID) + // log.Printf(" - Template ID: '%s'", server.TemplateID) + // log.Printf(" - Threshold ID: '%s'", server.ThresholdID) + // log.Printf(" - IP Address: %s", server.IPAddress) + // log.Printf(" - OS Type: %s", server.OSType) + // log.Printf(" - Max Retries: %s", server.MaxRetries) + + // Track the notification configuration BEFORE any updates + beforeNotificationID := server.NotificationID + beforeTemplateID := server.TemplateID + _ = beforeNotificationID + _ = beforeTemplateID + + // Skip paused servers + if server.Status == "paused" { + // log.Printf("โธ๏ธ Server %s is paused - skipping monitoring", server.Name) + return + } + + sm.mu.Lock() + lastStatus := sm.lastStatuses[server.ServerID] + sm.mu.Unlock() + + // log.Printf("๐Ÿ“ˆ Status Tracking:") + // log.Printf(" - Last Known Status: '%s'", lastStatus) + // log.Printf(" - Current Database Status: '%s'", server.Status) + + currentStatus := "up" + var message string + var statusReason string + + // Check agent status first - if agent is stopped, server is definitely down + if server.AgentStatus == "stopped" { + currentStatus = "down" + statusReason = "agent is stopped" + message = fmt.Sprintf("๐Ÿ”ด Server %s is DOWN - Agent has stopped running", server.Name) + // log.Printf("โŒ SERVER DOWN DETECTED: %s - Agent is stopped", server.Name) + } else if server.AgentStatus == "running" { + // log.Printf("โœ… Agent is running for server %s - checking metrics...", server.Name) + + // If agent is running, check metrics + checkIntervalMinutes := time.Duration(server.CheckInterval) * time.Minute + gracePeriod := 30 * time.Second + metricsTimeout := checkIntervalMinutes + gracePeriod + + // Minimum timeout of 2 minutes to avoid false positives + if metricsTimeout < 2*time.Minute { + metricsTimeout = 2 * time.Minute + } + + // log.Printf("๐Ÿ• Metrics Check Configuration:") + // log.Printf(" - Check Interval: %v", checkIntervalMinutes) + // log.Printf(" - Grace Period: %v", gracePeriod) + // log.Printf(" - Total Timeout: %v", metricsTimeout) + + // Get recent metrics for this server + // log.Printf("๐Ÿ” Fetching recent metrics for server %s...", server.Name) + metrics, err := sm.pbClient.GetLatestServerMetrics(server.ServerID, metricsTimeout) + if err != nil { + // log.Printf("โŒ ERROR: Failed to fetch metrics for server %s: %v", server.Name, err) + _ = err + return + } + + // log.Printf("๐Ÿ“Š Metrics Query Results:") + // log.Printf(" - Found %d metrics within timeout period", len(metrics)) + + if len(metrics) == 0 { + // No recent metrics found - server is down + currentStatus = "down" + statusReason = fmt.Sprintf("no metrics received in last %v", metricsTimeout) + message = fmt.Sprintf("๐Ÿ”ด Server %s is DOWN - No metrics received in the last %v (check interval: %d minutes)", + server.Name, metricsTimeout, server.CheckInterval) + // log.Printf("โŒ SERVER DOWN DETECTED: %s - No recent metrics", server.Name) + } else { + // Recent metrics found - server is up + currentStatus = "up" + latestMetric := metrics[0] + age := time.Since(latestMetric.CreatedTime) + statusReason = fmt.Sprintf("metrics received %v ago", age.Round(time.Second)) + message = fmt.Sprintf("โœ… Server %s is operational - Latest metrics received %v ago (check interval: %d minutes)", + server.Name, age.Round(time.Second), server.CheckInterval) + // log.Printf("โœ… SERVER UP: %s - Latest metric %v ago", server.Name, age.Round(time.Second)) + + // log.Printf("๐Ÿ“Š Latest Metric Details:") + // log.Printf(" - Metric ID: %s", latestMetric.ID) + // log.Printf(" - Created: %v", latestMetric.CreatedTime) + // log.Printf(" - Age: %v", age.Round(time.Second)) + // log.Printf(" - Status: %s", latestMetric.Status) + + // Check thresholds if server is up and has metrics + // log.Printf("๐ŸŽฏ Checking thresholds for server %s...", server.Name) + sm.thresholdMonitor.CheckServerThresholds(server, metrics) + } + } else { + // log.Printf("โš ๏ธ Unknown agent status '%s' for server %s", server.AgentStatus, server.Name) + currentStatus = "up" // Default to up for unknown status + statusReason = fmt.Sprintf("unknown agent status: %s", server.AgentStatus) + message = fmt.Sprintf("โš ๏ธ Server %s has unknown agent status: %s", server.Name, server.AgentStatus) + } + + // log.Printf("๐Ÿ“Š Status Determination:") + // log.Printf(" - Determined Status: %s", currentStatus) + // log.Printf(" - Reason: %s", statusReason) + // log.Printf(" - Message: %s", message) + + // Check if status has changed + statusChanged := lastStatus != currentStatus && lastStatus != "" + isFirstCheck := lastStatus == "" + _ = statusChanged + _ = isFirstCheck + + // log.Printf("๐Ÿ”„ Status Change Analysis:") + // log.Printf(" - Status Changed: %t", statusChanged) + // log.Printf(" - First Check: %t", isFirstCheck) + // log.Printf(" - Previous Status: '%s'", lastStatus) + // log.Printf(" - New Status: '%s'", currentStatus) + + // Update last known status + sm.mu.Lock() + sm.lastStatuses[server.ServerID] = currentStatus + sm.mu.Unlock() + + // Update server status in database if it changed + if server.Status != currentStatus { + // log.Printf("๐Ÿ’พ === DATABASE UPDATE FOR: %s ===", server.Name) + // log.Printf(" - BEFORE UPDATE:") + // log.Printf(" * Status: %s โ†’ %s", server.Status, currentStatus) + // log.Printf(" * Notification ID: '%s'", beforeNotificationID) + // log.Printf(" * Template ID: '%s'", beforeTemplateID) + + // Log the update attempt + LogServerUpdateAttempt(server.ID, "UpdateServerStatus", fmt.Sprintf("Status change from %s to %s", server.Status, currentStatus)) + + // Debug fields before update + sm.pbClient.DebugServerFields(server.ID, "BEFORE_UPDATE") + + if err := sm.pbClient.UpdateServerStatus(server.ID, currentStatus); err != nil { + // log.Printf("โŒ CRITICAL ERROR: Failed to update server status for %s: %v", server.Name, err) + _ = err + } else { + // log.Printf("โœ… Successfully updated database status for server %s", server.Name) + + // Debug fields after update + sm.pbClient.DebugServerFields(server.ID, "AFTER_UPDATE") + } + } else { + // log.Printf("โ„น๏ธ Database status already matches current status (%s) for server %s", currentStatus, server.Name) + } + + // Handle status notifications with retry logic + sm.handleStatusNotifications(server, currentStatus, message, statusChanged, isFirstCheck) + + // Final status summary + // log.Printf("๐Ÿ“Š === FINAL STATUS SUMMARY FOR: %s ===", server.Name) + // log.Printf(" - Final Status: %s (%s)", currentStatus, statusReason) + // log.Printf(" - Database Updated: %t", server.Status != currentStatus) + // log.Printf(" - Notification Config Preserved: ID='%s', Template='%s'", server.NotificationID, server.TemplateID) + + // Final debug after all operations + sm.pbClient.DebugServerFields(server.ID, "FINAL_CHECK") + + // log.Printf("=== END SERVER CHECK FOR: %s ===\n", server.Name) + _ = statusReason +} + +// handleStatusNotifications handles sending notifications based on status changes and retry logic +func (sm *ServerMonitor) handleStatusNotifications(server Server, currentStatus, message string, statusChanged, isFirstCheck bool) { + // log.Printf("๐Ÿ“ฑ === NOTIFICATION ANALYSIS FOR: %s ===", server.Name) + // log.Printf("๐Ÿ”ง Notification Configuration:") + // log.Printf(" - Notification ID: '%s'", server.NotificationID) + // log.Printf(" - Template ID: '%s'", server.TemplateID) + // log.Printf(" - Has Notification Config: %t", server.NotificationID != "") + // log.Printf(" - Max Retries: %s", server.MaxRetries) + + if server.NotificationID == "" { + // log.Printf("๐Ÿ”• NOTIFICATION SKIPPED: No notification ID configured for server %s", server.Name) + return + } + + // Parse max retries + maxRetries, err := strconv.Atoi(server.MaxRetries) + if err != nil { + // log.Printf("Invalid max_retries value '%s' for server %s, using default 3", server.MaxRetries, server.Name) + maxRetries = 3 + } + + // Determine if notification should be sent + shouldSendNotification := false + notificationReason := "" + + if currentStatus == "down" { + if sm.shouldSendStatusAlert(server.ServerID, "down", maxRetries) { + shouldSendNotification = true + retryCount := sm.getStatusRetryCount(server.ServerID) + notificationReason = fmt.Sprintf("server down - retry %d/%d", retryCount+1, maxRetries) + } else { + notificationReason = "server down but max retries reached" + } + } else if currentStatus == "up" && sm.shouldSendRecoveryStatusAlert(server.ServerID) { + shouldSendNotification = true + notificationReason = "server recovery notification" + } + + // log.Printf("๐ŸŽฏ Notification Decision:") + // log.Printf(" - Should Send: %t", shouldSendNotification) + // log.Printf(" - Reason: %s", notificationReason) + + if shouldSendNotification { + // log.Printf("๐Ÿ“ค SENDING NOTIFICATION for server %s", server.Name) + // log.Printf("๐Ÿ“ง Notification Details:") + // log.Printf(" - Server Name: %s", server.Name) + // log.Printf(" - Status: %s", currentStatus) + // log.Printf(" - Message: %s", message) + // log.Printf(" - Notification ID: %s", server.NotificationID) + // log.Printf(" - Template ID: %s", server.TemplateID) + // log.Printf(" - Reason: %s", notificationReason) + + err := sm.notificationService.SendServerNotification(server, currentStatus, message) + if err != nil { + // log.Printf("โŒ NOTIFICATION FAILED for server %s: %v", server.Name, err) + _ = err + } else { + // log.Printf("โœ… NOTIFICATION SENT SUCCESSFULLY for server %s", server.Name) + + // Update alert tracking based on status + if currentStatus == "down" { + sm.setStatusAlert(server.ServerID, "down") + } else if currentStatus == "up" { + sm.clearStatusAlert(server.ServerID) + } + } + } else { + // log.Printf("๐Ÿ“ฑ NOTIFICATION SKIPPED for server %s: %s", server.Name, notificationReason) + } + + _ = statusChanged + _ = isFirstCheck + _ = notificationReason +} + +// shouldSendStatusAlert determines if a status alert should be sent based on retry count +func (sm *ServerMonitor) shouldSendStatusAlert(serverID, status string, maxRetries int) bool { + sm.mu.RLock() + alert, exists := sm.activeStatusAlerts[serverID] + sm.mu.RUnlock() + + if !exists { + return true // No existing alert, send new one + } + + // Check if we've reached max retries + if alert.RetryCount >= maxRetries { + // log.Printf("๐Ÿ”‡ Status alert for server %s has reached max retries (%d), not sending notification", serverID, maxRetries) + return false + } + + // Check if enough time has passed since last alert (resend every 5 minutes) + timeSinceLastAlert := time.Since(alert.LastAlerted) + return timeSinceLastAlert > 5*time.Minute +} + +// getStatusRetryCount returns the current retry count for a status alert +func (sm *ServerMonitor) getStatusRetryCount(serverID string) int { + sm.mu.RLock() + defer sm.mu.RUnlock() + + if alert, exists := sm.activeStatusAlerts[serverID]; exists { + return alert.RetryCount + } + return 0 +} + +// shouldSendRecoveryStatusAlert determines if a recovery alert should be sent +func (sm *ServerMonitor) shouldSendRecoveryStatusAlert(serverID string) bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + + _, exists := sm.activeStatusAlerts[serverID] + return exists // Send recovery only if there was an active alert +} + +// setStatusAlert creates or updates a status alert with incremented retry count +func (sm *ServerMonitor) setStatusAlert(serverID, status string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + alert, exists := sm.activeStatusAlerts[serverID] + if exists { + // Update existing alert and increment retry count + alert.Status = status + alert.LastAlerted = time.Now() + alert.RetryCount++ + // log.Printf("๐Ÿ“Š Updated status alert for server %s - Retry count: %d", serverID, alert.RetryCount) + } else { + // Create new alert + sm.activeStatusAlerts[serverID] = &StatusAlert{ + IsActive: true, + Status: status, + LastAlerted: time.Now(), + RetryCount: 1, // Start with 1 since we're sending the first notification + } + // log.Printf("๐Ÿ†• Created new status alert for server %s - Retry count: 1", serverID) + } +} + +// clearStatusAlert removes a status alert +func (sm *ServerMonitor) clearStatusAlert(serverID string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + if _, exists := sm.activeStatusAlerts[serverID]; exists { + // log.Printf("๐Ÿ—‘๏ธ Cleared status alert for server %s", serverID) + delete(sm.activeStatusAlerts, serverID) + } +} \ No newline at end of file diff --git a/server/service-operation/server-monitoring/notification_service.go b/server/service-operation/server-monitoring/notification_service.go new file mode 100644 index 0000000..63cc487 --- /dev/null +++ b/server/service-operation/server-monitoring/notification_service.go @@ -0,0 +1,236 @@ + +package servermonitoring + +import ( + "time" + + "service-operation/notification" + "service-operation/pocketbase" +) + +// ServerNotificationService handles server-specific notifications +type ServerNotificationService struct { + notificationManager *notification.NotificationManager +} + +// NewServerNotificationService creates a new server notification service +func NewServerNotificationService(pbClient *pocketbase.PocketBaseClient) *ServerNotificationService { + // log.Println("๐Ÿ”ง Creating new ServerNotificationService instance") + return &ServerNotificationService{ + notificationManager: notification.NewNotificationManager(pbClient), + } +} + +// SendResourceNotification sends a resource-specific notification (CPU, RAM, Disk, etc.) +func (sns *ServerNotificationService) SendResourceNotification(server Server, status string, message string, resourceType string) error { + return sns.SendResourceNotificationWithValues(server, status, message, resourceType, "N/A", "N/A") +} + +// SendResourceNotificationWithValues sends a resource-specific notification with actual usage and threshold values +func (sns *ServerNotificationService) SendResourceNotificationWithValues(server Server, status string, message string, resourceType string, usageValue string, thresholdValue string) error { + // log.Printf("๐Ÿ“จ === SENDING RESOURCE NOTIFICATION WITH VALUES ===") + // log.Printf("๐Ÿ”” SendResourceNotificationWithValues called for server: %s", server.Name) + // log.Printf("๐Ÿ“Š Resource Notification Parameters:") + // log.Printf(" - Server Name: %s", server.Name) + // log.Printf(" - Resource Type: %s", resourceType) + // log.Printf(" - Status: %s", status) + // log.Printf(" - Message: %s", message) + // log.Printf(" - Usage Value: %s", usageValue) + // log.Printf(" - Threshold Value: %s", thresholdValue) + // log.Printf(" - Notification ID: '%s'", server.NotificationID) + // log.Printf(" - Template ID: '%s'", server.TemplateID) + + // Validate notification configuration + if server.NotificationID == "" { + // log.Printf("โŒ NOTIFICATION FAILED: No notification ID configured for server %s", server.Name) + return nil + } + + // Get server metrics for notification + cpuUsage, ramUsage, diskUsage, networkUsage, cpuTemp, diskIO := sns.getServerMetrics(server.ServerID) + + // Override specific resource metric with actual value + switch resourceType { + case "cpu": + cpuUsage = usageValue + case "ram", "memory": + ramUsage = usageValue + case "disk": + diskUsage = usageValue + case "network": + networkUsage = usageValue + case "cpu_temp", "cpu_temperature": + cpuTemp = usageValue + case "disk_io": + diskIO = usageValue + } + + // Convert to generic notification payload + notificationPayload := ¬ification.NotificationPayload{ + ServiceName: server.Name, + Status: status, + Host: server.IPAddress, + Hostname: server.Hostname, + Port: 0, + ServiceType: "server", + ResponseTime: 0, + Timestamp: time.Now(), + Message: message, + // Server monitoring metrics + CPUUsage: cpuUsage, + RAMUsage: ramUsage, + DiskUsage: diskUsage, + NetworkUsage: networkUsage, + CPUTemp: cpuTemp, + DiskIO: diskIO, + Threshold: thresholdValue, + } + + // log.Printf("๐Ÿ“ค About to call SendResourceNotification...") + // log.Printf(" - Resource Type: %s", resourceType) + // log.Printf(" - Notification ID: %s", server.NotificationID) + // log.Printf(" - Template ID: %s", server.TemplateID) + // log.Printf(" - Usage Value: %s", usageValue) + // log.Printf(" - Threshold Value: %s", thresholdValue) + + // Send resource-specific notification using the notification manager + err := sns.notificationManager.SendResourceNotification(notificationPayload, server.NotificationID, server.TemplateID, resourceType) + if err != nil { + // log.Printf("โŒ RESOURCE NOTIFICATION ERROR for server %s: %v", server.Name, err) + return err + } + + // log.Printf("โœ… RESOURCE NOTIFICATION SENT SUCCESSFULLY for server %s (%s)", server.Name, resourceType) + // log.Printf("=== RESOURCE NOTIFICATION COMPLETE ===") + + return nil +} + +// SendServerNotification sends a notification for server status change +func (sns *ServerNotificationService) SendServerNotification(server Server, status string, message string) error { + // log.Printf("๐Ÿ“จ === SENDING SERVER NOTIFICATION ===") + // log.Printf("๐Ÿ”” SendServerNotification called for server: %s", server.Name) + // log.Printf("๐Ÿ“Š Notification Parameters:") + // log.Printf(" - Server Name: %s", server.Name) + // log.Printf(" - Server ID: %s", server.ServerID) + // log.Printf(" - Status: %s", status) + // log.Printf(" - Message: %s", message) + // log.Printf(" - Notification ID: '%s'", server.NotificationID) + // log.Printf(" - Template ID: '%s'", server.TemplateID) + // log.Printf(" - IP Address: %s", server.IPAddress) + // log.Printf(" - Hostname: %s", server.Hostname) + + // Validate notification configuration + if server.NotificationID == "" { + // log.Printf("โŒ NOTIFICATION FAILED: No notification ID configured for server %s", server.Name) + return nil + } + + // log.Printf("โœ… Notification ID validation passed: %s", server.NotificationID) + + // Get server metrics for notification + cpuUsage, ramUsage, diskUsage, networkUsage, cpuTemp, diskIO := sns.getServerMetrics(server.ServerID) + + // Convert to generic notification payload + // log.Printf("๐Ÿ”„ Converting to generic notification payload...") + notificationPayload := ¬ification.NotificationPayload{ + ServiceName: server.Name, + Status: status, + Host: server.IPAddress, + Hostname: server.Hostname, + Port: 0, // Servers don't have ports + ServiceType: "server", + ResponseTime: 0, + Timestamp: time.Now(), + Message: message, + // Server monitoring metrics + CPUUsage: cpuUsage, + RAMUsage: ramUsage, + DiskUsage: diskUsage, + NetworkUsage: networkUsage, + CPUTemp: cpuTemp, + DiskIO: diskIO, + Threshold: "N/A", // Can be set based on server configuration + } + + // log.Printf("๐Ÿ“ฆ Generic notification payload created:") + // log.Printf(" - ServiceName: %s", notificationPayload.ServiceName) + // log.Printf(" - Status: %s", notificationPayload.Status) + // log.Printf(" - Host: %s", notificationPayload.Host) + // log.Printf(" - ServiceType: %s", notificationPayload.ServiceType) + // log.Printf(" - Message: %s", notificationPayload.Message) + // log.Printf(" - CPUUsage: %s", notificationPayload.CPUUsage) + // log.Printf(" - RAMUsage: %s", notificationPayload.RAMUsage) + // log.Printf(" - DiskUsage: %s", notificationPayload.DiskUsage) + // log.Printf(" - NetworkUsage: %s", notificationPayload.NetworkUsage) + // log.Printf(" - CPUTemp: %s", notificationPayload.CPUTemp) + // log.Printf(" - DiskIO: %s", notificationPayload.DiskIO) + // log.Printf(" - Timestamp: %v", notificationPayload.Timestamp) + + // log.Printf("๐Ÿ“ค About to call SendServiceNotification...") + // log.Printf(" - Notification ID: %s", server.NotificationID) + // log.Printf(" - Template ID: %s", server.TemplateID) + + // Send notification using the notification manager + err := sns.notificationManager.SendServiceNotification(notificationPayload, server.NotificationID, server.TemplateID) + if err != nil { + // log.Printf("โŒ NOTIFICATION MANAGER ERROR for server %s: %v", server.Name, err) + // log.Printf("โŒ Error Details:") + // log.Printf(" - Error Type: %T", err) + // log.Printf(" - Error Message: %s", err.Error()) + // log.Printf(" - Notification ID used: %s", server.NotificationID) + // log.Printf(" - Template ID used: %s", server.TemplateID) + return err + } + + // log.Printf("โœ… NOTIFICATION SENT SUCCESSFULLY for server %s", server.Name) + // log.Printf("โœ… Notification Details:") + // log.Printf(" - Status: %s", status) + // log.Printf(" - Notification ID: %s", server.NotificationID) + // log.Printf(" - Template ID: %s", server.TemplateID) + // log.Printf("=== NOTIFICATION COMPLETE ===") + + return nil +} + +// getServerMetrics retrieves the latest server metrics (placeholder implementation) +// This should be replaced with actual metric retrieval from your monitoring system +func (sns *ServerNotificationService) getServerMetrics(serverID string) (cpuUsage, ramUsage, diskUsage, networkUsage, cpuTemp, diskIO string) { + // TODO: Implement actual metric retrieval from your server monitoring system + // For now, returning placeholder values + // You should replace this with actual calls to your metrics collection system + + // log.Printf("๐Ÿ” Retrieving server metrics for server ID: %s", serverID) + + // These should be replaced with actual metric values from your monitoring system + cpuUsage = "N/A" + ramUsage = "N/A" + diskUsage = "N/A" + networkUsage = "N/A" + cpuTemp = "N/A" + diskIO = "N/A" + + // Example of how you might retrieve metrics: + // You could query your metrics database, call an API, or read from system files + // For example: + // metrics, err := sns.getLatestMetrics(serverID) + // if err == nil { + // cpuUsage = fmt.Sprintf("%.1f%%", metrics.CPUUsage) + // ramUsage = fmt.Sprintf("%.1f%%", metrics.RAMUsage) + // diskUsage = fmt.Sprintf("%.1f%%", metrics.DiskUsage) + // networkUsage = fmt.Sprintf("%.2f MB/s", metrics.NetworkUsage) + // cpuTemp = fmt.Sprintf("%.1fยฐC", metrics.CPUTemp) + // diskIO = fmt.Sprintf("%.2f MB/s", metrics.DiskIO) + // } + + // log.Printf("๐Ÿ“Š Retrieved server metrics:") + // log.Printf(" - CPU Usage: %s", cpuUsage) + // log.Printf(" - RAM Usage: %s", ramUsage) + // log.Printf(" - Disk Usage: %s", diskUsage) + // log.Printf(" - Network Usage: %s", networkUsage) + // log.Printf(" - CPU Temperature: %s", cpuTemp) + // log.Printf(" - Disk I/O: %s", diskIO) + + _ = serverID + return cpuUsage, ramUsage, diskUsage, networkUsage, cpuTemp, diskIO +} \ No newline at end of file diff --git a/server/service-operation/server-monitoring/pocketbase_client.go b/server/service-operation/server-monitoring/pocketbase_client.go new file mode 100644 index 0000000..179a262 --- /dev/null +++ b/server/service-operation/server-monitoring/pocketbase_client.go @@ -0,0 +1,287 @@ +package servermonitoring + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "time" + + "service-operation/pocketbase" +) + +// ServerMetrics represents server metrics record in PocketBase +type ServerMetrics struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + Timestamp string `json:"timestamp"` // Changed to string for custom parsing + RAMTotal string `json:"ram_total"` + RAMUsed string `json:"ram_used"` + RAMFree string `json:"ram_free"` + CPUCores string `json:"cpu_cores"` + CPUUsage string `json:"cpu_usage"` + CPUFree string `json:"cpu_free"` + DiskTotal string `json:"disk_total"` + DiskUsed string `json:"disk_used"` + DiskFree string `json:"disk_free"` + Status string `json:"status"` + NetworkRxBytes int64 `json:"network_rx_bytes"` + NetworkTxBytes int64 `json:"network_tx_bytes"` + NetworkRxSpeed int64 `json:"network_rx_speed"` + NetworkTxSpeed int64 `json:"network_tx_speed"` + Created string `json:"created"` // Changed to string for custom parsing + Updated string `json:"updated"` // Changed to string for custom parsing +} + +// ParsedServerMetrics represents ServerMetrics with parsed time fields +type ParsedServerMetrics struct { + ServerMetrics + CreatedTime time.Time + UpdatedTime time.Time + TimestampTime time.Time +} + +// ParseServerMetrics converts ServerMetrics to ParsedServerMetrics with proper time parsing +func ParseServerMetrics(sm ServerMetrics) (ParsedServerMetrics, error) { + psm := ParsedServerMetrics{ServerMetrics: sm} + + var err error + + // Parse Created time + if sm.Created != "" { + psm.CreatedTime, err = parsePocketBaseTime(sm.Created) + if err != nil { + log.Printf("Warning: Failed to parse Created time '%s': %v", sm.Created, err) + } + } + + // Parse Updated time + if sm.Updated != "" { + psm.UpdatedTime, err = parsePocketBaseTime(sm.Updated) + if err != nil { + log.Printf("Warning: Failed to parse Updated time '%s': %v", sm.Updated, err) + } + } + + // Parse Timestamp + if sm.Timestamp != "" { + psm.TimestampTime, err = parsePocketBaseTime(sm.Timestamp) + if err != nil { + log.Printf("Warning: Failed to parse Timestamp '%s': %v", sm.Timestamp, err) + } + } + + return psm, nil +} + +// parsePocketBaseTime parses PocketBase time format "2025-08-11 13:09:13.243Z" +func parsePocketBaseTime(timeStr string) (time.Time, error) { + // First try the PocketBase format with space separator + if t, err := time.Parse("2006-01-02 15:04:05.000Z", timeStr); err == nil { + return t, nil + } + + // Fallback to RFC3339 format with T separator + if t, err := time.Parse(time.RFC3339, timeStr); err == nil { + return t, nil + } + + // Fallback to RFC3339Nano format + if t, err := time.Parse(time.RFC3339Nano, timeStr); err == nil { + return t, nil + } + + return time.Time{}, fmt.Errorf("unable to parse time string: %s", timeStr) +} + +// ServerPocketBaseClient is a wrapper around the PocketBase client for server monitoring +type ServerPocketBaseClient struct { + client *pocketbase.PocketBaseClient +} + +// NewServerPocketBaseClient creates a new server PocketBase client +func NewServerPocketBaseClient(client *pocketbase.PocketBaseClient) *ServerPocketBaseClient { + return &ServerPocketBaseClient{ + client: client, + } +} + +// GetServerThreshold fetches threshold configuration for a server +func (spc *ServerPocketBaseClient) GetServerThreshold(thresholdID string) (*ServerThreshold, error) { + if thresholdID == "" { + log.Printf("No threshold ID provided") + _ = thresholdID + return nil, nil + } + + url := fmt.Sprintf("%s/api/collections/server_threshold_templates/records/%s", spc.client.GetBaseURL(), thresholdID) + //log.Printf("๐Ÿ” Fetching server threshold from: %s", url) + + resp, err := http.Get(url) + if err != nil { + //log.Printf("โŒ HTTP error fetching server threshold: %v", err) + _ = err + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + //log.Printf("โŒ Failed to fetch server threshold, status: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch server threshold, status: %d", resp.StatusCode) + } + + var threshold ServerThreshold + if err := json.NewDecoder(resp.Body).Decode(&threshold); err != nil { + //log.Printf("โŒ Error decoding server threshold JSON: %v", err) + _ = err + return nil, err + } + + //log.Printf("โœ… Successfully fetched server threshold: %+v", threshold) + _ = url + _ = threshold + return &threshold, nil +} + +// GetAllServers retrieves all servers from PocketBase +func (spc *ServerPocketBaseClient) GetAllServers() ([]Server, error) { + url := fmt.Sprintf("%s/api/collections/servers/records?perPage=500", spc.client.GetBaseURL()) + //log.Printf("๐ŸŒ Fetching all servers from: %s", url) + + resp, err := http.Get(url) + if err != nil { + //log.Printf("โŒ HTTP error fetching servers: %v", err) + _ = err + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + //log.Printf("โŒ Failed to fetch servers, status: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch servers, status: %d", resp.StatusCode) + } + + var response struct { + Items []Server `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + //log.Printf("โŒ Error decoding servers JSON: %v", err) + _ = err + return nil, err + } + + //log.Printf("โœ… Successfully fetched %d servers", len(response.Items)) + _ = url + return response.Items, nil +} + +// GetLatestServerMetrics retrieves the latest server metrics from PocketBase +func (spc *ServerPocketBaseClient) GetLatestServerMetrics(serverID string, timeout time.Duration) ([]ParsedServerMetrics, error) { + // Calculate the time before which metrics are considered too old + cutoff := time.Now().Add(-timeout).UTC().Format("2006-01-02 15:04:05.000Z") + + // Construct the filter string with proper formatting (no spaces around operators) + filter := fmt.Sprintf("server_id='%s'&&created>'%s'", serverID, cutoff) + + // URL encode the filter parameter + encodedFilter := url.QueryEscape(filter) + + // Construct the URL with properly encoded parameters + requestURL := fmt.Sprintf("%s/api/collections/server_metrics/records?filter=%s&sort=-created&perPage=1", + spc.client.GetBaseURL(), encodedFilter) + + //log.Printf("๐ŸŒ Fetching latest server metrics from: %s", requestURL) + //log.Printf("๐Ÿ” Filter used: %s", filter) + //log.Printf("๐Ÿ” Cutoff time: %s", cutoff) + + resp, err := http.Get(requestURL) + if err != nil { + //log.Printf("โŒ HTTP error fetching server metrics: %v", err) + _ = err + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + //log.Printf("โŒ Failed to fetch server metrics, status: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch server metrics, status: %d", resp.StatusCode) + } + + var response struct { + Items []ServerMetrics `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + //log.Printf("โŒ Error decoding server metrics JSON: %v", err) + _ = err + return nil, err + } + + // Convert to ParsedServerMetrics + var parsedMetrics []ParsedServerMetrics + for _, metric := range response.Items { + parsed, err := ParseServerMetrics(metric) + if err != nil { + //log.Printf("โŒ Error parsing server metric: %v", err) + _ = err + continue + } + parsedMetrics = append(parsedMetrics, parsed) + } + + //log.Printf("โœ… Successfully fetched and parsed %d server metrics", len(parsedMetrics)) + _ = requestURL + _ = filter + _ = cutoff + return parsedMetrics, nil +} + +// UpdateServerStatus updates the server status in PocketBase +func (spc *ServerPocketBaseClient) UpdateServerStatus(serverID string, status string) error { + url := fmt.Sprintf("%s/api/collections/servers/records/%s", spc.client.GetBaseURL(), serverID) + //log.Printf("๐ŸŒ Updating server status at: %s", url) + + payload := map[string]interface{}{ + "status": status, + "last_checked": time.Now().UTC().Format("2006-01-02 15:04:05.000Z"), + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + //log.Printf("โŒ Error marshaling payload: %v", err) + _ = err + return err + } + + //log.Printf("๐Ÿ“ Update payload: %s", string(payloadBytes)) + + req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(payloadBytes)) + if err != nil { + //log.Printf("โŒ Error creating request: %v", err) + _ = err + return err + } + + req.Header.Set("Content-Type", "application/json") + + httpClient := &http.Client{Timeout: 10 * time.Second} + resp, err := httpClient.Do(req) + if err != nil { + //log.Printf("โŒ HTTP error updating server status: %v", err) + _ = err + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + //log.Printf("โŒ Failed to update server status, status: %d", resp.StatusCode) + return fmt.Errorf("failed to update server status, status: %d", resp.StatusCode) + } + + //log.Printf("โœ… Successfully updated server status to %s", status) + _ = url + _ = payloadBytes + _ = status + return nil +} \ No newline at end of file diff --git a/server/service-operation/server-monitoring/threshold_monitor.go b/server/service-operation/server-monitoring/threshold_monitor.go new file mode 100644 index 0000000..893da04 --- /dev/null +++ b/server/service-operation/server-monitoring/threshold_monitor.go @@ -0,0 +1,333 @@ + +package servermonitoring + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// ThresholdAlert tracks alert state and retry count +type ThresholdAlert struct { + IsActive bool + Threshold int + CurrentValue float64 + LastAlerted time.Time + RetryCount int // Track how many times we've sent this alert +} + +// ThresholdMonitor handles server threshold monitoring and alerting +type ThresholdMonitor struct { + pbClient *ServerPocketBaseClient + notificationService *ServerNotificationService + activeAlerts map[string]*ThresholdAlert // serverID-metricType -> alert +} + +// NewThresholdMonitor creates a new threshold monitor +func NewThresholdMonitor(pbClient *ServerPocketBaseClient, notificationService *ServerNotificationService) *ThresholdMonitor { + return &ThresholdMonitor{ + pbClient: pbClient, + notificationService: notificationService, + activeAlerts: make(map[string]*ThresholdAlert), + } +} + +// CheckServerThresholds checks all thresholds for a server +func (tm *ThresholdMonitor) CheckServerThresholds(server Server, metrics []ParsedServerMetrics) { + if server.ThresholdID == "" { + // log.Printf("No threshold configuration for server %s", server.Name) + return + } + + // Get threshold configuration + threshold, err := tm.pbClient.GetServerThreshold(server.ThresholdID) + if err != nil { + // log.Printf("โŒ Failed to get threshold config for server %s: %v", server.Name, err) + _ = err + return + } + + if threshold == nil { + // log.Printf("No threshold configuration found for server %s", server.Name) + return + } + + if len(metrics) == 0 { + // log.Printf("No metrics available for threshold checking on server %s", server.Name) + return + } + + latestMetric := metrics[0] + // log.Printf("๐ŸŽฏ Checking thresholds for server %s with threshold config: %+v", server.Name, threshold) + + // Parse MaxRetries from string to int + maxRetries, err := strconv.Atoi(server.MaxRetries) + if err != nil { + // log.Printf("Invalid max_retries value '%s' for server %s, using default 3", server.MaxRetries, server.Name) + maxRetries = 3 + } + + // Check CPU threshold + tm.checkCPUThreshold(server, latestMetric, threshold, maxRetries) + + // Check RAM threshold + tm.checkRAMThreshold(server, latestMetric, threshold, maxRetries) + + // Check Disk threshold + tm.checkDiskThreshold(server, latestMetric, threshold, maxRetries) +} + +// checkCPUThreshold checks CPU usage against threshold +func (tm *ThresholdMonitor) checkCPUThreshold(server Server, metric ParsedServerMetrics, threshold *ServerThreshold, maxRetries int) { + cpuThresholdValue, err := strconv.Atoi(threshold.CPUThreshold) + if err != nil { + // log.Printf("Invalid CPU threshold value '%s' for server %s", threshold.CPUThreshold, server.Name) + return + } + + // Parse CPU usage from metric (format: "XX.XX%") + cpuUsageStr := strings.TrimSuffix(metric.CPUUsage, "%") + cpuUsage, err := strconv.ParseFloat(cpuUsageStr, 64) + if err != nil { + // log.Printf("Failed to parse CPU usage '%s' for server %s", metric.CPUUsage, server.Name) + return + } + + alertKey := fmt.Sprintf("%s-cpu", server.ServerID) + + if cpuUsage > float64(cpuThresholdValue) { + // Threshold exceeded + if tm.shouldCreateAlert(alertKey, cpuUsage, float64(cpuThresholdValue), maxRetries) { + // log.Printf("๐Ÿšจ CPU threshold exceeded for server %s: %.2f%% > %d%% (retry %d/%d)", + // server.Name, cpuUsage, cpuThresholdValue, tm.getRetryCount(alertKey)+1, maxRetries) + + message := fmt.Sprintf("๐Ÿšจ CPU Alert: Server %s CPU usage is %.2f%% (threshold: %d%%)", + server.Name, cpuUsage, cpuThresholdValue) + + tm.sendThresholdAlert(server, "cpu", message, cpuUsage, float64(cpuThresholdValue), fmt.Sprintf("%.2f%%", cpuUsage), fmt.Sprintf("%d%%", cpuThresholdValue)) + tm.setAlert(alertKey, cpuUsage, float64(cpuThresholdValue)) + } else { + // log.Printf("๐Ÿ”‡ CPU threshold exceeded for server %s but max retries (%d) reached, skipping notification", server.Name, maxRetries) + } + } else { + // Check if we need to send recovery notification + if tm.shouldSendRecoveryAlert(alertKey) { + // log.Printf("โœ… CPU recovered for server %s: %.2f%% <= %d%%", server.Name, cpuUsage, cpuThresholdValue) + + message := fmt.Sprintf("โœ… CPU Recovery: Server %s CPU usage is back to normal: %.2f%% (threshold: %d%%)", + server.Name, cpuUsage, cpuThresholdValue) + + tm.sendThresholdRecovery(server, "cpu", message, fmt.Sprintf("%.2f%%", cpuUsage), fmt.Sprintf("%d%%", cpuThresholdValue)) + tm.clearAlert(alertKey) + } + } +} + +// checkRAMThreshold checks RAM usage against threshold +func (tm *ThresholdMonitor) checkRAMThreshold(server Server, metric ParsedServerMetrics, threshold *ServerThreshold, maxRetries int) { + ramThresholdValue, err := strconv.Atoi(threshold.RAMThreshold) + if err != nil { + // log.Printf("Invalid RAM threshold value '%s' for server %s", threshold.RAMThreshold, server.Name) + return + } + + // Parse RAM usage from metric (format: "X.XX GB (XX.X%)") + ramUsageStr := metric.RAMUsed + if strings.Contains(ramUsageStr, "(") && strings.Contains(ramUsageStr, "%)") { + // Extract percentage from parentheses + start := strings.Index(ramUsageStr, "(") + 1 + end := strings.Index(ramUsageStr, "%)") + if start < end { + ramPercentageStr := ramUsageStr[start:end] + ramUsage, err := strconv.ParseFloat(ramPercentageStr, 64) + if err != nil { + // log.Printf("Failed to parse RAM usage percentage '%s' for server %s", ramPercentageStr, server.Name) + return + } + + alertKey := fmt.Sprintf("%s-ram", server.ServerID) + + if ramUsage > float64(ramThresholdValue) { + // Threshold exceeded + if tm.shouldCreateAlert(alertKey, ramUsage, float64(ramThresholdValue), maxRetries) { + // log.Printf("๐Ÿšจ RAM threshold exceeded for server %s: %.2f%% > %d%% (retry %d/%d)", + // server.Name, ramUsage, ramThresholdValue, tm.getRetryCount(alertKey)+1, maxRetries) + + message := fmt.Sprintf("๐Ÿšจ RAM Alert: Server %s RAM usage is %.2f%% (threshold: %d%%)", + server.Name, ramUsage, ramThresholdValue) + + tm.sendThresholdAlert(server, "ram", message, ramUsage, float64(ramThresholdValue), fmt.Sprintf("%.2f%%", ramUsage), fmt.Sprintf("%d%%", ramThresholdValue)) + tm.setAlert(alertKey, ramUsage, float64(ramThresholdValue)) + } else { + // log.Printf("๐Ÿ”‡ RAM threshold exceeded for server %s but max retries (%d) reached, skipping notification", server.Name, maxRetries) + } + } else { + // Check if we need to send recovery notification + if tm.shouldSendRecoveryAlert(alertKey) { + // log.Printf("โœ… RAM recovered for server %s: %.2f%% <= %d%%", server.Name, ramUsage, ramThresholdValue) + + message := fmt.Sprintf("โœ… RAM Recovery: Server %s RAM usage is back to normal: %.2f%% (threshold: %d%%)", + server.Name, ramUsage, ramThresholdValue) + + tm.sendThresholdRecovery(server, "ram", message, fmt.Sprintf("%.2f%%", ramUsage), fmt.Sprintf("%d%%", ramThresholdValue)) + tm.clearAlert(alertKey) + } + } + } + } +} + +// checkDiskThreshold checks disk usage against threshold +func (tm *ThresholdMonitor) checkDiskThreshold(server Server, metric ParsedServerMetrics, threshold *ServerThreshold, maxRetries int) { + diskThresholdValue, err := strconv.Atoi(threshold.DiskThreshold) + if err != nil { + // log.Printf("Invalid disk threshold value '%s' for server %s", threshold.DiskThreshold, server.Name) + return + } + + // Parse disk usage from metric (format: "X.XX GB (XX.X%)") + diskUsageStr := metric.DiskUsed + if strings.Contains(diskUsageStr, "(") && strings.Contains(diskUsageStr, "%)") { + // Extract percentage from parentheses + start := strings.Index(diskUsageStr, "(") + 1 + end := strings.Index(diskUsageStr, "%)") + if start < end { + diskPercentageStr := diskUsageStr[start:end] + diskUsage, err := strconv.ParseFloat(diskPercentageStr, 64) + if err != nil { + // log.Printf("Failed to parse disk usage percentage '%s' for server %s", diskPercentageStr, server.Name) + return + } + + alertKey := fmt.Sprintf("%s-disk", server.ServerID) + + if diskUsage > float64(diskThresholdValue) { + // Threshold exceeded + if tm.shouldCreateAlert(alertKey, diskUsage, float64(diskThresholdValue), maxRetries) { + // log.Printf("๐Ÿšจ Disk threshold exceeded for server %s: %.2f%% > %d%% (retry %d/%d)", + // server.Name, diskUsage, diskThresholdValue, tm.getRetryCount(alertKey)+1, maxRetries) + + message := fmt.Sprintf("๐Ÿšจ Disk Alert: Server %s disk usage is %.2f%% (threshold: %d%%)", + server.Name, diskUsage, diskThresholdValue) + + tm.sendThresholdAlert(server, "disk", message, diskUsage, float64(diskThresholdValue), fmt.Sprintf("%.2f%%", diskUsage), fmt.Sprintf("%d%%", diskThresholdValue)) + tm.setAlert(alertKey, diskUsage, float64(diskThresholdValue)) + } else { + // log.Printf("๐Ÿ”‡ Disk threshold exceeded for server %s but max retries (%d) reached, skipping notification", server.Name, maxRetries) + } + } else { + // Check if we need to send recovery notification + if tm.shouldSendRecoveryAlert(alertKey) { + // log.Printf("โœ… Disk recovered for server %s: %.2f%% <= %d%%", server.Name, diskUsage, diskThresholdValue) + + message := fmt.Sprintf("โœ… Disk Recovery: Server %s disk usage is back to normal: %.2f%% (threshold: %d%%)", + server.Name, diskUsage, diskThresholdValue) + + tm.sendThresholdRecovery(server, "disk", message, fmt.Sprintf("%.2f%%", diskUsage), fmt.Sprintf("%d%%", diskThresholdValue)) + tm.clearAlert(alertKey) + } + } + } + } +} + +// shouldCreateAlert determines if an alert should be created based on retry count and max retries +func (tm *ThresholdMonitor) shouldCreateAlert(alertKey string, currentValue, threshold float64, maxRetries int) bool { + alert, exists := tm.activeAlerts[alertKey] + if !exists { + return true // No existing alert, create new one + } + + // Check if we've reached max retries + if alert.RetryCount >= maxRetries { + // log.Printf("๐Ÿ”‡ Alert %s has reached max retries (%d), not sending notification", alertKey, maxRetries) + return false + } + + // Check if enough time has passed since last alert (resend every 5 minutes) + timeSinceLastAlert := time.Since(alert.LastAlerted) + return timeSinceLastAlert > 5*time.Minute +} + +// getRetryCount returns the current retry count for an alert +func (tm *ThresholdMonitor) getRetryCount(alertKey string) int { + if alert, exists := tm.activeAlerts[alertKey]; exists { + return alert.RetryCount + } + return 0 +} + +// shouldSendRecoveryAlert determines if a recovery alert should be sent +func (tm *ThresholdMonitor) shouldSendRecoveryAlert(alertKey string) bool { + _, exists := tm.activeAlerts[alertKey] + return exists // Send recovery only if there was an active alert +} + +// setAlert creates or updates an alert with incremented retry count +func (tm *ThresholdMonitor) setAlert(alertKey string, currentValue, threshold float64) { + alert, exists := tm.activeAlerts[alertKey] + if exists { + // Update existing alert and increment retry count + alert.CurrentValue = currentValue + alert.LastAlerted = time.Now() + alert.RetryCount++ + // log.Printf("๐Ÿ“Š Updated alert %s - Retry count: %d", alertKey, alert.RetryCount) + } else { + // Create new alert + tm.activeAlerts[alertKey] = &ThresholdAlert{ + IsActive: true, + Threshold: int(threshold), + CurrentValue: currentValue, + LastAlerted: time.Now(), + RetryCount: 1, // Start with 1 since we're sending the first notification + } + // log.Printf("๐Ÿ†• Created new alert %s - Retry count: 1", alertKey) + } +} + +// clearAlert removes an alert +func (tm *ThresholdMonitor) clearAlert(alertKey string) { + if _, exists := tm.activeAlerts[alertKey]; exists { + // log.Printf("๐Ÿ—‘๏ธ Cleared alert %s", alertKey) + delete(tm.activeAlerts, alertKey) + } +} + +// sendThresholdAlert sends a threshold exceeded notification using resource-specific templates +func (tm *ThresholdMonitor) sendThresholdAlert(server Server, metricType, message string, currentValue, threshold float64, usageStr, thresholdStr string) { + if server.NotificationID == "" { + // log.Printf("No notification ID configured for server %s", server.Name) + return + } + + // log.Printf("๐Ÿ“ค Sending resource-specific threshold alert for server %s (%s): %s", server.Name, metricType, message) + + // Use the new resource-specific notification method with actual values + err := tm.notificationService.SendResourceNotificationWithValues(server, "warning", message, metricType, usageStr, thresholdStr) + if err != nil { + // log.Printf("โŒ Failed to send threshold alert for server %s: %v", server.Name, err) + _ = err + } else { + // log.Printf("โœ… Threshold alert sent successfully for server %s", server.Name) + } +} + +// sendThresholdRecovery sends a threshold recovery notification using resource-specific templates +func (tm *ThresholdMonitor) sendThresholdRecovery(server Server, metricType, message, usageStr, thresholdStr string) { + if server.NotificationID == "" { + // log.Printf("No notification ID configured for server %s", server.Name) + return + } + + // log.Printf("๐Ÿ“ค Sending resource-specific threshold recovery for server %s (%s): %s", server.Name, metricType, message) + + // Use the new resource-specific notification method with actual values + err := tm.notificationService.SendResourceNotificationWithValues(server, "up", message, metricType, usageStr, thresholdStr) + if err != nil { + // log.Printf("โŒ Failed to send threshold recovery for server %s: %v", server.Name, err) + _ = err + } else { + // log.Printf("โœ… Threshold recovery sent successfully for server %s", server.Name) + } +} \ No newline at end of file diff --git a/server/service-operation/server-monitoring/types.go b/server/service-operation/server-monitoring/types.go new file mode 100644 index 0000000..1dc242d --- /dev/null +++ b/server/service-operation/server-monitoring/types.go @@ -0,0 +1,77 @@ + +package servermonitoring + +import "time" + +// Server represents a server record in PocketBase +type Server struct { + CollectionId string `json:"collectionId"` + CollectionName string `json:"collectionName"` + ID string `json:"id"` + ServerID string `json:"server_id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + IPAddress string `json:"ip_address"` + OSType string `json:"os_type"` + Status string `json:"status"` // 'up' | 'down' | 'warning' | 'paused' + Uptime string `json:"uptime"` + RAMTotal float64 `json:"ram_total"` + RAMUsed float64 `json:"ram_used"` + CPUCores int `json:"cpu_cores"` + CPUUsage float64 `json:"cpu_usage"` + DiskTotal float64 `json:"disk_total"` + DiskUsed float64 `json:"disk_used"` + LastChecked string `json:"last_checked"` + ServerToken string `json:"server_token"` + TemplateID string `json:"template_id"` + ThresholdID string `json:"threshold_id"` + NotificationID string `json:"notification_id"` + NotificationStatus bool `json:"notification_status"` + MaxRetries string `json:"max_retries"` + Timestamp string `json:"timestamp"` + Connection string `json:"connection"` + AgentStatus string `json:"agent_status"` + SystemInfo string `json:"system_info"` + NetworkRxBytes string `json:"network_rx_bytes"` + NetworkTxBytes string `json:"network_tx_bytes"` + NetworkRxSpeed string `json:"network_rx_speed"` + NetworkTxSpeed string `json:"network_tx_speed"` + CheckInterval int `json:"check_interval"` + Docker string `json:"docker"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +// ServerStats represents server statistics +type ServerStats struct { + Total int `json:"total"` + Online int `json:"online"` + Offline int `json:"offline"` + Warning int `json:"warning"` +} + +// ServerThreshold represents threshold configuration for server monitoring +type ServerThreshold struct { + ID string `json:"id"` + Name string `json:"name"` + CPUThreshold string `json:"cpu_threshold"` + RAMThreshold string `json:"ram_threshold"` + DiskThreshold string `json:"disk_threshold"` + NetworkThreshold string `json:"network_threshold"` + DiskIOThreshold string `json:"disk_io_threshold"` + CPUTempThreshold string `json:"cpu_temp_threshold"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +// ServerNotificationPayload represents server-specific notification data +type ServerNotificationPayload struct { + ServerName string `json:"server_name"` + ServerID string `json:"server_id"` + Hostname string `json:"hostname"` + IPAddress string `json:"ip_address"` + Status string `json:"status"` + OSType string `json:"os_type"` + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/dns_saver.go b/server/service-operation/shared/savers/dns_saver.go new file mode 100644 index 0000000..5754f76 --- /dev/null +++ b/server/service-operation/shared/savers/dns_saver.go @@ -0,0 +1,74 @@ + +package savers + +import ( + "fmt" + "strings" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +func (ms *MetricsSaver) SaveDNSDataToPocketBase(result *types.OperationResult, serviceID string) { + // Create a short, professional status message + var details string + + if result.Success { + // Success message with record count and query info + recordCount := len(result.DNSRecords) + details = fmt.Sprintf("โœ… DNS %s Query OK - %d records found", + strings.ToUpper(result.DNSType), recordCount) + + // Add response time + details += fmt.Sprintf(" | Response time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + + // Add first few records for context + if recordCount > 0 { + if recordCount <= 2 { + details += fmt.Sprintf(" | Records: %s", strings.Join(result.DNSRecords, ", ")) + } else { + details += fmt.Sprintf(" | Records: %s... (+%d more)", + strings.Join(result.DNSRecords[:2], ", "), recordCount-2) + } + } + } else { + // Error message with query type + details = fmt.Sprintf("โŒ DNS %s Query Failed - %s", + strings.ToUpper(result.DNSType), + GetShortErrorMessage(result.Error)) + + // Add response time if available + if result.ResponseTime > 0 { + details += fmt.Sprintf(" | Response time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + } + } + + dnsData := pocketbase.DNSDataRecord{ + ServiceID: serviceID, + Timestamp: time.Now(), + ResponseTime: result.ResponseTime.Milliseconds(), + Status: GetStatusString(result.Success), + QueryType: result.DNSType, + ResolveIP: strings.Join(result.DNSRecords, ","), + MsgSize: fmt.Sprintf("%d", len(result.DNSRecords)), + Question: result.Host, + Answer: strings.Join(result.DNSRecords, ","), + Authority: "", // Not available in current implementation + ErrorMessage: result.Error, + Details: details, // Short, clean message + RegionName: ms.regionName, // Add regional fields + AgentID: ms.agentID, + } + + if err := ms.pbClient.SaveDNSData(dnsData); err != nil { + println("Failed to save DNS data to PocketBase:", err.Error()) + } +} + +// Method for monitoring service usage +func (ms *MetricsSaver) SaveDNSDataForService(service pocketbase.Service, result *types.OperationResult) { + ms.SaveDNSDataToPocketBase(result, service.ID) +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/metrics_saver.go b/server/service-operation/shared/savers/metrics_saver.go new file mode 100644 index 0000000..e9a64ca --- /dev/null +++ b/server/service-operation/shared/savers/metrics_saver.go @@ -0,0 +1,145 @@ +package savers + +import ( + "fmt" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +type MetricsSaver struct { + pbClient *pocketbase.PocketBaseClient + regionName string + agentID string +} + +func NewMetricsSaver(pbClient *pocketbase.PocketBaseClient) *MetricsSaver { + return &MetricsSaver{ + pbClient: pbClient, + regionName: "Default", // Default fallback + agentID: "1", // Default fallback + } +} + +func NewMetricsSaverWithRegion(pbClient *pocketbase.PocketBaseClient, regionName, agentID string) *MetricsSaver { + return &MetricsSaver{ + pbClient: pbClient, + regionName: regionName, + agentID: agentID, + } +} + +func (ms *MetricsSaver) SaveMetricsToPocketBase(result *types.OperationResult, serviceID string) { + // Save general metrics using the new structure + metrics := pocketbase.MetricsRecord{ + ServiceName: result.Host, + Host: result.Host, + Uptime: 0, // This would need to be calculated based on your requirements + ResponseTime: result.ResponseTime.Milliseconds(), + LastChecked: time.Now().Format(time.RFC3339), + Port: result.Port, + ServiceType: string(result.Type), + Status: GetStatusString(result.Success), + ErrorMessage: result.Error, + Details: FormatResultDetails(result), + CheckedAt: time.Now().Format(time.RFC3339), + } + + if err := ms.pbClient.SaveMetrics(metrics); err != nil { + // Log error but don't fail the operation + println("Failed to save metrics to PocketBase:", err.Error()) + } + + // Save detailed data based on operation type - only once per check + if serviceID != "" { + switch result.Type { + case types.OperationPing: + ms.SavePingDataToPocketBase(result, serviceID) + case types.OperationHTTP: + ms.SaveUptimeDataToPocketBase(result, serviceID) + case types.OperationDNS: + ms.SaveDNSDataToPocketBase(result, serviceID) + case types.OperationTCP: + ms.SaveTCPDataToPocketBase(result, serviceID) + case types.OperationSSL: + ms.SaveSSLDataToPocketBase(result, serviceID) + } + } +} + +// Primary method for monitoring service usage - this prevents duplicates +func (ms *MetricsSaver) SaveMetricsForService(service pocketbase.Service, result *types.OperationResult) { + // Save general metrics first - reduced logging + metrics := pocketbase.MetricsRecord{ + ServiceName: service.Name, + Host: service.Host, + Uptime: 0, // This would need to be calculated based on your requirements + ResponseTime: result.ResponseTime.Milliseconds(), + LastChecked: time.Now().Format(time.RFC3339), + Port: service.Port, + ServiceType: service.ServiceType, + Status: GetStatusString(result.Success), + ErrorMessage: result.Error, + Details: FormatResultDetails(result), + CheckedAt: time.Now().Format(time.RFC3339), + } + + if err := ms.pbClient.SaveMetrics(metrics); err != nil { + // Silent error - no logging to reduce output + return + } + + // Save detailed data based on service type - only once per service with minimal logging + switch service.ServiceType { + case "ping", "icmp": + ms.SavePingDataToPocketBase(result, service.ID) + case "dns": + ms.SaveDNSDataToPocketBase(result, service.ID) + case "http", "https": + ms.SaveUptimeDataToPocketBase(result, service.ID) + case "tcp": + ms.SaveTCPDataToPocketBase(result, service.ID) + case "ssl": + ms.SaveSSLDataToPocketBase(result, service.ID) + } +} + +// SSL Data saver - remains unchanged (no regional fields) +func (ms *MetricsSaver) SaveSSLDataToPocketBase(result *types.OperationResult, serviceID string) { + // Create SSL data record without regional fields + var details string + + if result.Success { + details = fmt.Sprintf("โœ… SSL Certificate Valid - Expires in %d days", result.SSLDaysLeft) + details += fmt.Sprintf(" | Valid until: %s", result.SSLValidTill.Format("2006-01-02")) + + if result.SSLIssuer != "" { + details += fmt.Sprintf(" | Issuer: %s", result.SSLIssuer) + } + } else { + details = fmt.Sprintf("โŒ SSL Certificate Issue - %s", GetShortErrorMessage(result.Error)) + } + + sslData := pocketbase.SSLDataRecord{ + ServiceID: serviceID, + Timestamp: time.Now(), + ResponseTime: result.ResponseTime.Milliseconds(), + Status: GetStatusString(result.Success), + ValidFrom: result.SSLValidFrom.Format(time.RFC3339), + ValidTill: result.SSLValidTill.Format(time.RFC3339), + DaysLeft: result.SSLDaysLeft, + Issuer: result.SSLIssuer, + Subject: result.SSLSubject, + SerialNumber: result.SSLSerialNumber, + Algorithm: result.SSLAlgorithm, + SANs: result.SSLSANs, + ResolvedIP: result.SSLResolvedIP, + ErrorMessage: result.Error, + Details: details, + } + + if err := ms.pbClient.SaveSSLData(sslData); err != nil { + println("Failed to save SSL data to PocketBase:", err.Error()) + } +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/ping_saver.go b/server/service-operation/shared/savers/ping_saver.go new file mode 100644 index 0000000..f91ddbc --- /dev/null +++ b/server/service-operation/shared/savers/ping_saver.go @@ -0,0 +1,74 @@ + +package savers + +import ( + "fmt" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +func (ms *MetricsSaver) SavePingDataToPocketBase(result *types.OperationResult, serviceID string) { + // Create a short, professional status message + var details string + + if result.Success { + // Success message with packet stats + details = fmt.Sprintf("โœ… Ping OK - %d/%d packets received", + result.PacketsRecv, result.PacketsSent) + + // Add loss percentage if there was any loss + if result.PacketLoss > 0 { + details += fmt.Sprintf(" (%.1f%% loss)", result.PacketLoss) + } + + // Add response time info + details += fmt.Sprintf(" | Avg: %.2fms", + float64(result.AvgRTT.Nanoseconds())/1000000) + + // Add min/max if different from average (significant variance) + if result.MinRTT != result.MaxRTT { + details += fmt.Sprintf(", Min: %.2fms, Max: %.2fms", + float64(result.MinRTT.Nanoseconds())/1000000, + float64(result.MaxRTT.Nanoseconds())/1000000) + } + } else { + // Error message + if result.PacketLoss >= 100 { + details = fmt.Sprintf("โŒ Ping Failed - 100%% packet loss (%s)", + GetShortErrorMessage(result.Error)) + } else { + details = fmt.Sprintf("โš ๏ธ Ping Partial - %.1f%% packet loss (%s)", + result.PacketLoss, GetShortErrorMessage(result.Error)) + } + } + + pingData := pocketbase.PingDataRecord{ + ServiceID: serviceID, + Timestamp: time.Now(), + ResponseTime: result.ResponseTime.Milliseconds(), + Status: GetStatusString(result.Success), + PacketsSent: fmt.Sprintf("%d", result.PacketsSent), + PacketsRecv: fmt.Sprintf("%d", result.PacketsRecv), + PacketLoss: fmt.Sprintf("%.1f%%", result.PacketLoss), + MinRTT: fmt.Sprintf("%.2fms", float64(result.MinRTT.Nanoseconds())/1000000), + MaxRTT: fmt.Sprintf("%.2fms", float64(result.MaxRTT.Nanoseconds())/1000000), + AvgRTT: fmt.Sprintf("%.2fms", float64(result.AvgRTT.Nanoseconds())/1000000), + RTTs: "", // Not currently tracked + Latency: fmt.Sprintf("%.2fms", float64(result.AvgRTT.Nanoseconds())/1000000), + ErrorMessage: result.Error, + Details: details, // Short, clean message + RegionName: ms.regionName, // Add regional fields + AgentID: ms.agentID, + } + + if err := ms.pbClient.SavePingData(pingData); err != nil { + println("Failed to save ping data to PocketBase:", err.Error()) + } +} + +// Method for monitoring service usage +func (ms *MetricsSaver) SavePingDataForService(service pocketbase.Service, result *types.OperationResult) { + ms.SavePingDataToPocketBase(result, service.ID) +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/tcp_saver.go b/server/service-operation/shared/savers/tcp_saver.go new file mode 100644 index 0000000..28d8f71 --- /dev/null +++ b/server/service-operation/shared/savers/tcp_saver.go @@ -0,0 +1,66 @@ + +package savers + +import ( + "fmt" + "strconv" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +func (ms *MetricsSaver) SaveTCPDataToPocketBase(result *types.OperationResult, serviceID string) { + // Create a short, professional status message + var details string + + if result.Success && result.TCPConnected { + // Success message with connection info + details = fmt.Sprintf("โœ… TCP Connection OK - Port %d accessible", result.Port) + + // Add response time + details += fmt.Sprintf(" | Connection time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + } else { + // Error message with port info + details = fmt.Sprintf("โŒ TCP Connection Failed - Port %d unreachable", result.Port) + + if result.Error != "" { + details += fmt.Sprintf(" (%s)", GetShortErrorMessage(result.Error)) + } + + // Add response time if available + if result.ResponseTime > 0 { + details += fmt.Sprintf(" | Timeout: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + } + } + + connectionStatus := "disconnected" + if result.TCPConnected { + connectionStatus = "connected" + } + + tcpData := pocketbase.TCPDataRecord{ + ServiceID: serviceID, + Timestamp: time.Now(), + ResponseTime: result.ResponseTime.Milliseconds(), + Status: GetStatusString(result.Success), + Connection: connectionStatus, + Latency: fmt.Sprintf("%.2fms", float64(result.ResponseTime.Nanoseconds())/1000000), + Port: strconv.Itoa(result.Port), + ErrorMessage: result.Error, + Details: details, + RegionName: ms.regionName, // Add regional fields + AgentID: ms.agentID, + } + + if err := ms.pbClient.SaveTCPData(tcpData); err != nil { + fmt.Printf("Failed to save TCP data to PocketBase: %v\n", err) + } +} + +// Method for monitoring service usage +func (ms *MetricsSaver) SaveTCPDataForService(service pocketbase.Service, result *types.OperationResult) { + ms.SaveTCPDataToPocketBase(result, service.ID) +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/uptime_saver.go b/server/service-operation/shared/savers/uptime_saver.go new file mode 100644 index 0000000..fe89bc1 --- /dev/null +++ b/server/service-operation/shared/savers/uptime_saver.go @@ -0,0 +1,74 @@ + +package savers + +import ( + "fmt" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +func (ms *MetricsSaver) SaveUptimeDataToPocketBase(result *types.OperationResult, serviceID string) { + // Create a short, professional status message + var details string + + if result.Success { + // Success message with basic info + details = fmt.Sprintf("โœ… HTTP %d OK - Response time: %.2fms", + result.HTTPStatusCode, + float64(result.ResponseTime.Nanoseconds())/1000000) + + // Add content info if available + if result.ContentLength > 0 { + details += fmt.Sprintf(" | Content: %s", FormatBytes(result.ContentLength)) + } + + // Add server info if available + if server, exists := result.HTTPHeaders["Server"]; exists { + details += fmt.Sprintf(" | Server: %s", server) + } + } else { + // Error message with status code if available + if result.HTTPStatusCode > 0 { + details = fmt.Sprintf("โŒ HTTP %d Error - %s", + result.HTTPStatusCode, + GetShortErrorMessage(result.Error)) + } else { + details = fmt.Sprintf("๐Ÿ”Œ Connection Error - %s", + GetShortErrorMessage(result.Error)) + } + + // Add response time if available + if result.ResponseTime > 0 { + details += fmt.Sprintf(" | Response time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + } + } + + uptimeData := pocketbase.UptimeDataRecord{ + ServiceID: serviceID, + Timestamp: time.Now(), + ResponseTime: result.ResponseTime.Milliseconds(), + Status: GetStatusString(result.Success), + Packets: "N/A", // Not applicable for HTTP + Latency: fmt.Sprintf("%.2fms", float64(result.ResponseTime.Nanoseconds())/1000000), + StatusCodes: fmt.Sprintf("%d", result.HTTPStatusCode), + Keyword: "", // Can be populated later if needed + ErrorMessage: result.Error, + Details: details, // Short, clean message + Region: ms.regionName, // Legacy field + RegionID: ms.agentID, // Legacy field + RegionName: ms.regionName, // Add regional fields + AgentID: ms.agentID, + } + + if err := ms.pbClient.SaveUptimeData(uptimeData); err != nil { + println("Failed to save uptime data to PocketBase:", err.Error()) + } +} + +// Method for monitoring service usage +func (ms *MetricsSaver) SaveUptimeDataForService(service pocketbase.Service, result *types.OperationResult) { + ms.SaveUptimeDataToPocketBase(result, service.ID) +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/utils.go b/server/service-operation/shared/savers/utils.go new file mode 100644 index 0000000..f540b6f --- /dev/null +++ b/server/service-operation/shared/savers/utils.go @@ -0,0 +1,89 @@ + +package savers + +import ( + "fmt" + "strings" + + "service-operation/types" +) + +// Helper function to format bytes in a readable way +func FormatBytes(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } else if bytes < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(bytes)/1024) + } else { + return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) + } +} + +// Helper function to create short error messages +func GetShortErrorMessage(errorMessage string) string { + if errorMessage == "" { + return "Unknown error" + } + + errorLower := strings.ToLower(errorMessage) + + if strings.Contains(errorLower, "timeout") { + return "Request timeout" + } else if strings.Contains(errorLower, "connection refused") { + return "Connection refused" + } else if strings.Contains(errorLower, "dns") || strings.Contains(errorLower, "no such host") { + return "DNS resolution failed" + } else if strings.Contains(errorLower, "certificate") || strings.Contains(errorLower, "ssl") || strings.Contains(errorLower, "tls") { + return "SSL certificate error" + } else if strings.Contains(errorLower, "server error") || strings.Contains(errorLower, "internal server error") { + return "Internal server error" + } else if strings.Contains(errorLower, "not found") { + return "Page not found" + } else if strings.Contains(errorLower, "unauthorized") { + return "Unauthorized access" + } else if strings.Contains(errorLower, "forbidden") { + return "Access forbidden" + } + + // For other errors, take first 50 characters and clean it up + shortMsg := errorMessage + if len(shortMsg) > 50 { + shortMsg = shortMsg[:50] + "..." + } + + return shortMsg +} + +func GetStatusString(success bool) string { + if success { + return "up" + } + return "down" +} + +func FormatResultDetails(result *types.OperationResult) string { + // This can be expanded based on operation type + if result.Details != "" { + return result.Details + } + + switch result.Type { + case types.OperationPing: + if result.Success { + return fmt.Sprintf("Ping successful - %d packets sent, %d received", result.PacketsSent, result.PacketsRecv) + } + return fmt.Sprintf("Ping failed - %s", result.Error) + case types.OperationHTTP: + if result.Success { + return fmt.Sprintf("HTTP %d - Response time: %.2fms", result.HTTPStatusCode, float64(result.ResponseTime.Nanoseconds())/1000000) + } + return fmt.Sprintf("HTTP failed - %s", result.Error) + case types.OperationDNS: + if result.Success { + return fmt.Sprintf("DNS %s query successful - %d records found", result.DNSType, len(result.DNSRecords)) + } + return fmt.Sprintf("DNS query failed - %s", result.Error) + default: + return "Operation completed" + } +} \ No newline at end of file diff --git a/server/service-operation/ssl-monitoring/monitor.go b/server/service-operation/ssl-monitoring/monitor.go new file mode 100644 index 0000000..c368c62 --- /dev/null +++ b/server/service-operation/ssl-monitoring/monitor.go @@ -0,0 +1,98 @@ + +package sslmonitoring + +import ( + "time" + + "service-operation/pocketbase" +) + +// SSLMonitor monitors SSL certificates and sends notifications +type SSLMonitor struct { + client *SSLClient + notificationService *SSLNotificationService + checkInterval time.Duration + stopChan chan bool +} + +// NewSSLMonitor creates a new SSL monitor +func NewSSLMonitor(pbClient *pocketbase.PocketBaseClient) *SSLMonitor { + // Initialize SSL client + sslClient := NewSSLClient(pbClient) + + // Initialize notification service + notificationService := NewSSLNotificationService(sslClient, pbClient) + + // Set check interval to 30 seconds to match other services + checkInterval := 30 * time.Second + + return &SSLMonitor{ + client: sslClient, + notificationService: notificationService, + checkInterval: checkInterval, + stopChan: make(chan bool, 1), + } +} + +// Start begins monitoring SSL certificates +func (sm *SSLMonitor) Start() { + // log.Printf("๐Ÿš€ [SSL-MONITOR] Starting with check interval: %v", sm.checkInterval) + // log.Printf("โฐ [SSL-MONITOR] Startup grace period: 2 minutes (no notifications for valid certificates)") + + // Run initial check + sm.checkCertificates() + + // Set up periodic checking every 30 seconds + ticker := time.NewTicker(sm.checkInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + sm.checkCertificates() + case <-sm.stopChan: + // log.Println("๐Ÿ›‘ [SSL-MONITOR] Monitoring stopped") + return + } + } +} + +// Stop gracefully stops the SSL monitoring +func (sm *SSLMonitor) Stop() { + // log.Printf("๐Ÿ›‘ [SSL-MONITOR] Stopping SSL monitoring...") + select { + case sm.stopChan <- true: + default: + } +} + +// checkCertificates fetches all SSL certificates and checks their notification requirements +func (sm *SSLMonitor) checkCertificates() { + // log.Printf("๐Ÿ” [SSL-CHECK] Checking SSL certificates for expiration alerts...") + + certificates, err := sm.client.GetSSLCertificates() + if err != nil { + // log.Printf("โŒ [SSL-ERROR] Failed to fetch SSL certificates: %v", err) + return + } + + // log.Printf("๐Ÿ“Š [SSL-CERTIFICATES] Found %d certificates to check", len(certificates)) + + successCount := 0 + errorCount := 0 + + for _, cert := range certificates { + // Check each certificate for notification requirements + if err := sm.notificationService.CheckAndNotifySSLCertificate(cert); err != nil { + // log.Printf("โŒ [SSL-FAILED] Failed to process certificate %s: %v", cert.Domain, err) + errorCount++ + } else { + successCount++ + } + } + + // log.Printf("โœ… [SSL-SUMMARY] Processed %d certificates (success: %d, errors: %d)", + // len(certificates), successCount, errorCount) + _ = successCount // Prevent unused variable warning + _ = errorCount // Prevent unused variable warning +} \ No newline at end of file diff --git a/server/service-operation/ssl-monitoring/notification_service.go b/server/service-operation/ssl-monitoring/notification_service.go new file mode 100644 index 0000000..c5ffdd1 --- /dev/null +++ b/server/service-operation/ssl-monitoring/notification_service.go @@ -0,0 +1,192 @@ +package sslmonitoring + +import ( + "fmt" + "strconv" + "time" + + "service-operation/notification" + "service-operation/pocketbase" +) + +// SSLNotificationService handles SSL certificate notifications +type SSLNotificationService struct { + client *SSLClient + pbClient *pocketbase.PocketBaseClient + notificationManager *notification.NotificationManager + statusTracker *SSLStatusTracker +} + +// NewSSLNotificationService creates a new SSL notification service +func NewSSLNotificationService(client *SSLClient, pbClient *pocketbase.PocketBaseClient) *SSLNotificationService { + return &SSLNotificationService{ + client: client, + pbClient: pbClient, + notificationManager: notification.NewNotificationManager(pbClient), + statusTracker: NewSSLStatusTracker(pbClient), // Pass pbClient to status tracker + } +} + +// CheckAndNotifySSLCertificate checks SSL certificate and sends notification if needed +func (sns *SSLNotificationService) CheckAndNotifySSLCertificate(cert SSLCertificate) error { + // Always recalculate days left from the actual expiry date to ensure accuracy + actualDaysLeft := sns.calculateDaysLeft(cert.ValidTill) + + // Use the calculated value as it's more accurate + cert.DaysLeft = actualDaysLeft + + // Determine current status based on thresholds with calculated days left + currentStatus := sns.determineSSLStatus(cert.DaysLeft, cert.WarningThreshold, cert.ExpiryThreshold) + + // Check if notification should be sent + if !sns.shouldSendNotification(cert.ID, currentStatus) { + return nil + } + + // Skip if no notification configuration + if cert.NotificationID == "" { + return nil + } + + // Send notification + err := sns.sendSSLNotification(cert, currentStatus) + if err != nil { + return err + } + + // Update tracking in database + sns.statusTracker.UpdateStatus(cert.ID, currentStatus) + sns.statusTracker.SetLastNotificationTime(cert.ID, time.Now()) + + return nil +} + +// calculateDaysLeft calculates days remaining until expiration with better error handling +func (sns *SSLNotificationService) calculateDaysLeft(validTill string) int { + // Try multiple date formats to parse the expiry date + var expiryTime time.Time + var err error + + // Common date formats - updated to handle PocketBase format correctly + formats := []string{ + "2006-01-02 15:04:05.000Z", // PocketBase format with space (most common) + "2006-01-02T15:04:05.000Z", // ISO 8601 with milliseconds + time.RFC3339, // Standard RFC3339 + time.RFC3339Nano, // RFC3339 with nanoseconds + "2006-01-02 15:04:05Z", // Without milliseconds + "2006-01-02T15:04:05Z", // ISO without milliseconds + "2006-01-02 15:04:05.999Z", // Alternative milliseconds format + "2006-01-02T15:04:05.999Z", // Alternative ISO milliseconds + "2006-01-02 15:04:05.000000Z", // Microseconds format + "2006-01-02T15:04:05.000000Z", // ISO microseconds format + "2006-01-02 15:04:05", // Simple format without timezone + "2006-01-02", // Date only + } + + for _, format := range formats { + expiryTime, err = time.Parse(format, validTill) + if err == nil { + break + } + } + + if err != nil { + return 0 + } + + now := time.Now() + duration := expiryTime.Sub(now) + daysLeft := int(duration.Hours() / 24) + + // If the result is negative, the certificate is expired + if daysLeft < 0 { + daysLeft = 0 + } + + return daysLeft +} + +// sendSSLNotification sends the actual notification +func (sns *SSLNotificationService) sendSSLNotification(cert SSLCertificate, status string) error { + // Determine the correct issuer value - prioritize IssuerCN over IssuerO + issuerValue := "" + if cert.IssuerCN != "" { + issuerValue = cert.IssuerCN + } else if cert.IssuerO != "" { + issuerValue = cert.IssuerO + } else { + issuerValue = "Unknown" + } + + // Create notification payload with SSL-specific data using calculated values + payload := ¬ification.NotificationPayload{ + ServiceName: fmt.Sprintf("SSL Certificate - %s", cert.Domain), + Status: status, + Host: cert.Domain, + Domain: cert.Domain, + ServiceType: "ssl", + Timestamp: time.Now(), + Message: sns.generateStatusMessage(cert, status), + + // SSL-specific fields with calculated/corrected values + CertificateName: cert.Domain, + ExpiryDate: cert.ValidTill, + DaysLeft: strconv.Itoa(cert.DaysLeft), // Use the calculated value + IssuerCN: issuerValue, + SerialNumber: cert.SerialNumber, + } + + // Send SSL notification using the manager + return sns.notificationManager.SendSSLNotification(payload, cert.NotificationID, cert.TemplateID) +} + +// generateStatusMessage creates appropriate message based on certificate status using calculated days +func (sns *SSLNotificationService) generateStatusMessage(cert SSLCertificate, status string) string { + switch status { + case "expired": + return fmt.Sprintf("SSL certificate for %s expired on %s", cert.Domain, cert.ValidTill) + case "expiring_soon": + return fmt.Sprintf("SSL certificate for %s expires in %d days on %s", cert.Domain, cert.DaysLeft, cert.ValidTill) + case "warning": + return fmt.Sprintf("SSL certificate for %s expires in %d days", cert.Domain, cert.DaysLeft) + default: + return fmt.Sprintf("SSL certificate for %s is valid (%d days remaining)", cert.Domain, cert.DaysLeft) + } +} + +// shouldSendNotification determines if notification should be sent with enhanced logic +func (sns *SSLNotificationService) shouldSendNotification(certID, currentStatus string) bool { + lastStatus := sns.statusTracker.GetLastStatus(certID) + lastNotified := sns.statusTracker.GetLastNotificationTime(certID) + + // Send if status changed or first check + if lastStatus == "" || lastStatus != currentStatus { + return true + } + + // For critical statuses (expired/expiring_soon), resend after 24 hours + if (currentStatus == "expired" || currentStatus == "expiring_soon") && + !lastNotified.IsZero() && time.Since(lastNotified) > 24*time.Hour { + return true + } + + // For warning status, resend after 7 days to avoid spam + if currentStatus == "warning" && + !lastNotified.IsZero() && time.Since(lastNotified) > 7*24*time.Hour { + return true + } + + return false +} + +// determineSSLStatus determines SSL certificate status based on days left and thresholds +func (sns *SSLNotificationService) determineSSLStatus(daysLeft, warningThreshold, expiryThreshold int) string { + if daysLeft <= 0 { + return "expired" + } else if daysLeft <= expiryThreshold { + return "expiring_soon" + } else if daysLeft <= warningThreshold { + return "warning" + } + return "valid" +} \ No newline at end of file diff --git a/server/service-operation/ssl-monitoring/ssl_client.go b/server/service-operation/ssl-monitoring/ssl_client.go new file mode 100644 index 0000000..29d204a --- /dev/null +++ b/server/service-operation/ssl-monitoring/ssl_client.go @@ -0,0 +1,78 @@ + +package sslmonitoring + +import ( + "encoding/json" + "fmt" + "net/http" + + "service-operation/pocketbase" +) + +// SSLClient handles SSL certificate data operations +type SSLClient struct { + pbClient *pocketbase.PocketBaseClient +} + +// NewSSLClient creates a new SSL client +func NewSSLClient(pbClient *pocketbase.PocketBaseClient) *SSLClient { + return &SSLClient{ + pbClient: pbClient, + } +} + +// GetSSLCertificates fetches all SSL certificates from PocketBase +func (sc *SSLClient) GetSSLCertificates() ([]SSLCertificate, error) { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records", sc.pbClient.GetBaseURL()) + // log.Printf("๐Ÿ” [SSL-CLIENT] Fetching SSL certificates from: %s", url) + + resp, err := http.Get(url) + if err != nil { + // log.Printf("โŒ [SSL-CLIENT] HTTP error: %v", err) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // log.Printf("โŒ [SSL-CLIENT] Failed to fetch SSL certificates, status: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch SSL certificates, status: %d", resp.StatusCode) + } + + var response struct { + Items []SSLCertificate `json:"items"` + } + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + // log.Printf("โŒ [SSL-CLIENT] Error decoding response: %v", err) + return nil, err + } + + // log.Printf("โœ… [SSL-CLIENT] Successfully fetched %d SSL certificates", len(response.Items)) + return response.Items, nil +} + +// UpdateSSLCertificate updates SSL certificate status and notification time +func (sc *SSLClient) UpdateSSLCertificate(certID string, data map[string]interface{}) error { + url := fmt.Sprintf("%s/api/collections/ssl_certificates/records/%s", sc.pbClient.GetBaseURL(), certID) + + req, err := http.NewRequest(http.MethodPatch, url, nil) + if err != nil { + return fmt.Errorf("failed to create SSL certificate update request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to update SSL certificate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to update SSL certificate, status: %d", resp.StatusCode) + } + + // log.Printf("โœ… [SSL-CLIENT] Successfully updated SSL certificate: %s", certID) + return nil +} \ No newline at end of file diff --git a/server/service-operation/ssl-monitoring/status_tracker.go b/server/service-operation/ssl-monitoring/status_tracker.go new file mode 100644 index 0000000..ab7d089 --- /dev/null +++ b/server/service-operation/ssl-monitoring/status_tracker.go @@ -0,0 +1,111 @@ + +package sslmonitoring + +import ( + "sync" + "time" + + "service-operation/pocketbase" +) + +// SSLStatusTracker tracks SSL certificate status changes and notification timing using database persistence +type SSLStatusTracker struct { + pbClient *pocketbase.PocketBaseClient + mu sync.RWMutex +} + +// NewSSLStatusTracker creates a new SSL status tracker with database persistence +func NewSSLStatusTracker(pbClient *pocketbase.PocketBaseClient) *SSLStatusTracker { + return &SSLStatusTracker{ + pbClient: pbClient, + } +} + +// GetLastStatus returns the last known status for an SSL certificate from database +func (sst *SSLStatusTracker) GetLastStatus(certID string) string { + sst.mu.RLock() + defer sst.mu.RUnlock() + + cert, err := sst.pbClient.GetSSLCertificateByID(certID) + if err != nil { + // log.Printf("๐Ÿ” [SSL-TRACKER] Error getting last status for %s: %v", certID, err) + return "" + } + + status := cert.Status + // log.Printf("๐Ÿ” [SSL-TRACKER] GetLastStatus for %s: %s", certID, status) + return status +} + +// UpdateStatus updates the last known status for an SSL certificate in database +func (sst *SSLStatusTracker) UpdateStatus(certID, status string) { + sst.mu.Lock() + defer sst.mu.Unlock() + + // Get current certificate to preserve other fields + cert, err := sst.pbClient.GetSSLCertificateByID(certID) + if err != nil { + // log.Printf("๐Ÿ“ [SSL-TRACKER] Error getting certificate for status update %s: %v", certID, err) + return + } + + oldStatus := cert.Status + + // Update only the status field + updateData := map[string]interface{}{ + "status": status, + } + + err = sst.pbClient.UpdateSSLCertificate(certID, updateData) + if err != nil { + // log.Printf("๐Ÿ“ [SSL-TRACKER] Error updating status for %s: %v", certID, err) + return + } + + // log.Printf("๐Ÿ“ [SSL-TRACKER] UpdateStatus for %s: %s -> %s", certID, oldStatus, status) + _ = oldStatus // Prevent unused variable warning +} + +// GetLastNotificationTime returns the last time a notification was sent for an SSL certificate from database +func (sst *SSLStatusTracker) GetLastNotificationTime(certID string) time.Time { + sst.mu.RLock() + defer sst.mu.RUnlock() + + cert, err := sst.pbClient.GetSSLCertificateByID(certID) + if err != nil { + // log.Printf("โฐ [SSL-TRACKER] Error getting last notification time for %s: %v", certID, err) + return time.Time{} + } + + if cert.LastNotified == "" { + // log.Printf("โฐ [SSL-TRACKER] GetLastNotificationTime for %s: never notified", certID) + return time.Time{} + } + + lastTime, err := time.Parse(time.RFC3339, cert.LastNotified) + if err != nil { + // log.Printf("โฐ [SSL-TRACKER] Error parsing last notification time for %s: %v", certID, err) + return time.Time{} + } + + // log.Printf("โฐ [SSL-TRACKER] GetLastNotificationTime for %s: %v", certID, lastTime) + return lastTime +} + +// SetLastNotificationTime sets the last notification time for an SSL certificate in database +func (sst *SSLStatusTracker) SetLastNotificationTime(certID string, t time.Time) { + sst.mu.Lock() + defer sst.mu.Unlock() + + updateData := map[string]interface{}{ + "last_notified": t.Format(time.RFC3339), + } + + err := sst.pbClient.UpdateSSLCertificate(certID, updateData) + if err != nil { + // log.Printf("โฐ [SSL-TRACKER] Error setting last notification time for %s: %v", certID, err) + return + } + + // log.Printf("โฐ [SSL-TRACKER] SetLastNotificationTime for %s: %v", certID, t) +} \ No newline at end of file diff --git a/server/service-operation/ssl-monitoring/types.go b/server/service-operation/ssl-monitoring/types.go new file mode 100644 index 0000000..6520087 --- /dev/null +++ b/server/service-operation/ssl-monitoring/types.go @@ -0,0 +1,110 @@ + +package sslmonitoring + +import ( + "encoding/json" + "strconv" + "time" +) + +type SSLCertificate struct { + ID string `json:"id"` + CollectionID string `json:"collectionId"` + CollectionName string `json:"collectionName"` + Domain string `json:"domain"` + IssuerO string `json:"issuer_o"` + Status string `json:"status"` + LastNotified string `json:"last_notified"` + WarningThreshold int `json:"warning_threshold"` + ExpiryThreshold int `json:"expiry_threshold"` + NotificationChannel string `json:"notification_channel"` + NotificationID string `json:"notification_id"` + TemplateID string `json:"template_id"` + ValidFrom string `json:"valid_from"` + SerialNumber string `json:"serial_number"` + IssuedTo string `json:"issued_to"` + ValidTill string `json:"valid_till"` + ValidityDays int `json:"validity_days"` + DaysLeft int `json:"days_left"` + ValidDaysToExpire int `json:"valid_days_to_expire"` + ResolvedIP string `json:"resolved_ip"` + IssuerCN string `json:"issuer_cn"` + CertAlg string `json:"cert_alg"` + CertSans string `json:"cert_sans"` + CheckInterval int `json:"check_interval"` + CheckAt string `json:"check_at"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +// Custom unmarshaler to handle check_interval as both string and int, and serial_number as string +func (s *SSLCertificate) UnmarshalJSON(data []byte) error { + // Create a temporary struct with flexible types + type Alias SSLCertificate + aux := &struct { + CheckInterval interface{} `json:"check_interval"` + SerialNumber interface{} `json:"serial_number"` + *Alias + }{ + Alias: (*Alias)(s), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Handle check_interval conversion + switch v := aux.CheckInterval.(type) { + case string: + if v == "" { + s.CheckInterval = 1440 // Default 24 hours in minutes + } else { + interval, err := strconv.Atoi(v) + if err != nil { + s.CheckInterval = 1440 // Default on error + } else { + s.CheckInterval = interval + } + } + case float64: + s.CheckInterval = int(v) + case int: + s.CheckInterval = v + default: + s.CheckInterval = 1440 // Default 24 hours in minutes + } + + // Handle serial_number conversion to string + switch v := aux.SerialNumber.(type) { + case string: + s.SerialNumber = v + case float64: + // Handle scientific notation by converting to string + s.SerialNumber = strconv.FormatFloat(v, 'f', 0, 64) + case int64: + s.SerialNumber = strconv.FormatInt(v, 10) + case int: + s.SerialNumber = strconv.Itoa(v) + default: + s.SerialNumber = "" + } + + return nil +} + +type SSLCheckResult struct { + Domain string `json:"domain"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + ValidFrom time.Time `json:"valid_from"` + ValidTill time.Time `json:"valid_till"` + DaysLeft int `json:"days_left"` + Issuer string `json:"issuer"` + Subject string `json:"subject"` + SerialNumber string `json:"serial_number"` + Algorithm string `json:"algorithm"` + SANs []string `json:"sans"` + ResponseTime time.Duration `json:"response_time"` + ResolvedIP string `json:"resolved_ip"` + CheckedAt time.Time `json:"checked_at"` +} \ No newline at end of file diff --git a/server/service-operation/types/operation.go b/server/service-operation/types/operation.go new file mode 100644 index 0000000..f36cb0a --- /dev/null +++ b/server/service-operation/types/operation.go @@ -0,0 +1,72 @@ +package types + +import "time" + +type OperationType string + +const ( + OperationPing OperationType = "ping" + OperationDNS OperationType = "dns" + OperationTCP OperationType = "tcp" + OperationHTTP OperationType = "http" + OperationSSL OperationType = "ssl" +) + +type OperationRequest struct { + Type OperationType `json:"type"` + Host string `json:"host"` + Port int `json:"port,omitempty"` // For TCP + Count int `json:"count,omitempty"` // For ping + Timeout int `json:"timeout,omitempty"` // In seconds + Query string `json:"query,omitempty"` // For DNS + URL string `json:"url,omitempty"` // For HTTP + Method string `json:"method,omitempty"` // For HTTP (GET, POST, etc.) + ServiceID string `json:"service_id,omitempty"` // For linking to specific service +} + +type OperationResult struct { + Type OperationType `json:"type"` + Host string `json:"host"` + Port int `json:"port,omitempty"` + Success bool `json:"success"` + ResponseTime time.Duration `json:"response_time"` + Error string `json:"error,omitempty"` + Details string `json:"details,omitempty"` + + // Ping specific fields + PacketsSent int `json:"packets_sent,omitempty"` + PacketsRecv int `json:"packets_recv,omitempty"` + PacketLoss float64 `json:"packet_loss,omitempty"` + MinRTT time.Duration `json:"min_rtt,omitempty"` + MaxRTT time.Duration `json:"max_rtt,omitempty"` + AvgRTT time.Duration `json:"avg_rtt,omitempty"` + RTTs []time.Duration `json:"rtts,omitempty"` + + // DNS specific fields + DNSRecords []string `json:"dns_records,omitempty"` + DNSType string `json:"dns_type,omitempty"` + + // TCP specific fields + TCPConnected bool `json:"tcp_connected,omitempty"` + + // HTTP specific fields + HTTPStatusCode int `json:"http_status_code,omitempty"` + HTTPMethod string `json:"http_method,omitempty"` + HTTPHeaders map[string]string `json:"http_headers,omitempty"` + ContentLength int64 `json:"content_length,omitempty"` + ResponseBody string `json:"response_body,omitempty"` + + // SSL specific fields + SSLValidFrom time.Time `json:"ssl_valid_from,omitempty"` + SSLValidTill time.Time `json:"ssl_valid_till,omitempty"` + SSLDaysLeft int `json:"ssl_days_left,omitempty"` + SSLIssuer string `json:"ssl_issuer,omitempty"` + SSLSubject string `json:"ssl_subject,omitempty"` + SSLSerialNumber string `json:"ssl_serial_number,omitempty"` + SSLAlgorithm string `json:"ssl_algorithm,omitempty"` + SSLSANs string `json:"ssl_sans,omitempty"` + SSLResolvedIP string `json:"ssl_resolved_ip,omitempty"` + + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} \ No newline at end of file diff --git a/server/service-operation/types/ssl.go b/server/service-operation/types/ssl.go new file mode 100644 index 0000000..02e53dd --- /dev/null +++ b/server/service-operation/types/ssl.go @@ -0,0 +1,108 @@ + +package types + +import ( + "encoding/json" + "strconv" + "time" +) + +type SSLCertificate struct { + ID string `json:"id"` + CollectionID string `json:"collectionId"` + CollectionName string `json:"collectionName"` + Domain string `json:"domain"` + IssuerO string `json:"issuer_o"` + Status string `json:"status"` + LastNotified string `json:"last_notified"` + WarningThreshold int `json:"warning_threshold"` + ExpiryThreshold int `json:"expiry_threshold"` + NotificationChannel string `json:"notification_channel"` + ValidFrom string `json:"valid_from"` + SerialNumber string `json:"serial_number"` // Changed to string to handle large numbers + IssuedTo string `json:"issued_to"` + ValidTill string `json:"valid_till"` + ValidityDays int `json:"validity_days"` + DaysLeft int `json:"days_left"` + ValidDaysToExpire int `json:"valid_days_to_expire"` + ResolvedIP string `json:"resolved_ip"` + IssuerCN string `json:"issuer_cn"` + CertAlg string `json:"cert_alg"` + CertSans string `json:"cert_sans"` + CheckInterval int `json:"check_interval"` + CheckAt string `json:"check_at"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +// Custom unmarshaler to handle check_interval as both string and int, and serial_number as string +func (s *SSLCertificate) UnmarshalJSON(data []byte) error { + // Create a temporary struct with flexible types + type Alias SSLCertificate + aux := &struct { + CheckInterval interface{} `json:"check_interval"` + SerialNumber interface{} `json:"serial_number"` + *Alias + }{ + Alias: (*Alias)(s), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Handle check_interval conversion + switch v := aux.CheckInterval.(type) { + case string: + if v == "" { + s.CheckInterval = 1440 // Default 24 hours in minutes + } else { + interval, err := strconv.Atoi(v) + if err != nil { + s.CheckInterval = 1440 // Default on error + } else { + s.CheckInterval = interval + } + } + case float64: + s.CheckInterval = int(v) + case int: + s.CheckInterval = v + default: + s.CheckInterval = 1440 // Default 24 hours in minutes + } + + // Handle serial_number conversion to string + switch v := aux.SerialNumber.(type) { + case string: + s.SerialNumber = v + case float64: + // Handle scientific notation by converting to string + s.SerialNumber = strconv.FormatFloat(v, 'f', 0, 64) + case int64: + s.SerialNumber = strconv.FormatInt(v, 10) + case int: + s.SerialNumber = strconv.Itoa(v) + default: + s.SerialNumber = "" + } + + return nil +} + +type SSLCheckResult struct { + Domain string `json:"domain"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + ValidFrom time.Time `json:"valid_from"` + ValidTill time.Time `json:"valid_till"` + DaysLeft int `json:"days_left"` + Issuer string `json:"issuer"` + Subject string `json:"subject"` + SerialNumber string `json:"serial_number"` + Algorithm string `json:"algorithm"` + SANs []string `json:"sans"` + ResponseTime time.Duration `json:"response_time"` + ResolvedIP string `json:"resolved_ip"` + CheckedAt time.Time `json:"checked_at"` +} \ No newline at end of file diff --git a/server/service-operation/uptime-monitoring/client.go b/server/service-operation/uptime-monitoring/client.go new file mode 100644 index 0000000..ff166fe --- /dev/null +++ b/server/service-operation/uptime-monitoring/client.go @@ -0,0 +1,113 @@ + +package uptimemonitoring + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "service-operation/pocketbase" +) + +// UptimeClient handles uptime service operations +type UptimeClient struct { + pbClient *pocketbase.PocketBaseClient +} + +// MetricRecord represents a metric record from any collection +type MetricRecord struct { + ID string `json:"id"` + ServiceID string `json:"service_id"` + Status string `json:"status"` + ResponseTime int `json:"response_time"` + ErrorMessage string `json:"error_message"` // Added missing ErrorMessage field + Timestamp time.Time `json:"timestamp"` + Created time.Time `json:"created"` +} + +// NewUptimeClient creates a new uptime client +func NewUptimeClient(pbClient *pocketbase.PocketBaseClient) *UptimeClient { + return &UptimeClient{ + pbClient: pbClient, + } +} + +// GetServices retrieves all services for monitoring +func (uc *UptimeClient) GetServices() ([]UptimeService, error) { + url := fmt.Sprintf("%s/api/collections/services/records", uc.pbClient.GetBaseURL()) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch services: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch services, status: %d", resp.StatusCode) + } + + var response struct { + Items []UptimeService `json:"items"` + } + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode services: %v", err) + } + + // log.Printf("โœ… Fetched %d services for monitoring", len(response.Items)) + return response.Items, nil +} + +// GetLatestMetricRecord gets the latest metric record for a service from specified collection +func (uc *UptimeClient) GetLatestMetricRecord(serviceID, collection string) ([]MetricRecord, error) { + url := fmt.Sprintf("%s/api/collections/%s/records?filter=service_id='%s'&sort=-timestamp&perPage=1", + uc.pbClient.GetBaseURL(), collection, serviceID) + + // log.Printf("๐Ÿ” [METRICS-QUERY] Getting latest record for service %s from %s", serviceID, collection) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch metric record: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch metric record, status: %d", resp.StatusCode) + } + + var response struct { + Items []struct { + ID string `json:"id"` + ServiceID string `json:"service_id"` + Status string `json:"status"` + ResponseTime int `json:"response_time"` + ErrorMessage string `json:"error_message"` // Added missing ErrorMessage field + Timestamp string `json:"timestamp"` + Created string `json:"created"` + } `json:"items"` + } + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode metric record: %v", err) + } + + var records []MetricRecord + for _, item := range response.Items { + timestamp, _ := time.Parse(time.RFC3339, item.Timestamp) + created, _ := time.Parse(time.RFC3339, item.Created) + + records = append(records, MetricRecord{ + ID: item.ID, + ServiceID: item.ServiceID, + Status: item.Status, + ResponseTime: item.ResponseTime, + ErrorMessage: item.ErrorMessage, // Include ErrorMessage in the record + Timestamp: timestamp, + Created: created, + }) + } + + // log.Printf("๐Ÿ“Š [METRICS-RESULT] Found %d records for service %s in %s", len(records), serviceID, collection) + return records, nil +} \ No newline at end of file diff --git a/server/service-operation/uptime-monitoring/monitor.go b/server/service-operation/uptime-monitoring/monitor.go new file mode 100644 index 0000000..0c89b8d --- /dev/null +++ b/server/service-operation/uptime-monitoring/monitor.go @@ -0,0 +1,106 @@ + +package uptimemonitoring + +import ( + "time" + + "service-operation/pocketbase" +) + +// UptimeMonitor monitors uptime services and sends notifications +type UptimeMonitor struct { + client *UptimeClient + notificationService *UptimeNotificationService + checkInterval time.Duration + stopChan chan bool +} + +// NewUptimeMonitor creates a new uptime monitor +func NewUptimeMonitor(pbClient *pocketbase.PocketBaseClient) *UptimeMonitor { + // Initialize uptime client + uptimeClient := NewUptimeClient(pbClient) + + // Initialize notification service + notificationService := NewUptimeNotificationService(uptimeClient, pbClient) + + // Set check interval to 30 seconds to match server monitoring + checkInterval := 30 * time.Second + + return &UptimeMonitor{ + client: uptimeClient, + notificationService: notificationService, + checkInterval: checkInterval, + stopChan: make(chan bool, 1), + } +} + +// Start begins monitoring uptime services +func (um *UptimeMonitor) Start() { + // log.Printf("๐Ÿš€ [UPTIME-MONITOR] Starting with check interval: %v", um.checkInterval) + // log.Printf("โฐ [UPTIME-MONITOR] Startup grace period: 2 minutes (no notifications for UP services)") + + // Run initial check + um.checkServices() + + // Set up periodic checking every 30 seconds + ticker := time.NewTicker(um.checkInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + um.checkServices() + case <-um.stopChan: + // log.Println("๐Ÿ›‘ [UPTIME-MONITOR] Monitoring stopped") + return + } + } +} + +// Stop gracefully stops the uptime monitoring +func (um *UptimeMonitor) Stop() { + // log.Printf("๐Ÿ›‘ [UPTIME-MONITOR] Stopping uptime monitoring...") + select { + case um.stopChan <- true: + default: + } +} + +// checkServices fetches all services and checks their notification requirements +func (um *UptimeMonitor) checkServices() { + // log.Printf("๐Ÿ” [UPTIME-CHECK] Checking uptime services for status changes...") + + services, err := um.client.GetServices() + if err != nil { + // log.Printf("โŒ [UPTIME-ERROR] Failed to fetch services: %v", err) + _ = err + return + } + + // log.Printf("๐Ÿ“Š [UPTIME-SERVICES] Found %d services to check", len(services)) + + successCount := 0 + errorCount := 0 + + for _, service := range services { + // Skip paused services + if service.Status == "paused" { + // log.Printf("โธ๏ธ [UPTIME-SKIP] Service %s is paused - skipping", service.Name) + continue + } + + // Check each service for notification requirements + if err := um.notificationService.CheckAndNotifyService(service); err != nil { + // log.Printf("โŒ [UPTIME-FAILED] Failed to process service %s: %v", service.Name, err) + _ = err + errorCount++ + } else { + successCount++ + } + } + + // log.Printf("โœ… [UPTIME-SUMMARY] Processed %d services (success: %d, errors: %d)", + // len(services), successCount, errorCount) + _ = successCount + _ = errorCount +} \ No newline at end of file diff --git a/server/service-operation/uptime-monitoring/notification_service.go b/server/service-operation/uptime-monitoring/notification_service.go new file mode 100644 index 0000000..112ad34 --- /dev/null +++ b/server/service-operation/uptime-monitoring/notification_service.go @@ -0,0 +1,272 @@ + +package uptimemonitoring + +import ( + "fmt" + "strings" + "time" + + "service-operation/notification" + "service-operation/pocketbase" +) + +// UptimeNotificationService handles notifications for uptime services +type UptimeNotificationService struct { + client *UptimeClient + notificationMgr *notification.NotificationManager + statusTracker *ServiceStatusTracker + startupTime time.Time + gracePeriod time.Duration +} + +// NewUptimeNotificationService creates a new uptime notification service +func NewUptimeNotificationService(client *UptimeClient, pbClient *pocketbase.PocketBaseClient) *UptimeNotificationService { + return &UptimeNotificationService{ + client: client, + notificationMgr: notification.NewNotificationManager(pbClient), + statusTracker: NewServiceStatusTracker(), + startupTime: time.Now(), + gracePeriod: 2 * time.Minute, // 2 minute grace period after startup + } +} + +// isInGracePeriod checks if we're still in the startup grace period +func (uns *UptimeNotificationService) isInGracePeriod() bool { + return time.Since(uns.startupTime) < uns.gracePeriod +} + +// CheckAndNotifyService checks a service and sends notifications for status changes +func (uns *UptimeNotificationService) CheckAndNotifyService(service UptimeService) error { + // log.Printf("๐Ÿ” [UPTIME-CHECK] Checking service: %s (ID: %s, Type: %s)", service.Name, service.ID, service.ServiceType) + + // Check if notifications are enabled for this service + if !uns.isNotificationEnabled(service) { + // log.Printf("โš ๏ธ [SKIP] Notifications disabled for service %s", service.Name) + return nil + } + + // Get ACTUAL current status from PocketBase metrics (including error message) + actualCurrentStatus, responseTime, errorMessage, err := uns.getActualServiceStatusWithError(service) + if err != nil { + // log.Printf("โŒ [ERROR] Failed to get actual status for %s: %v", service.Name, err) + // If we can't get status, assume it's down + actualCurrentStatus = "down" + responseTime = 0 + errorMessage = fmt.Sprintf("Failed to retrieve service metrics: %v", err) + } + + // Get previously tracked status + previousStatus := uns.statusTracker.GetLastStatus(service.ID) + + // log.Printf("๐Ÿ“Š [STATUS-COMPARE] Service %s: PREVIOUS='%s' -> ACTUAL_CURRENT='%s', Error: '%s'", service.Name, previousStatus, actualCurrentStatus, errorMessage) + + // Initialize tracking for new services + if previousStatus == "" { + // log.Printf("๐Ÿ†• [INIT] First time tracking service %s - setting initial status: %s", service.Name, actualCurrentStatus) + + // Check if we're in startup grace period + if uns.isInGracePeriod() { + // log.Printf("โฐ [GRACE-PERIOD] Service %s - within startup grace period, skipping initial notification", service.Name) + uns.statusTracker.UpdateStatus(service.ID, actualCurrentStatus) + return nil + } + + // Only send initial notification if service is DOWN (critical status) + if actualCurrentStatus == "down" { + // log.Printf("๐Ÿšจ [CRITICAL-INIT] Service %s is DOWN on first check - sending notification", service.Name) + err := uns.sendImmediateNotification(service, actualCurrentStatus, responseTime, errorMessage, "INITIAL_DOWN_DETECTED") + if err != nil { + // log.Printf("โŒ [FAILED] Initial DOWN notification failed for %s: %v", service.Name, err) + _ = err + } else { + // log.Printf("โœ… [SUCCESS] Initial DOWN notification sent for %s", service.Name) + uns.statusTracker.SetLastNotificationTime(service.ID, time.Now()) + } + } else { + // log.Printf("โ„น๏ธ [INIT-SKIP] Service %s is %s on first check - skipping notification (not critical)", service.Name, actualCurrentStatus) + } + + uns.statusTracker.UpdateStatus(service.ID, actualCurrentStatus) + return nil + } + + // Check if there's a REAL status change + if previousStatus != actualCurrentStatus { + // log.Printf("๐Ÿšจ [REAL-CHANGE] STATUS CHANGE for %s: %s -> %s", service.Name, previousStatus, actualCurrentStatus) + + // Send notification immediately for real status change + err := uns.sendImmediateNotification(service, actualCurrentStatus, responseTime, errorMessage, fmt.Sprintf("STATUS_CHANGE_%s_TO_%s", previousStatus, actualCurrentStatus)) + if err != nil { + // log.Printf("โŒ [FAILED] Status change notification failed for %s: %v", service.Name, err) + return err + } + + // log.Printf("โœ… [SUCCESS] Status change notification sent for %s", service.Name) + uns.statusTracker.SetLastNotificationTime(service.ID, time.Now()) + uns.statusTracker.UpdateStatus(service.ID, actualCurrentStatus) + + } else if actualCurrentStatus == "down" { + // For services that remain down, send periodic notifications (every 5 minutes) + lastNotified := uns.statusTracker.GetLastNotificationTime(service.ID) + if time.Since(lastNotified) >= 5*time.Minute { + // log.Printf("โฐ [PERIODIC] Service %s still down for 5+ minutes - sending reminder", service.Name) + + err := uns.sendImmediateNotification(service, actualCurrentStatus, responseTime, errorMessage, "PERIODIC_DOWN_REMINDER") + if err != nil { + // log.Printf("โŒ [FAILED] Periodic down notification failed for %s: %v", service.Name, err) + return err + } + + // log.Printf("โœ… [SUCCESS] Periodic down notification sent for %s", service.Name) + uns.statusTracker.SetLastNotificationTime(service.ID, time.Now()) + } + } else { + // log.Printf("โ„น๏ธ [NO-CHANGE] No status change for %s (both %s) - no notification needed", service.Name, actualCurrentStatus) + } + + return nil +} + +// getActualServiceStatusWithError gets the real current status from PocketBase metrics including error message +func (uns *UptimeNotificationService) getActualServiceStatusWithError(service UptimeService) (string, int64, string, error) { + // Get the appropriate collection based on service type + collection := uns.getCollectionForServiceType(service.ServiceType) + + // log.Printf("๐Ÿ” [METRICS-CHECK] Getting actual status for %s from collection: %s", service.Name, collection) + + // Get the latest metric record for this service + records, err := uns.client.GetLatestMetricRecord(service.ID, collection) + if err != nil { + // log.Printf("โŒ [METRICS-ERROR] Failed to get metrics for %s: %v", service.Name, err) + return "unknown", 0, fmt.Sprintf("Failed to retrieve metrics: %v", err), err + } + + if len(records) == 0 { + // log.Printf("โš ๏ธ [NO-METRICS] No metrics found for service %s", service.Name) + return "down", 0, "No metrics data available", fmt.Errorf("no metrics found for service %s", service.Name) + } + + // Get the latest record + latestRecord := records[0] + actualStatus := latestRecord.Status + responseTime := int64(latestRecord.ResponseTime) + errorMessage := latestRecord.ErrorMessage // Get the error message from the record + + // log.Printf("๐Ÿ“ˆ [METRICS-RESULT] Latest metric for %s: Status=%s, ResponseTime=%dms, Error='%s', Timestamp=%s", + // service.Name, actualStatus, responseTime, errorMessage, latestRecord.Timestamp) + + return actualStatus, responseTime, errorMessage, nil +} + +// getCollectionForServiceType maps service types to their corresponding collections +func (uns *UptimeNotificationService) getCollectionForServiceType(serviceType string) string { + switch strings.ToLower(serviceType) { + case "ping", "icmp": + return "ping_data" + case "dns": + return "dns_data" + case "tcp": + return "tcp_data" + case "http", "https": + return "uptime_data" + default: + return "uptime_data" + } +} + +// sendImmediateNotification sends notification with zero delay for status changes +func (uns *UptimeNotificationService) sendImmediateNotification(service UptimeService, status string, responseTime int64, errorMessage, reason string) error { + // log.Printf("โšก [IMMEDIATE-SEND] Sending notification for %s (status: %s, error: '%s', reason: %s)", service.Name, status, errorMessage, reason) + + // Build notification payload with actual current status and error message + payload := ¬ification.NotificationPayload{ + ServiceName: service.Name, + Status: status, + Host: service.Host, + Port: service.Port, + ServiceType: service.ServiceType, + ResponseTime: responseTime, + ErrorMessage: errorMessage, // Include the error message from metrics + Timestamp: time.Now(), + Message: uns.generateMessage(service, status, reason), + } + + // Add optional fields + if service.Domain != "" { + payload.Domain = service.Domain + } + if service.URL != "" { + payload.URL = service.URL + } + if service.RegionName != "" { + payload.RegionName = service.RegionName + } + if service.AgentID != "" { + payload.AgentID = service.AgentID + } + payload.Uptime = service.Uptime + + // log.Printf("โšก [TELEGRAM-SEND] Sending uptime notification for %s via NotificationManager with error: '%s'", service.Name, errorMessage) + + // Send notification using the uptime manager + return uns.notificationMgr.SendUptimeServiceNotification(payload, service.NotificationID, service.TemplateID) +} + +// generateMessage creates a status message for notifications +func (uns *UptimeNotificationService) generateMessage(service UptimeService, status, reason string) string { + statusEmoji := "โœ…" + action := "is operational" + + switch strings.ToLower(status) { + case "down": + statusEmoji = "โŒ" + action = "is DOWN" + case "warning": + statusEmoji = "โš ๏ธ" + action = "has issues" + case "up": + statusEmoji = "โœ…" + previousStatus := uns.statusTracker.GetLastStatus(service.ID) + if previousStatus == "down" { + action = "has RECOVERED" + statusEmoji = "๐Ÿ”„" + } else { + action = "is operational" + } + } + + message := fmt.Sprintf("%s [UPTIME] %s %s", statusEmoji, service.Name, action) + + if service.Host != "" { + message += fmt.Sprintf(" | Host: %s", service.Host) + } + + // Add reason for debugging (only in logs, not in user message) + // log.Printf("๐Ÿ“ [MSG-DEBUG] Generated uptime message for %s: %s [%s]", service.Name, message, reason) + + return message +} + +// isNotificationEnabled checks if notifications should be sent +func (uns *UptimeNotificationService) isNotificationEnabled(service UptimeService) bool { + // Check notification_status boolean field + if !service.NotificationStatus { + // log.Printf("๐Ÿ“ต Service %s notification_status: false", service.Name) + return false + } + + // Check alerts field for muted status + if service.Alerts == "muted" { + // log.Printf("๐Ÿ”‡ Service %s alerts muted", service.Name) + return false + } + + // Check if notification_id is configured + if service.NotificationID == "" { + // log.Printf("๐Ÿ“ต Service %s has no notification_id", service.Name) + return false + } + + // log.Printf("โœ… Service %s notifications enabled (ID: %s)", service.Name, service.NotificationID) + return true +} \ No newline at end of file diff --git a/server/service-operation/uptime-monitoring/status_tracker.go b/server/service-operation/uptime-monitoring/status_tracker.go new file mode 100644 index 0000000..e59e74a --- /dev/null +++ b/server/service-operation/uptime-monitoring/status_tracker.go @@ -0,0 +1,73 @@ + +package uptimemonitoring + +import ( + "sync" + "time" +) + +// ServiceStatusTracker tracks service status changes and notification timing +type ServiceStatusTracker struct { + lastStatus map[string]string + lastNotificationTime map[string]time.Time + mu sync.RWMutex +} + +// NewServiceStatusTracker creates a new service status tracker +func NewServiceStatusTracker() *ServiceStatusTracker { + return &ServiceStatusTracker{ + lastStatus: make(map[string]string), + lastNotificationTime: make(map[string]time.Time), + } +} + +// GetLastStatus returns the last known status for a service +func (sst *ServiceStatusTracker) GetLastStatus(serviceID string) string { + sst.mu.RLock() + defer sst.mu.RUnlock() + status := sst.lastStatus[serviceID] + // log.Printf("๐Ÿ” GetLastStatus for %s: %s", serviceID, status) + return status +} + +// UpdateStatus updates the last known status for a service +func (sst *ServiceStatusTracker) UpdateStatus(serviceID, status string) { + sst.mu.Lock() + defer sst.mu.Unlock() + + oldStatus := sst.lastStatus[serviceID] + sst.lastStatus[serviceID] = status + + // log.Printf("๐Ÿ“ UpdateStatus for %s: %s -> %s", serviceID, oldStatus, status) + _ = oldStatus +} + +// GetLastNotificationTime returns the last time a notification was sent for a service +func (sst *ServiceStatusTracker) GetLastNotificationTime(serviceID string) time.Time { + sst.mu.RLock() + defer sst.mu.RUnlock() + lastTime := sst.lastNotificationTime[serviceID] + // log.Printf("โฐ GetLastNotificationTime for %s: %v", serviceID, lastTime) + return lastTime +} + +// SetLastNotificationTime sets the last notification time for a service +func (sst *ServiceStatusTracker) SetLastNotificationTime(serviceID string, t time.Time) { + sst.mu.Lock() + defer sst.mu.Unlock() + sst.lastNotificationTime[serviceID] = t + // log.Printf("โฐ SetLastNotificationTime for %s: %v", serviceID, t) +} + +// ShouldNotify determines if a notification should be sent (legacy method for compatibility) +func (sst *ServiceStatusTracker) ShouldNotify(serviceID, currentStatus string) bool { + sst.mu.RLock() + lastStatus := sst.lastStatus[serviceID] + sst.mu.RUnlock() + + shouldNotify := lastStatus == "" || lastStatus != currentStatus + // log.Printf("๐Ÿค” ShouldNotify for %s: lastStatus='%s', currentStatus='%s', result=%t", serviceID, lastStatus, currentStatus, shouldNotify) + + // Send notification if status changed or if it's the first check + return shouldNotify +} \ No newline at end of file diff --git a/server/service-operation/uptime-monitoring/types.go b/server/service-operation/uptime-monitoring/types.go new file mode 100644 index 0000000..81d7064 --- /dev/null +++ b/server/service-operation/uptime-monitoring/types.go @@ -0,0 +1,41 @@ + +package uptimemonitoring + +// UptimeService represents a service from PocketBase +type UptimeService struct { + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + Uptime int `json:"uptime"` + ResponseTime int64 `json:"response_time"` + LastChecked string `json:"last_checked"` + Port int `json:"port"` + Domain string `json:"domain"` + HeartbeatInterval int `json:"heartbeat_interval"` + MaxRetries int `json:"max_retries"` + NotificationID string `json:"notification_id"` + TemplateID string `json:"template_id"` + ServiceType string `json:"service_type"` + Status string `json:"status"` + URL string `json:"url"` + Alerts string `json:"alerts"` + StatusCodes string `json:"status_codes"` + Keyword string `json:"keyword"` + RegionName string `json:"region_name"` + AgentID string `json:"agent_id"` + RegionalStatus string `json:"regional_status"` + NotificationStatus bool `json:"notification_status"` // Changed from string to bool + GroupID string `json:"group_id"` + WebhookID string `json:"webhook_id"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +// UptimeServicesResponse represents the response from PocketBase services API +type UptimeServicesResponse struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Items []UptimeService `json:"items"` +} \ No newline at end of file