Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f88cc32cf9 | |||
| 918a248b8f | |||
| 458f5f7d82 | |||
| 5ceaa09710 | |||
| 1eabf66311 | |||
| a0c0a3ed75 | |||
| abb129bde0 | |||
| 220be3bea4 | |||
| 614c10188d | |||
| 11408dcba0 | |||
| 9f4e759b01 | |||
| d3ea5dc77c | |||
| 89572c44b7 | |||
| dfe98f9fa6 | |||
| 549a3793a5 | |||
| 9ed4d4c7ac | |||
| cca2338468 | |||
| c1c74bb2a8 | |||
| a2ae66fe76 | |||
| 907fdaa0dc | |||
| 93b58cfa2c | |||
| 879622b364 | |||
| 2f52010963 | |||
| c90f0dd963 | |||
| 4a14f64e07 | |||
| 19b4de78d1 | |||
| bdf648c2fe | |||
| 0705fc89b4 | |||
| 883a541b9b | |||
| 0758b1d24b | |||
| 9a001f6aec | |||
| 9c78def48d | |||
| 94e2c0da77 | |||
| 78674f758c | |||
| 3a6ab2258a | |||
| 1daaf067d9 | |||
| 6d1b8387ca | |||
| e3f9111484 | |||
| 575869c50a | |||
| cfdedb9636 | |||
| 08b00728c2 | |||
| 4486a79e58 | |||
| 41fcaaaf05 | |||
| ddcabac564 | |||
| 4e0a9953db | |||
| f25fa7de83 | |||
| aea70d0023 | |||
| 4e07c8c932 | |||
| 5f18fde775 | |||
| 9d5fa805c9 | |||
| e1d466a638 | |||
| 8dc3bd6a0d | |||
| 6d63d9f485 | |||
| 68a4ad5478 | |||
| 895da83999 | |||
| f6592d331f | |||
| da0808cefe | |||
| 1e77f2747e | |||
| 9b1918e25a | |||
| 99e988e316 | |||
| 419e9118f9 | |||
| e2c2705378 | |||
| 82fa9a4ac2 | |||
| 576f2fbb34 | |||
| 554faefe4a | |||
| c92e9fe442 | |||
| ede5c36ac4 | |||
| 9097a618cd | |||
| f4f6cb48db | |||
| 1264d293bf | |||
| 8608b6a263 | |||
| 86e1e70e22 | |||
| dd2c64420d | |||
| ba47c1d953 | |||
| ba6dc41d95 | |||
| 1dc25cc461 | |||
| 11ddc77e32 | |||
| 5a5772f9a4 | |||
| 039a832b79 | |||
| a88b356bf5 | |||
| 2d798e2983 | |||
| 426f41e337 | |||
| 4dc005c7e4 | |||
| 4ef84b5469 | |||
| 0739d6b0ce | |||
| bd17ef7140 | |||
| 518b469c04 | |||
| 6652ac5e76 | |||
| 4ec0c7a8fa | |||
| 0b52e7ffa9 | |||
| dd758b9334 | |||
| 9b1167955e | |||
| 611380a5e4 | |||
| 0b9bc76d37 | |||
| 0562adbef8 | |||
| d4d4040cc8 | |||
| aec7118496 | |||
| c17d902b9a | |||
| a5ad5eef42 | |||
| c7846a2fed | |||
| 4ad001b560 | |||
| d52842a8ea | |||
| 9e02eea0d1 | |||
| a1d2f1a9e0 | |||
| 230f5562c7 | |||
| 759c3249e1 | |||
| bff3cd7d91 | |||
| 3799be8b23 | |||
| 0932acfb61 | |||
| 38374acdcd | |||
| a58180f252 | |||
| a307fd006c | |||
| 31738b3a52 | |||
| e3e7206d91 | |||
| fe1b94d8e3 | |||
| 4f1c78f8e1 | |||
| 7ccd413973 | |||
| e74bf35264 | |||
| 23be1fca0f | |||
| df047541b9 | |||
| c8323e91b0 | |||
| 9da272b464 | |||
| 706a01e5aa | |||
| 8c159f402e | |||
| f483fbd2ee | |||
| 616148f794 | |||
| 7a125bf0b7 | |||
| dfe052aba6 | |||
| 641e98c001 | |||
| 1ee63ec74a | |||
| 0d8b88d9a1 | |||
| 280c5dcc86 | |||
| 09ce22b4fb | |||
| 1e8962cb11 | |||
| a3727b5dfd | |||
| 4c2f65b432 | |||
| 0692a78576 | |||
| 839fcc8771 | |||
| 686634f0ac | |||
| 705acfbe1d | |||
| b845e46231 | |||
| 1f13d5c6be | |||
| 9781a0ad68 | |||
| 1e51c964cf | |||
| b282b0b2d5 | |||
| 8910c1355c | |||
| b4bef67220 | |||
| 462b6d6d40 | |||
| 747ae5c23f | |||
| 395a580032 | |||
| a7a1c9a144 | |||
| 11deebe45f | |||
| 0e705e1309 | |||
| 4a6cd92661 | |||
| 3f6d606190 | |||
| 7c382ddb7a | |||
| e4b5ba96c1 | |||
| 34e78e5c46 | |||
| 442e025f7b | |||
| 975fb65c3d | |||
| 48abc618f3 | |||
| ba318f13a5 | |||
| c099f03bbb | |||
| 19f13598fb | |||
| 918e012082 | |||
| e0c7541a87 | |||
| a1c6c7053e | |||
| 389eb22737 | |||
| 466ef1cb90 | |||
| caaa32ea13 | |||
| 78be48ffd0 | |||
| aaee189ace | |||
| ce988574e4 | |||
| a166938d42 | |||
| e4d6d84477 | |||
| c0cf913935 | |||
| c8d640ebb5 | |||
| 9f38e23b22 | |||
| 1ea81a23b2 | |||
| 8ee933563e | |||
| 687619ce0d | |||
| 9e931ad439 | |||
| c7fd7f4fbe | |||
| eafc4c33ab | |||
| 7644480782 | |||
| 1515ae56af | |||
| caf3e73dc3 | |||
| 98c86ff5dd | |||
| 42441b615a | |||
| e5651c152a | |||
| fc84922959 | |||
| 093300c38a | |||
| 2dcedf7b30 | |||
| 38e06b9bec | |||
| 9f2ab1a79b | |||
| 54ebe0604d | |||
| cf28eb41d6 | |||
| 8ef3614908 | |||
| 7773c1bc66 | |||
| 580dbc424c | |||
| 981bc972f0 | |||
| a9043e8088 | |||
| 1ae961c5d9 | |||
| 51226a7afe | |||
| dfd57a6b62 | |||
| 12f4d18521 | |||
| aea32f10b6 | |||
| a16b1d228a | |||
| 930622b5be |
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
application/src/lib/pocketbase.ts
|
||||||
|
Dockerfile
|
||||||
+1
-1
@@ -46,7 +46,7 @@ Click "Fork" on [GitHub](https://github.com/operacle/checkcle) to create your ow
|
|||||||
|
|
||||||
### 2. Clone Your Fork
|
### 2. Clone Your Fork
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/operacle/checkcle.git
|
git clone --branch develop https://github.com/operacle/checkcle.git
|
||||||
cd checkcle
|
cd checkcle
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
# Stage 1: Build the frontend
|
|
||||||
FROM node:18-alpine AS frontend-builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY application/package*.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY application/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: Download PocketBase binary
|
|
||||||
FROM alpine:3.17 AS pb-builder
|
|
||||||
WORKDIR /pb
|
|
||||||
ARG PB_VERSION=0.27.2
|
|
||||||
RUN apk add --no-cache wget unzip \
|
|
||||||
&& wget https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip \
|
|
||||||
&& unzip pocketbase_${PB_VERSION}_linux_amd64.zip \
|
|
||||||
&& chmod +x /pb/pocketbase
|
|
||||||
|
|
||||||
# Stage 3: Final runtime image
|
|
||||||
FROM alpine:3.17
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy PocketBase binary
|
|
||||||
COPY --from=pb-builder /pb/pocketbase /app/pocketbase
|
|
||||||
|
|
||||||
# Copy frontend build to PocketBase public directory
|
|
||||||
COPY --from=frontend-builder /app/dist /app/pb_public
|
|
||||||
|
|
||||||
# Copy backend files
|
|
||||||
COPY server/pb_migrations /app/pb_migrations
|
|
||||||
COPY server/pb_hooks /app/pb_hooks
|
|
||||||
COPY server/pb_data /app/pb_data
|
|
||||||
|
|
||||||
# Mark pb_data as a volume for runtime persistence
|
|
||||||
VOLUME /app/pb_data
|
|
||||||
|
|
||||||
# Expose default PocketBase port
|
|
||||||
EXPOSE 8090
|
|
||||||
|
|
||||||
# Launch PocketBase
|
|
||||||
#CMD ["/app/pocketbase", "serve", "--dir", "/app/pb_data"]
|
|
||||||
|
|
||||||
|
|
||||||
# Launch PocketBase and bind to 0.0.0.0 for external access
|
|
||||||
CMD ["/app/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir", "/app/pb_data"]
|
|
||||||
+18
-14
@@ -1,17 +1,21 @@
|
|||||||
The MIT License (MIT)
|
MIT License
|
||||||
Copyright (c) 2022 - Present, Tola Leng
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
Copyright (c) 2025 Tola Leng
|
||||||
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
substantial portions of the Software.
|
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 SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
The above copyright notice and this permission notice shall be included in all
|
||||||
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
copies or substantial portions of the Software.
|
||||||
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,
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
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.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
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.
|
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
|
## 🎯 Live Demo
|
||||||
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io) Coming Soon!
|
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
|
||||||
|
|
||||||
## 🌟 Core Features
|
## 🌟 Core Features
|
||||||
|
|
||||||
@@ -24,21 +24,33 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
|
|||||||
|
|
||||||
## #️⃣ Getting Started
|
## #️⃣ Getting Started
|
||||||
|
|
||||||
### Installation with Docker Compose
|
### Current Architecture Support
|
||||||
1. Clone the repository and run
|
* ✅ 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 options below.
|
||||||
|
|
||||||
|
|
||||||
|
1. CheckCle One-Click Installation - Just copy and run on terminal
|
||||||
```bash
|
```bash
|
||||||
#Clone the repository
|
curl -fsSL https://checkcle.io/install.sh | bash
|
||||||
|
|
||||||
git clone https://github.com/operacle/checkcle.git
|
|
||||||
cd checkcle
|
|
||||||
|
|
||||||
# Run docker compose
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
```
|
```
|
||||||
2. Docker Compose - Recommended
|
2. Install with docker run. Just copy ready docker run command below
|
||||||
```bash
|
```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. Install with Docker compose Configuration.
|
||||||
|
```bash
|
||||||
|
|
||||||
|
version: '3.9'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
checkcle:
|
checkcle:
|
||||||
@@ -48,15 +60,12 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8090:8090" # Web Application
|
- "8090:8090" # Web Application
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/app/pb_data # Ensure persistent data across rebuilds
|
- /opt/pb_data:/mnt/pb_data # Host directory mapped to container path
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 4096
|
soft: 4096
|
||||||
hard: 8192
|
hard: 8192
|
||||||
|
|
||||||
volumes:
|
|
||||||
pb_data: # Docker-managed volume for data persistence
|
|
||||||
|
|
||||||
```
|
```
|
||||||
3. Admin Web Management
|
3. Admin Web Management
|
||||||
|
|
||||||
@@ -64,29 +73,34 @@ volumes:
|
|||||||
User: admin@example.com
|
User: admin@example.com
|
||||||
Passwd: Admin123456
|
Passwd: Admin123456
|
||||||
|
|
||||||
4. Follow the Quick Start Guide at https://docs.checkcle.com (Coming Soon)
|
4. Follow the Quick Start Guide at https://docs.checkcle.io
|
||||||
|
|
||||||
###
|
###
|
||||||

|
|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
## 📝 Development Roadmap
|
## 📝 Development Roadmap
|
||||||
|
|
||||||
- [x] Health check & uptime monitoring (HTTP)
|
- ✅ Health check & uptime monitoring (HTTP)
|
||||||
- [x] Dashboard UI with live stats
|
- ✅ Dashboard UI with live stats
|
||||||
- [x] Auth with Multi-users system (admin)
|
- ✅ Auth with Multi-users system (admin)
|
||||||
- [x] Notifications (Telegram)
|
- ✅ Notifications (Telegram)
|
||||||
- [x] Docker containerization
|
- ✅ Docker containerization
|
||||||
- [ ] SSL & Domain Monitoring
|
- ✅ CheckCle Website
|
||||||
|
- ✅ CheckCle Demo Server
|
||||||
|
- ✅ SSL & Domain Monitoring
|
||||||
|
- ✅ Schedule Maintenance
|
||||||
|
- ✅ Incident Management
|
||||||
|
- [ ] Uptime monitoring (PING - Inprogress)
|
||||||
- [ ] Infrastructure Server Monitoring
|
- [ ] Infrastructure Server Monitoring
|
||||||
- [ ] Schedule Maintenance & Incident Management
|
- ✅ Operational Status / Public Status Pages
|
||||||
- [ ] Operational Status / Public Status Pages
|
|
||||||
- [ ] Uptime monitoring (TCP, PING, DNS)
|
- [ ] Uptime monitoring (TCP, PING, DNS)
|
||||||
- [ ] User Permission Roles & Service Group
|
- ✅ System Setting Panel and Mail Settings
|
||||||
|
- ✅ User Permission Roles
|
||||||
- [ ] Notifications (Email/Slack/Discord/Signal)
|
- [ ] Notifications (Email/Slack/Discord/Signal)
|
||||||
- [ ] Mobile PWA version
|
- ✅ Data Retention & Automate Strink (Muti Options to Shrink Data & Database )
|
||||||
- [ ] Open-source release with full documentation
|
- ✅ Open-source release with full documentation
|
||||||
|
|
||||||
## 🌟 CheckCle for Communities?
|
## 🌟 CheckCle for Communities?
|
||||||
- **Built with Passion**: Created by an open-source enthusiast for the community
|
- **Built with Passion**: Created by an open-source enthusiast for the community
|
||||||
@@ -109,6 +123,7 @@ Here are some ways you can help improve CheckCle:
|
|||||||
|
|
||||||
## 🌍 Stay Connected
|
## 🌍 Stay Connected
|
||||||
- Website: [checkcle.io](https://checkcle.io)
|
- Website: [checkcle.io](https://checkcle.io)
|
||||||
|
- Documentation: [docs.checkcle.io](https://docs.checkcle.io)
|
||||||
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
|
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
|
||||||
- Community Channels: Engage via discussions and issues!
|
- Community Channels: Engage via discussions and issues!
|
||||||
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
||||||
@@ -120,4 +135,8 @@ CheckCle is released under the MIT License.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#operacle/checkcle&Date)
|
||||||
|
|
||||||
Stay informed, stay online, with CheckCle! 🌐
|
Stay informed, stay online, with CheckCle! 🌐
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# 🔐 Security Policy
|
||||||
|
|
||||||
|
## 📦 Project: [checkcle](https://github.com/operacle/checkcle)
|
||||||
|
|
||||||
|
**checkcle** is a lightweight, TypeScript-based built for uptime and server monitoring tools.
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---------|-----------|
|
||||||
|
| `main` (latest) | ✅ Yes |
|
||||||
|
| Older versions | ⚠️ Best-effort |
|
||||||
|
| Pre-release or forks | ❌ No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Security Practices
|
||||||
|
|
||||||
|
This project adheres to the following practices to enhance security:
|
||||||
|
|
||||||
|
- 🔎 Regular vulnerability checks using `npm audit`
|
||||||
|
- ⛓️ Dependency pinning via `package-lock.json`
|
||||||
|
- ✅ Type-safe code using `TypeScript`
|
||||||
|
- 🧪 Continuous testing and CI
|
||||||
|
- 🔐 No data is stored or transmitted unless explicitly configured by the user
|
||||||
|
- 🧑💻 All contributions are reviewed before being merged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Known Security Limitations
|
||||||
|
|
||||||
|
- `checkcle` makes outbound HTTPS requests to check certificate details — avoid running in untrusted or high-risk environments without proper network policies.
|
||||||
|
- Output may contain certificate metadata (e.g., CN, SANs, expiry dates) — avoid exposing this to public logs unless sanitized.
|
||||||
|
- The data may be lost upon system restarts or crashes. Always ensure that backup 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)
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@radix-ui/react-radio-group": "^1.2.0",
|
"@radix-ui/react-radio-group": "^1.2.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.6",
|
||||||
"@radix-ui/react-slider": "^1.2.0",
|
"@radix-ui/react-slider": "^1.2.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.1.0",
|
"@radix-ui/react-switch": "^1.1.0",
|
||||||
@@ -46,6 +46,8 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
|
"jspdf": "^3.0.1",
|
||||||
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"pocketbase": "^0.19.0",
|
"pocketbase": "^0.19.0",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
+46
-89
@@ -1,107 +1,64 @@
|
|||||||
|
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import React from 'react';
|
||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||||
import { useEffect, useState } from "react";
|
import { LanguageProvider } from '@/contexts/LanguageContext';
|
||||||
import { authService } from "@/services/authService";
|
import { SidebarProvider } from '@/contexts/SidebarContext';
|
||||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
import { LanguageProvider } from "@/contexts/LanguageContext";
|
|
||||||
|
|
||||||
import Login from "./pages/Login";
|
// Pages
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Index from '@/pages/Index';
|
||||||
import ServiceDetail from "./pages/ServiceDetail";
|
import Login from '@/pages/Login';
|
||||||
import Settings from "./pages/Settings";
|
import Dashboard from '@/pages/Dashboard';
|
||||||
import Profile from "./pages/Profile";
|
import ServiceDetail from '@/pages/ServiceDetail';
|
||||||
import NotFound from "./pages/NotFound";
|
import Profile from '@/pages/Profile';
|
||||||
|
import Settings from '@/pages/Settings';
|
||||||
|
import OperationalPage from '@/pages/OperationalPage';
|
||||||
|
import ScheduleIncident from '@/pages/ScheduleIncident';
|
||||||
|
import SslDomain from '@/pages/SslDomain';
|
||||||
|
import PublicStatusPage from '@/pages/PublicStatusPage';
|
||||||
|
import NotFound from '@/pages/NotFound';
|
||||||
|
|
||||||
// Create a Protected route component
|
const queryClient = new QueryClient({
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
defaultOptions: {
|
||||||
const isAuthenticated = authService.isAuthenticated();
|
queries: {
|
||||||
|
retry: 1,
|
||||||
if (!isAuthenticated) {
|
refetchOnWindowFocus: false,
|
||||||
return <Navigate to="/login" replace />;
|
},
|
||||||
}
|
},
|
||||||
|
});
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check authentication status when the app loads
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
// Just check the auth state
|
|
||||||
authService.isAuthenticated();
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkAuth();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="flex items-center justify-center h-screen bg-background text-foreground">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<TooltipProvider>
|
<SidebarProvider>
|
||||||
<Toaster />
|
<ErrorBoundary>
|
||||||
<Sonner />
|
<Router>
|
||||||
<BrowserRouter>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route
|
<Route path="/service/:id" element={<ServiceDetail />} />
|
||||||
path="/dashboard"
|
<Route path="/profile" element={<Profile />} />
|
||||||
element={
|
<Route path="/settings" element={<Settings />} />
|
||||||
<ProtectedRoute>
|
<Route path="/operational-page" element={<OperationalPage />} />
|
||||||
<Dashboard />
|
<Route path="/schedule-incident" element={<ScheduleIncident />} />
|
||||||
</ProtectedRoute>
|
<Route path="/ssl-domain" element={<SslDomain />} />
|
||||||
}
|
<Route path="/status/:slug" element={<PublicStatusPage />} />
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/service/:id"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ServiceDetail />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/settings"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Settings />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/profile"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Profile />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</Router>
|
||||||
</TooltipProvider>
|
<Toaster />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</SidebarProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
// API routes handler
|
// API routes handler
|
||||||
import realtime from './realtime';
|
import realtime from './realtime';
|
||||||
|
import settingsApi from './settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple API router for client-side application
|
* Simple API router for client-side application
|
||||||
@@ -16,6 +17,9 @@ const api = {
|
|||||||
if (path === '/api/realtime') {
|
if (path === '/api/realtime') {
|
||||||
console.log("Routing to realtime handler");
|
console.log("Routing to realtime handler");
|
||||||
return await realtime(body);
|
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
|
// Return 404 for unknown routes
|
||||||
@@ -31,4 +35,52 @@ const api = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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;
|
export default api;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||||
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
|
export const getSettings = async (): Promise<SettingsApiResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getBaseUrl()}/api/settings`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
|
||||||
|
const settings = await response.json();
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: true, data: settings },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
json: { success: false, message: 'Failed to fetch settings' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
|
||||||
|
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 ReamStack';
|
||||||
|
let htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #2563eb;">Test Email</h2>
|
||||||
|
<p>This is a test email from your monitoring system.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">
|
||||||
|
Sent from ReamStack Monitoring System<br>
|
||||||
|
Template: ${template}<br>
|
||||||
|
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
switch (template) {
|
||||||
|
case 'verification':
|
||||||
|
subject = 'Email Verification Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #10b981;">Email Verification Test</h2>
|
||||||
|
<p>This is a test of the email verification template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Verification Email</p>
|
||||||
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'password-reset':
|
||||||
|
subject = 'Password Reset Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #f59e0b;">Password Reset Test</h2>
|
||||||
|
<p>This is a test of the password reset template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Password Reset Email</p>
|
||||||
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'email-change':
|
||||||
|
subject = 'Email Change Confirmation Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
|
||||||
|
<p>This is a test of the email change confirmation template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subject, htmlBody };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendTestEmail = async (data: any): Promise<SettingsApiResponse> => {
|
||||||
|
console.log('sendTestEmail function called with data:', data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
console.log('Invalid request data - not object');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Invalid request data' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.email || typeof data.email !== 'string') {
|
||||||
|
console.log('Email address missing or invalid type');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Email address is required and must be a string' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEmail(data.email)) {
|
||||||
|
console.log('Invalid email format:', data.email);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Invalid email address format' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Email validation passed for:', data.email);
|
||||||
|
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
// Get current SMTP settings first
|
||||||
|
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
|
||||||
|
|
||||||
|
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settingsResponse.ok) {
|
||||||
|
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Failed to get SMTP settings' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsData = await settingsResponse.json();
|
||||||
|
console.log('Retrieved settings data:', settingsData);
|
||||||
|
|
||||||
|
const smtpSettings = settingsData?.smtp;
|
||||||
|
|
||||||
|
if (!smtpSettings || !smtpSettings.enabled) {
|
||||||
|
console.log('SMTP not enabled or missing');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smtpSettings.host || !smtpSettings.username) {
|
||||||
|
console.log('SMTP configuration incomplete - missing host or username');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smtpSettings.password) {
|
||||||
|
console.log('SMTP password missing');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP password is required for authentication. Please configure the SMTP password.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test email content based on template
|
||||||
|
const template = data.template || 'basic';
|
||||||
|
const { subject, htmlBody } = createEmailTemplate(template, data);
|
||||||
|
|
||||||
|
console.log('Test email prepared successfully:', {
|
||||||
|
to: data.email,
|
||||||
|
subject: subject,
|
||||||
|
template: template,
|
||||||
|
smtpHost: smtpSettings.host,
|
||||||
|
smtpPort: smtpSettings.port || 587
|
||||||
|
});
|
||||||
|
|
||||||
|
// For now, we'll simulate a successful email send
|
||||||
|
// In a real implementation, you would integrate with your email service here
|
||||||
|
// This could be nodemailer, SendGrid, or your PocketBase email system
|
||||||
|
|
||||||
|
// Simulate processing time
|
||||||
|
console.log('Simulating email send...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
console.log('Email send simulation completed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: true,
|
||||||
|
message: `Test email sent successfully to ${data.email}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in sendTestEmail function:', error);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
|
||||||
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
|
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
||||||
|
let subject = 'Test Email from ReamStack';
|
||||||
|
let htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #2563eb;">Test Email</h2>
|
||||||
|
<p>This is a test email from your monitoring system.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">
|
||||||
|
Sent from ReamStack Monitoring System<br>
|
||||||
|
Template: ${template}<br>
|
||||||
|
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
switch (template) {
|
||||||
|
case 'verification':
|
||||||
|
subject = 'Email Verification Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #10b981;">Email Verification Test</h2>
|
||||||
|
<p>This is a test of the email verification template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Verification Email</p>
|
||||||
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'password-reset':
|
||||||
|
subject = 'Password Reset Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #f59e0b;">Password Reset Test</h2>
|
||||||
|
<p>This is a test of the password reset template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Password Reset Email</p>
|
||||||
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'email-change':
|
||||||
|
subject = 'Email Change Confirmation Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
|
||||||
|
<p>This is a test of the email change confirmation template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subject, htmlBody };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testEmail = async (data: any): Promise<SettingsApiResponse> => {
|
||||||
|
console.log('testEmail function called with data:', data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
console.log('Invalid request data - not object');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Invalid request data' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.email || typeof data.email !== 'string') {
|
||||||
|
console.log('Email address missing or invalid type');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Email address is required and must be a string' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEmail(data.email)) {
|
||||||
|
console.log('Invalid email format:', data.email);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Invalid email address format' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Email validation passed for:', data.email);
|
||||||
|
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
// Get current SMTP settings first
|
||||||
|
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
|
||||||
|
|
||||||
|
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settingsResponse.ok) {
|
||||||
|
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Failed to get SMTP settings' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsData = await settingsResponse.json();
|
||||||
|
console.log('Retrieved settings data:', settingsData);
|
||||||
|
|
||||||
|
const smtpSettings = settingsData?.smtp;
|
||||||
|
|
||||||
|
if (!smtpSettings || !smtpSettings.enabled) {
|
||||||
|
console.log('SMTP not enabled or missing');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smtpSettings.host || !smtpSettings.username) {
|
||||||
|
console.log('SMTP configuration incomplete - missing host or username');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test email content based on template
|
||||||
|
const template = data.template || 'basic';
|
||||||
|
const { subject, htmlBody } = createEmailTemplate(template, data);
|
||||||
|
|
||||||
|
console.log('Test email prepared successfully:', {
|
||||||
|
to: data.email,
|
||||||
|
subject: subject,
|
||||||
|
template: template,
|
||||||
|
smtpHost: smtpSettings.host,
|
||||||
|
smtpPort: smtpSettings.port || 587
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send actual email using the correct PocketBase API endpoint
|
||||||
|
console.log('Sending actual email via PocketBase...');
|
||||||
|
|
||||||
|
// Fix the payload structure to match PocketBase API expectations
|
||||||
|
const emailPayload = {
|
||||||
|
email: data.email, // Use 'email' instead of 'to'
|
||||||
|
template: template, // Add the template field
|
||||||
|
subject: subject,
|
||||||
|
html: htmlBody,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Email payload:', emailPayload);
|
||||||
|
|
||||||
|
const emailResponse = await fetch(`${baseUrl}/api/settings/test/email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(emailPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!emailResponse.ok) {
|
||||||
|
console.error('Failed to send email, status:', emailResponse.status);
|
||||||
|
const errorText = await emailResponse.text();
|
||||||
|
console.error('Email send error response:', errorText);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Failed to send email. Please check your SMTP configuration.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 204 No Content response (successful but no body)
|
||||||
|
if (emailResponse.status === 204) {
|
||||||
|
console.log('Email sent successfully (204 No Content)');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: true,
|
||||||
|
message: `Test email sent successfully to ${data.email}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other successful responses, try to parse JSON
|
||||||
|
const emailResult = await emailResponse.json();
|
||||||
|
console.log('Email sent successfully:', emailResult);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: true,
|
||||||
|
message: `Test email sent successfully to ${data.email}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in testEmail function:', error);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||||
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
|
export const testEmailConnection = async (data: any): Promise<SettingsApiResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getBaseUrl()}/api/settings/test-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: result.success || false,
|
||||||
|
message:
|
||||||
|
result.message || (result.success ? 'Connection successful' : 'Connection failed'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing email connection:', error);
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
json: { success: false, message: 'Failed to test email connection' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||||
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
|
export const updateSettings = async (data: any): Promise<SettingsApiResponse> => {
|
||||||
|
try {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
let response = await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && (response.status === 404 || response.status === 405)) {
|
||||||
|
response = await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
|
||||||
|
const updatedSettings = await response.json();
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: true, data: updatedSettings },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating settings:', error);
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
json: { success: false, message: 'Failed to update settings' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
import { getSettings } from './actions/getSettings';
|
||||||
|
import { updateSettings } from './actions/updateSettings';
|
||||||
|
import { testEmailConnection } from './actions/testEmailConnection';
|
||||||
|
import { testEmail } from './actions/testEmail';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings API handler
|
||||||
|
*/
|
||||||
|
const settingsApi = async (body: any, path?: string) => {
|
||||||
|
console.log('Settings API called with path:', path, 'body:', body);
|
||||||
|
|
||||||
|
// Handle test email endpoint specifically
|
||||||
|
if (path === '/api/settings/test/email') {
|
||||||
|
console.log('Handling test email request');
|
||||||
|
return await testEmail(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle regular settings API with action-based routing
|
||||||
|
const action = body?.action;
|
||||||
|
console.log('Settings API called with action:', action, 'data:', body?.data);
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'getSettings':
|
||||||
|
return await getSettings();
|
||||||
|
|
||||||
|
case 'updateSettings':
|
||||||
|
return await updateSettings(body.data);
|
||||||
|
|
||||||
|
case 'testEmailConnection':
|
||||||
|
return await testEmailConnection(body.data);
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error('Unknown action:', action);
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
json: { success: false, message: 'Unknown action' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default settingsApi;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
export interface SettingsApiRequest {
|
||||||
|
action: string;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsApiResponse {
|
||||||
|
status: number;
|
||||||
|
json: {
|
||||||
|
success: boolean;
|
||||||
|
data?: any;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmtpSettings {
|
||||||
|
enabled?: boolean;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
authMethod?: string;
|
||||||
|
tls?: boolean;
|
||||||
|
localName?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
export const getAuthHeaders = (): Record<string, string> => {
|
||||||
|
const authToken = pb.authStore.token;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBaseUrl = (): string => {
|
||||||
|
return getCurrentEndpoint();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateEmail = (email: string): boolean => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-destructive mb-4">Something went wrong</h2>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
import { Mail, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { getCurrentEndpoint } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
interface ForgotPasswordDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) {
|
||||||
|
const [step, setStep] = useState<'request' | 'confirm'>('request');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleRequestReset = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = getCurrentEndpoint();
|
||||||
|
const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to send reset email');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Reset Email Sent",
|
||||||
|
description: "Please check your email for password reset instructions.",
|
||||||
|
});
|
||||||
|
setStep('confirm');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset request error:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Reset Failed",
|
||||||
|
description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmReset = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Password Mismatch",
|
||||||
|
description: "Passwords do not match. Please try again.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Password Too Short",
|
||||||
|
description: "Password must be at least 6 characters long.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = getCurrentEndpoint();
|
||||||
|
const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
passwordConfirm
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to reset password');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Password Reset Successful",
|
||||||
|
description: "Your password has been reset successfully. You can now log in with your new password.",
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset form state
|
||||||
|
setStep('request');
|
||||||
|
setEmail('');
|
||||||
|
setToken('');
|
||||||
|
setPassword('');
|
||||||
|
setPasswordConfirm('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset confirmation error:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Reset Failed",
|
||||||
|
description: error instanceof Error ? error.message : "Failed to reset password. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset form state when closing
|
||||||
|
setStep('request');
|
||||||
|
setEmail('');
|
||||||
|
setToken('');
|
||||||
|
setPassword('');
|
||||||
|
setPasswordConfirm('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 'request'
|
||||||
|
? 'Enter your email address and we\'ll send you a reset link.'
|
||||||
|
: 'Enter the reset token from your email and your new password.'
|
||||||
|
}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'request' ? (
|
||||||
|
<form onSubmit={handleRequestReset} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="reset-email"
|
||||||
|
placeholder="your.email@provider.com"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !email}
|
||||||
|
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||||
|
>
|
||||||
|
{loading ? 'Sending...' : 'Send Reset Email'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleConfirmReset} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
|
||||||
|
Reset Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="reset-token"
|
||||||
|
placeholder="Enter token from email"
|
||||||
|
type="text"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStep('request')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !token || !password || !passwordConfirm}
|
||||||
|
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||||
|
>
|
||||||
|
{loading ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import QuickActionsDialog from "./QuickActionsDialog";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
currentUser: AuthUser | null;
|
currentUser: AuthUser | null;
|
||||||
@@ -28,6 +29,7 @@ export const Header = ({
|
|||||||
const [greeting, setGreeting] = useState<string>("");
|
const [greeting, setGreeting] = useState<string>("");
|
||||||
const { systemName } = useSystemSettings();
|
const { systemName } = useSystemSettings();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
|
||||||
|
|
||||||
// Set greeting based on time of day
|
// Set greeting based on time of day
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,8 +90,18 @@ export const Header = ({
|
|||||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
|
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
|
||||||
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
|
{/* Quick Actions Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setQuickActionsOpen(true)}
|
||||||
|
className="mr-2"
|
||||||
|
>
|
||||||
<Grid3x3 className="h-5 w-5 text-green-500" />
|
<Grid3x3 className="h-5 w-5 text-green-500" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨</h1>
|
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,29 +126,52 @@ export const Header = ({
|
|||||||
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
|
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
|
||||||
{t("khmer")}
|
{t("khmer")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
|
||||||
|
{t("Deutsch")}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Documentation */}
|
{/* Documentation */}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => window.open("https://docs.checkcle.io", "_blank")}
|
||||||
|
>
|
||||||
<span className="sr-only">{t("documentation")}</span>
|
<span className="sr-only">{t("documentation")}</span>
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* GitHub */}
|
{/* GitHub */}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => window.open("https://github.com/operacle/checkcle", "_blank")}
|
||||||
|
>
|
||||||
<span className="sr-only">GitHub</span>
|
<span className="sr-only">GitHub</span>
|
||||||
<Github className="w-4 h-4" />
|
<Github className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* X (Twitter) */}
|
{/* X (Twitter) */}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => window.open("https://x.com/tlengoss", "_blank")}
|
||||||
|
>
|
||||||
<span className="sr-only">X (Twitter)</span>
|
<span className="sr-only">X (Twitter)</span>
|
||||||
<Twitter className="w-4 h-4" />
|
<Twitter className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Discord (replaced with MessageSquare) */}
|
{/* Discord */}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => window.open("https://discord.gg/xs9gbubGwX", "_blank")}
|
||||||
|
>
|
||||||
<span className="sr-only">Discord</span>
|
<span className="sr-only">Discord</span>
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -185,6 +220,9 @@ export const Header = ({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions Dialog */}
|
||||||
|
<QuickActionsDialog isOpen={quickActionsOpen} setIsOpen={setQuickActionsOpen} />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogClose
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface QuickActionsDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickActionsDialog: React.FC<QuickActionsDialogProps> = ({ isOpen, setIsOpen }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[800px] bg-background max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogClose className="absolute right-4 top-4">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">{t('quickActions')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('quickActionsDescription')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
{/* Website Monitoring Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('monitorWebsite')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('monitorWebsiteDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('monitorMultipleEndpoints')}</li>
|
||||||
|
<li>{t('trackResponseTimes')}</li>
|
||||||
|
<li>{t('setCustomAlerts')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Monitoring Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('monitorServer')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('monitorServerDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('monitorCPURam')}</li>
|
||||||
|
<li>{t('trackNetworkMetrics')}</li>
|
||||||
|
<li>{t('viewProcesses')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSL Certificate Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('sslCertificate')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('sslCertificateDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('trackSSLExpiration')}</li>
|
||||||
|
<li>{t('getEarlyRenewal')}</li>
|
||||||
|
<li>{t('monitorMultipleDomains')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Incidents Management Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('incidentsManagement')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('incidentsManagementDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('createTrackIncidents')}</li>
|
||||||
|
<li>{t('assignIncidents')}</li>
|
||||||
|
<li>{t('monitorResolution')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operational Status Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('operationalStatus')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('operationalStatusDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('createCustomStatus')}</li>
|
||||||
|
<li>{t('displayRealTime')}</li>
|
||||||
|
<li>{t('shareIncidentUpdates')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reports & Analytics Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('reportsAnalytics')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('reportsAnalyticsDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('generateUptimeReports')}</li>
|
||||||
|
<li>{t('analyzePerformance')}</li>
|
||||||
|
<li>{t('exportMonitoringData')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||||
|
{t('close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuickActionsDialog;
|
||||||
@@ -1,141 +1,22 @@
|
|||||||
|
|
||||||
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, Settings, User, UserCog, Bell, FileClock, Database, RefreshCw, Info, ChevronDown, BookOpen } from "lucide-react";
|
import React from "react";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { SidebarHeader } from "./sidebar/SidebarHeader";
|
||||||
import { useState, useEffect } from "react";
|
import { MainNavigation } from "./sidebar/MainNavigation";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { SettingsPanel } from "./sidebar/SettingsPanel";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar = ({
|
export const Sidebar = ({ collapsed }: SidebarProps) => {
|
||||||
collapsed
|
const { theme } = useTheme();
|
||||||
}: SidebarProps) => {
|
|
||||||
const {
|
|
||||||
theme
|
|
||||||
} = useTheme();
|
|
||||||
const {
|
|
||||||
t
|
|
||||||
} = useLanguage();
|
|
||||||
const location = useLocation();
|
|
||||||
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
|
|
||||||
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
|
||||||
|
|
||||||
// Update active settings item based on URL
|
return (
|
||||||
useEffect(() => {
|
<div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}>
|
||||||
if (location.pathname === '/settings') {
|
<SidebarHeader collapsed={collapsed} />
|
||||||
const params = new URLSearchParams(location.search);
|
<MainNavigation collapsed={collapsed} />
|
||||||
const panel = params.get('panel');
|
<SettingsPanel collapsed={collapsed} />
|
||||||
if (panel) {
|
|
||||||
setActiveSettingsItem(panel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
const handleSettingsItemClick = (item: string) => {
|
|
||||||
setActiveSettingsItem(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMenuItemClasses = (isActive: boolean) => {
|
|
||||||
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// New larger icon size for the main menu
|
|
||||||
const mainIconSize = "h-6 w-6";
|
|
||||||
|
|
||||||
return <div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}>
|
|
||||||
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
|
|
||||||
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
|
|
||||||
<span className="text-white font-bold">C</span>
|
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
);
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="my-2 mx-1 py-1 px-1">
|
|
||||||
<Link to="/dashboard" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/dashboard' ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
|
||||||
<Globe className={`${mainIconSize} text-purple-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("uptimeMonitoring")}</span>}
|
|
||||||
</Link>
|
|
||||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
|
||||||
<Boxes className={`${mainIconSize} text-blue-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
|
|
||||||
</div>
|
|
||||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
|
||||||
<Radar className={`${mainIconSize} text-cyan-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
|
|
||||||
</div>
|
|
||||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
|
||||||
<Calendar className={`${mainIconSize} text-emerald-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
|
|
||||||
</div>
|
|
||||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
|
||||||
<BarChart2 className={`${mainIconSize} text-amber-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
|
|
||||||
</div>
|
|
||||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
|
||||||
<LineChart className={`${mainIconSize} text-rose-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("reports")}</span>}
|
|
||||||
</div>
|
|
||||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
|
||||||
<FileText className={`${mainIconSize} text-indigo-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("apiDocumentation")}</span>}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{!collapsed && <div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
|
|
||||||
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
|
|
||||||
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Settings className="h-4 w-4 mr-1" />
|
|
||||||
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
|
|
||||||
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="space-y-2 pr-4">
|
|
||||||
<Link to={`/settings?panel=general`} className={getMenuItemClasses(activeSettingsItem === 'general')} onClick={() => handleSettingsItemClick('general')}>
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("generalSettings")}</span>
|
|
||||||
</Link>
|
|
||||||
<Link to={`/settings?panel=users`} className={getMenuItemClasses(activeSettingsItem === 'users')} onClick={() => handleSettingsItemClick('users')}>
|
|
||||||
<User className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("userManagement")}</span>
|
|
||||||
</Link>
|
|
||||||
<Link to={`/settings?panel=notifications`} className={getMenuItemClasses(activeSettingsItem === 'notifications')} onClick={() => handleSettingsItemClick('notifications')}>
|
|
||||||
<Bell className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("notificationSettings")}</span>
|
|
||||||
</Link>
|
|
||||||
<Link to={`/settings?panel=templates`} className={getMenuItemClasses(activeSettingsItem === 'templates')} onClick={() => handleSettingsItemClick('templates')}>
|
|
||||||
<BookOpen className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("alertsTemplates")}</span>
|
|
||||||
</Link>
|
|
||||||
<div className={getMenuItemClasses(false)}>
|
|
||||||
<Database className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("dataRetention")}</span>
|
|
||||||
</div>
|
|
||||||
<Link to={`/settings?panel=about`} className={getMenuItemClasses(activeSettingsItem === 'about')} onClick={() => handleSettingsItemClick('about')}>
|
|
||||||
<Info className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("aboutSystem")}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
{collapsed && <div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
|
|
||||||
<Link to="/settings">
|
|
||||||
<Settings className={`${mainIconSize} text-purple-400`} />
|
|
||||||
</Link>
|
|
||||||
</div>}
|
|
||||||
</div>;
|
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { MenuItem } from "./MenuItem";
|
||||||
|
import { mainMenuItems } from "./navigationData";
|
||||||
|
|
||||||
|
interface MainNavigationProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) => {
|
||||||
|
return (
|
||||||
|
<nav className="my-2 mx-1 py-1 px-1">
|
||||||
|
{mainMenuItems.map((item) => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
path={item.path}
|
||||||
|
icon={item.icon}
|
||||||
|
translationKey={item.translationKey}
|
||||||
|
color={item.color}
|
||||||
|
hasNavigation={item.hasNavigation}
|
||||||
|
collapsed={collapsed}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface MenuItemProps {
|
||||||
|
id: string;
|
||||||
|
path: string | null;
|
||||||
|
icon: LucideIcon;
|
||||||
|
translationKey: string;
|
||||||
|
color: string;
|
||||||
|
hasNavigation: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuItem: React.FC<MenuItemProps> = ({
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
icon: Icon,
|
||||||
|
translationKey,
|
||||||
|
color,
|
||||||
|
hasNavigation,
|
||||||
|
collapsed
|
||||||
|
}) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (hasNavigation && path) {
|
||||||
|
// Use navigate instead of window.location to prevent full page reload
|
||||||
|
navigate(path, { replace: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = path && location.pathname === path;
|
||||||
|
const mainIconSize = "h-6 w-6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${isActive ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200 cursor-pointer`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<Icon className={`${mainIconSize} ${color}`} />
|
||||||
|
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t(translationKey)}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
import { Settings, ChevronDown } from "lucide-react";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { settingsMenuItems } from "./navigationData";
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
|
||||||
|
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
||||||
|
|
||||||
|
// Update active settings item based on URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname === '/settings') {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const panel = params.get('panel');
|
||||||
|
if (panel) {
|
||||||
|
setActiveSettingsItem(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
const handleSettingsItemClick = (item: string) => {
|
||||||
|
setActiveSettingsItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuItemClick = (path: string, event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
// Use navigate instead of window.location to prevent full page reload
|
||||||
|
navigate(path, { replace: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuItemClasses = (isActive: boolean) => {
|
||||||
|
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
const mainIconSize = "h-6 w-6";
|
||||||
|
return (
|
||||||
|
<div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
|
||||||
|
<div
|
||||||
|
onClick={(e) => handleMenuItemClick('/settings', e)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Settings className={`${mainIconSize} text-purple-400`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
|
||||||
|
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
|
||||||
|
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Settings className="h-4 w-4 mr-1" />
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
|
||||||
|
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-2 pr-4">
|
||||||
|
{settingsMenuItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={getMenuItemClasses(activeSettingsItem === item.id)}
|
||||||
|
onClick={(e) => {
|
||||||
|
handleMenuItemClick(`/settings?panel=${item.id}`, e);
|
||||||
|
handleSettingsItemClick(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 mr-2" />
|
||||||
|
<span className="text-sm">{t(item.translationKey)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
interface SidebarHeaderProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
|
||||||
|
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
|
||||||
|
<span className="text-white font-bold">C</span>
|
||||||
|
</div>
|
||||||
|
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export { SidebarHeader } from './SidebarHeader';
|
||||||
|
export { MainNavigation } from './MainNavigation';
|
||||||
|
export { MenuItem } from './MenuItem';
|
||||||
|
export { SettingsPanel } from './SettingsPanel';
|
||||||
|
export { mainMenuItems, settingsMenuItems } from './navigationData';
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
|
||||||
|
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, 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: null,
|
||||||
|
icon: Boxes,
|
||||||
|
translationKey: 'instanceMonitoring',
|
||||||
|
color: 'text-blue-400',
|
||||||
|
hasNavigation: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssl-domain',
|
||||||
|
path: '/ssl-domain',
|
||||||
|
icon: Radar,
|
||||||
|
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: 'reports',
|
||||||
|
path: null,
|
||||||
|
icon: LineChart,
|
||||||
|
translationKey: 'reports',
|
||||||
|
color: 'text-rose-400',
|
||||||
|
hasNavigation: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'api-documentation',
|
||||||
|
path: null,
|
||||||
|
icon: FileText,
|
||||||
|
translationKey: 'apiDocumentation',
|
||||||
|
color: 'text-indigo-400',
|
||||||
|
hasNavigation: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const settingsMenuItems = [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
icon: Settings,
|
||||||
|
translationKey: 'generalSettings'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
icon: User,
|
||||||
|
translationKey: 'userManagement'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notifications',
|
||||||
|
icon: Bell,
|
||||||
|
translationKey: 'notificationSettings'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'templates',
|
||||||
|
icon: BookOpen,
|
||||||
|
translationKey: 'alertsTemplates'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'data-retention',
|
||||||
|
icon: Database,
|
||||||
|
translationKey: 'dataRetention'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
icon: Info,
|
||||||
|
translationKey: 'aboutSystem'
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
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, Shield, AlertTriangle } from 'lucide-react';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { serviceService } from '@/services/serviceService';
|
||||||
|
|
||||||
|
interface ComponentsSelectorProps {
|
||||||
|
selectedComponents: Partial<StatusPageComponentRecord>[];
|
||||||
|
onComponentsChange: (components: Partial<StatusPageComponentRecord>[]) => void;
|
||||||
|
onComponentDelete?: (componentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentTypes = [
|
||||||
|
{ value: 'uptime', label: 'Uptime Service', icon: Server },
|
||||||
|
{ value: 'ssl', label: 'SSL Certificate', icon: Shield },
|
||||||
|
{ value: 'incident', label: 'Incident Monitoring', icon: AlertTriangle },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [newComponent, setNewComponent] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
service_id: '',
|
||||||
|
server_id: '',
|
||||||
|
display_order: selectedComponents.length + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch uptime services for the dropdown
|
||||||
|
const { data: services = [] } = useQuery({
|
||||||
|
queryKey: ['services'],
|
||||||
|
queryFn: serviceService.getServices,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addComponent = () => {
|
||||||
|
if (!newComponent.name.trim()) return;
|
||||||
|
|
||||||
|
const component: Partial<StatusPageComponentRecord> = {
|
||||||
|
...newComponent,
|
||||||
|
operational_status_id: '', // Will be set when page is created
|
||||||
|
};
|
||||||
|
|
||||||
|
onComponentsChange([...selectedComponents, component]);
|
||||||
|
setNewComponent({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
service_id: '',
|
||||||
|
server_id: '',
|
||||||
|
display_order: selectedComponents.length + 2,
|
||||||
|
});
|
||||||
|
setShowAddForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeComponent = async (index: number) => {
|
||||||
|
const component = selectedComponents[index];
|
||||||
|
|
||||||
|
// If component has an ID, it exists in database and needs to be deleted
|
||||||
|
if (component.id && onComponentDelete) {
|
||||||
|
await onComponentDelete(component.id);
|
||||||
|
} else {
|
||||||
|
// For new components not yet saved, just remove from local state
|
||||||
|
const updated = selectedComponents.filter((_, i) => i !== index);
|
||||||
|
onComponentsChange(updated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
Status Page Components
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add monitoring components like uptime services, SSL certificates, and incident tracking
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{selectedComponents.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Selected Components</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedComponents.map((component, index) => (
|
||||||
|
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{component.name}</div>
|
||||||
|
{component.description && (
|
||||||
|
<div className="text-sm text-muted-foreground">{component.description}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
{component.service_id && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Service: {services.find(s => s.id === component.service_id)?.name || component.service_id}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{component.server_id && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Server: {component.server_id}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeComponent(index)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showAddForm ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Component
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="component-name">Component Name</Label>
|
||||||
|
<Input
|
||||||
|
id="component-name"
|
||||||
|
placeholder="e.g., Main Website"
|
||||||
|
value={newComponent.name}
|
||||||
|
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="display-order">Display Order</Label>
|
||||||
|
<Input
|
||||||
|
id="display-order"
|
||||||
|
type="number"
|
||||||
|
value={newComponent.display_order}
|
||||||
|
onChange={(e) => setNewComponent({ ...newComponent, display_order: parseInt(e.target.value) || 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="component-description">Description (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="component-description"
|
||||||
|
placeholder="Brief description of this component"
|
||||||
|
value={newComponent.description}
|
||||||
|
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-id">Uptime Service (Optional)</Label>
|
||||||
|
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an uptime service" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-50 bg-white border shadow-lg">
|
||||||
|
{services.map((service) => (
|
||||||
|
<SelectItem key={service.id} value={service.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
service.status === 'up' ? 'bg-green-500' :
|
||||||
|
service.status === 'down' ? 'bg-red-500' :
|
||||||
|
'bg-yellow-500'
|
||||||
|
}`} />
|
||||||
|
{service.name}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="server-id">Server ID (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="server-id"
|
||||||
|
placeholder="server_456"
|
||||||
|
value={newComponent.server_id}
|
||||||
|
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
|
||||||
|
Add Component
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setShowAddForm(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Label>Quick Add Templates</Label>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-2">
|
||||||
|
{componentTypes.map((type) => {
|
||||||
|
const Icon = type.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={type.value}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const component: Partial<StatusPageComponentRecord> = {
|
||||||
|
name: type.label,
|
||||||
|
description: `Monitor ${type.label.toLowerCase()}`,
|
||||||
|
service_id: '',
|
||||||
|
server_id: '',
|
||||||
|
display_order: selectedComponents.length + 1,
|
||||||
|
operational_status_id: '',
|
||||||
|
};
|
||||||
|
onComponentsChange([...selectedComponents, component]);
|
||||||
|
}}
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 mr-2" />
|
||||||
|
{type.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useCreateOperationalPage } from '@/hooks/useOperationalPage';
|
||||||
|
import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||||
|
import { ComponentsSelector } from './ComponentsSelector';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required'),
|
||||||
|
description: z.string().min(1, 'Description is required'),
|
||||||
|
slug: z.string().min(1, 'Slug is required'),
|
||||||
|
theme: z.string().min(1, 'Theme is required'),
|
||||||
|
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||||
|
is_public: z.boolean(),
|
||||||
|
logo_url: z.string().optional(),
|
||||||
|
custom_domain: z.string().optional(),
|
||||||
|
custom_css: z.string().optional(),
|
||||||
|
page_style: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export const CreateOperationalPageDialog = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||||
|
const createMutation = useCreateOperationalPage();
|
||||||
|
const createComponentMutation = useCreateStatusPageComponent();
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
slug: '',
|
||||||
|
theme: 'default',
|
||||||
|
status: 'operational',
|
||||||
|
is_public: true,
|
||||||
|
logo_url: '',
|
||||||
|
custom_domain: '',
|
||||||
|
custom_css: '',
|
||||||
|
page_style: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
slug: data.slug,
|
||||||
|
theme: data.theme,
|
||||||
|
status: data.status,
|
||||||
|
is_public: data.is_public ? 'true' : 'false',
|
||||||
|
logo_url: data.logo_url || '',
|
||||||
|
custom_domain: data.custom_domain || '',
|
||||||
|
custom_css: data.custom_css || '',
|
||||||
|
page_style: data.page_style || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Creating operational page with payload:', payload);
|
||||||
|
const createdPage = await createMutation.mutateAsync(payload);
|
||||||
|
console.log('Created page:', createdPage);
|
||||||
|
|
||||||
|
// Create components after page is created
|
||||||
|
if (selectedComponents.length > 0) {
|
||||||
|
console.log('Creating components for page:', createdPage.id);
|
||||||
|
for (const component of selectedComponents) {
|
||||||
|
const componentPayload = {
|
||||||
|
operational_status_id: createdPage.id,
|
||||||
|
name: component.name || '',
|
||||||
|
description: component.description || '',
|
||||||
|
service_id: component.service_id || '',
|
||||||
|
server_id: component.server_id || '',
|
||||||
|
display_order: component.display_order || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Creating component with payload:', componentPayload);
|
||||||
|
await createComponentMutation.mutateAsync(componentPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
setSelectedComponents([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating operational page:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Page
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Operational Page</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new operational status page to monitor your services and components.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My Service Status" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Slug</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-service-status" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="A brief description of your operational page"
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="theme"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Theme</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Initial Status</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="operational">Operational</SelectItem>
|
||||||
|
<SelectItem value="degraded">Degraded Performance</SelectItem>
|
||||||
|
<SelectItem value="maintenance">Under Maintenance</SelectItem>
|
||||||
|
<SelectItem value="major_outage">Major Outage</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="is_public"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Public Page</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Make this page publicly accessible
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="custom_domain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Custom Domain (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="status.yourdomain.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Custom domain for your status page
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComponentsSelector
|
||||||
|
selectedComponents={selectedComponents}
|
||||||
|
onComponentsChange={setSelectedComponents}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending || createComponentMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending || createComponentMutation.isPending ? 'Creating...' : 'Create Page'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useUpdateOperationalPage } from '@/hooks/useOperationalPage';
|
||||||
|
import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, useDeleteStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||||
|
import { ComponentsSelector } from './ComponentsSelector';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required'),
|
||||||
|
description: z.string().min(1, 'Description is required'),
|
||||||
|
slug: z.string().min(1, 'Slug is required'),
|
||||||
|
theme: z.string().min(1, 'Theme is required'),
|
||||||
|
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||||
|
is_public: z.boolean(),
|
||||||
|
logo_url: z.string().optional(),
|
||||||
|
custom_domain: z.string().optional(),
|
||||||
|
custom_css: z.string().optional(),
|
||||||
|
page_style: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface EditOperationalPageDialogProps {
|
||||||
|
page: OperationalPageRecord | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
|
||||||
|
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||||
|
const [existingComponents, setExistingComponents] = useState<StatusPageComponentRecord[]>([]);
|
||||||
|
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
|
||||||
|
const updateMutation = useUpdateOperationalPage();
|
||||||
|
const createComponentMutation = useCreateStatusPageComponent();
|
||||||
|
const deleteComponentMutation = useDeleteStatusPageComponent();
|
||||||
|
|
||||||
|
// Fetch existing components for this operational page
|
||||||
|
const { data: components = [] } = useStatusPageComponentsByOperationalId(page?.id || '');
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
slug: '',
|
||||||
|
theme: 'default',
|
||||||
|
status: 'operational',
|
||||||
|
is_public: true,
|
||||||
|
logo_url: '',
|
||||||
|
custom_domain: '',
|
||||||
|
custom_css: '',
|
||||||
|
page_style: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page) {
|
||||||
|
form.reset({
|
||||||
|
title: page.title,
|
||||||
|
description: page.description,
|
||||||
|
slug: page.slug,
|
||||||
|
theme: page.theme,
|
||||||
|
status: page.status,
|
||||||
|
is_public: page.is_public === 'true',
|
||||||
|
logo_url: page.logo_url || '',
|
||||||
|
custom_domain: page.custom_domain || '',
|
||||||
|
custom_css: page.custom_css || '',
|
||||||
|
page_style: page.page_style || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [page, form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (components && components.length > 0) {
|
||||||
|
console.log('Loading existing components:', components);
|
||||||
|
setExistingComponents(components);
|
||||||
|
// Convert existing components to the format expected by ComponentsSelector
|
||||||
|
const existingComponentsForSelector = components.map(comp => ({
|
||||||
|
id: comp.id,
|
||||||
|
name: comp.name,
|
||||||
|
description: comp.description,
|
||||||
|
service_id: comp.service_id,
|
||||||
|
server_id: comp.server_id,
|
||||||
|
display_order: comp.display_order,
|
||||||
|
operational_status_id: comp.operational_status_id,
|
||||||
|
}));
|
||||||
|
setSelectedComponents(existingComponentsForSelector);
|
||||||
|
} else {
|
||||||
|
setExistingComponents([]);
|
||||||
|
setSelectedComponents([]);
|
||||||
|
}
|
||||||
|
}, [components]);
|
||||||
|
|
||||||
|
const handleComponentDelete = async (componentId: string) => {
|
||||||
|
try {
|
||||||
|
console.log('Deleting component:', componentId);
|
||||||
|
await deleteComponentMutation.mutateAsync(componentId);
|
||||||
|
|
||||||
|
// Update local state to remove the deleted component
|
||||||
|
setSelectedComponents(prev => prev.filter(comp => comp.id !== componentId));
|
||||||
|
setExistingComponents(prev => prev.filter(comp => comp.id !== componentId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting component:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsFormSubmitting(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
slug: data.slug,
|
||||||
|
theme: data.theme,
|
||||||
|
status: data.status,
|
||||||
|
is_public: data.is_public ? 'true' : 'false',
|
||||||
|
logo_url: data.logo_url || '',
|
||||||
|
custom_domain: data.custom_domain || '',
|
||||||
|
custom_css: data.custom_css || '',
|
||||||
|
page_style: data.page_style || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Updating operational page with payload:', payload);
|
||||||
|
await updateMutation.mutateAsync({ id: page.id, data: payload });
|
||||||
|
|
||||||
|
// Handle component changes
|
||||||
|
const currentComponentIds = existingComponents.map(c => c.id);
|
||||||
|
const newComponentsToCreate = selectedComponents.filter(comp => !comp.id);
|
||||||
|
const componentsToKeep = selectedComponents.filter(comp => comp.id && currentComponentIds.includes(comp.id));
|
||||||
|
const componentsToDelete = existingComponents.filter(comp => !selectedComponents.some(selected => selected.id === comp.id));
|
||||||
|
|
||||||
|
// Delete removed components (only if not already deleted via handleComponentDelete)
|
||||||
|
for (const component of componentsToDelete) {
|
||||||
|
if (selectedComponents.some(selected => selected.id === component.id)) {
|
||||||
|
continue; // Skip if already handled by handleComponentDelete
|
||||||
|
}
|
||||||
|
console.log('Deleting component during save:', component.id);
|
||||||
|
await deleteComponentMutation.mutateAsync(component.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new components
|
||||||
|
for (const component of newComponentsToCreate) {
|
||||||
|
const componentPayload = {
|
||||||
|
operational_status_id: page.id,
|
||||||
|
name: component.name || '',
|
||||||
|
description: component.description || '',
|
||||||
|
service_id: component.service_id || '',
|
||||||
|
server_id: component.server_id || '',
|
||||||
|
display_order: component.display_order || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Creating component with payload:', componentPayload);
|
||||||
|
await createComponentMutation.mutateAsync(componentPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating operational page:', error);
|
||||||
|
} finally {
|
||||||
|
setIsFormSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Operational Page</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update your operational status page settings and manage components.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My Service Status" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Slug</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-service-status" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="A brief description of your operational page"
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="theme"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Theme</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Status</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="operational">Operational</SelectItem>
|
||||||
|
<SelectItem value="degraded">Degraded Performance</SelectItem>
|
||||||
|
<SelectItem value="maintenance">Under Maintenance</SelectItem>
|
||||||
|
<SelectItem value="major_outage">Major Outage</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="is_public"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Public Page</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Make this page publicly accessible
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="custom_domain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Custom Domain (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="status.yourdomain.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Custom domain for your status page
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComponentsSelector
|
||||||
|
selectedComponents={selectedComponents}
|
||||||
|
onComponentsChange={setSelectedComponents}
|
||||||
|
onComponentDelete={handleComponentDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
|
||||||
|
>
|
||||||
|
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? 'Updating...' : 'Update Page'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { StatusBadge } from './StatusBadge';
|
||||||
|
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface OperationalPageCardProps {
|
||||||
|
page: OperationalPageRecord;
|
||||||
|
onEdit?: (page: OperationalPageRecord) => void;
|
||||||
|
onView?: (page: OperationalPageRecord) => void;
|
||||||
|
onDelete?: (page: OperationalPageRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card className="hover:shadow-lg transition-shadow duration-200">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-lg font-semibold mb-1">{page.title}</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-muted-foreground">
|
||||||
|
{page.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={page.status} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Slug:</span>
|
||||||
|
<p className="mt-1">{page.slug}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Theme:</span>
|
||||||
|
<p className="mt-1 capitalize">{page.theme}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Public:</span>
|
||||||
|
<p className="mt-1">
|
||||||
|
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
|
||||||
|
{page.is_public === 'true' ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Updated:</span>
|
||||||
|
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{page.custom_domain && (
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{page.custom_domain}</span>
|
||||||
|
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
{onView && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onView(page)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(page)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(page)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useOperationalPages, useDeleteOperationalPage } from '@/hooks/useOperationalPage';
|
||||||
|
import { CreateOperationalPageDialog } from './CreateOperationalPageDialog';
|
||||||
|
import { EditOperationalPageDialog } from './EditOperationalPageDialog';
|
||||||
|
import { OperationalPageCard } from './OperationalPageCard';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { Activity, Plus, RefreshCw } from 'lucide-react';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
|
export const OperationalPageContent = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
|
||||||
|
const deleteMutation = useDeleteOperationalPage();
|
||||||
|
|
||||||
|
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(null);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [pageToDelete, setPageToDelete] = useState<OperationalPageRecord | null>(null);
|
||||||
|
|
||||||
|
const handleEdit = (page: OperationalPageRecord) => {
|
||||||
|
setEditingPage(page);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (page: OperationalPageRecord) => {
|
||||||
|
if (page.custom_domain) {
|
||||||
|
window.open(`https://${page.custom_domain}`, '_blank');
|
||||||
|
} else {
|
||||||
|
// Navigate to the public status page route
|
||||||
|
window.open(`/status/${page.slug}`, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (page: OperationalPageRecord) => {
|
||||||
|
setPageToDelete(page);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (pageToDelete) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(pageToDelete.id);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setPageToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting page:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Failed to load operational pages</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
There was an error loading your operational pages. Please try again.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight mb-2">Operational Pages</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your public status pages and monitor service health
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-4 sm:mt-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isRefetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<CreateOperationalPageDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<div className="p-6">
|
||||||
|
<Skeleton className="h-6 w-3/4 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-full mb-4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Skeleton className="h-8 flex-1" />
|
||||||
|
<Skeleton className="h-8 flex-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!isLoading && (!pages || pages.length === 0) && (
|
||||||
|
<Card className="p-12">
|
||||||
|
<CardContent className="text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No operational pages found</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Create your first operational page to start monitoring your services and communicate status to your users.
|
||||||
|
</p>
|
||||||
|
<CreateOperationalPageDialog />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pages Grid */}
|
||||||
|
{!isLoading && pages && pages.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{pages.map((page) => (
|
||||||
|
<OperationalPageCard
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onView={handleView}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Footer */}
|
||||||
|
{!isLoading && pages && pages.length > 0 && (
|
||||||
|
<div className="mt-8 pt-6 border-t">
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Total Pages:</span> {pages.length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Public Pages:</span>{' '}
|
||||||
|
{pages.filter(p => p.is_public === 'true').length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Operational:</span>{' '}
|
||||||
|
{pages.filter(p => p.status === 'operational').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<EditOperationalPageDialog
|
||||||
|
page={editingPage}
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{pageToDelete?.title}"? This action cannot be undone and will permanently remove the operational page and all its components.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: OperationalPageRecord['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBadge = ({ status }: StatusBadgeProps) => {
|
||||||
|
const getStatusConfig = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return {
|
||||||
|
label: 'Operational',
|
||||||
|
className: 'bg-green-100 text-green-800 hover:bg-green-200',
|
||||||
|
};
|
||||||
|
case 'degraded':
|
||||||
|
return {
|
||||||
|
label: 'Degraded Performance',
|
||||||
|
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
|
||||||
|
};
|
||||||
|
case 'maintenance':
|
||||||
|
return {
|
||||||
|
label: 'Under Maintenance',
|
||||||
|
className: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
|
||||||
|
};
|
||||||
|
case 'major_outage':
|
||||||
|
return {
|
||||||
|
label: 'Major Outage',
|
||||||
|
className: 'bg-red-100 text-red-800 hover:bg-red-200',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: 'Unknown',
|
||||||
|
className: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className={config.className}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -10,6 +9,7 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { authService } from "@/services/authService";
|
import { authService } from "@/services/authService";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
// Password change form schema
|
// Password change form schema
|
||||||
const passwordFormSchema = z.object({
|
const passwordFormSchema = z.object({
|
||||||
@@ -34,6 +34,7 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
|||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const form = useForm<PasswordFormValues>({
|
const form = useForm<PasswordFormValues>({
|
||||||
resolver: zodResolver(passwordFormSchema),
|
resolver: zodResolver(passwordFormSchema),
|
||||||
@@ -44,12 +45,35 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Function to determine which collection the user belongs to
|
||||||
|
const getUserCollection = async (userId: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
// First try to find the user in the regular users collection
|
||||||
|
await pb.collection('users').getOne(userId);
|
||||||
|
return 'users';
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
// If not found, try the superadmin collection
|
||||||
|
await pb.collection('_superusers').getOne(userId);
|
||||||
|
return '_superusers';
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('User not found in any collection');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function onSubmit(data: PasswordFormValues) {
|
async function onSubmit(data: PasswordFormValues) {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("Starting password change for user:", userId);
|
||||||
|
|
||||||
|
// Determine which collection the user belongs to
|
||||||
|
const collection = await getUserCollection(userId);
|
||||||
|
console.log("User found in collection:", collection);
|
||||||
|
|
||||||
// PocketBase requires the old password along with the new one
|
// PocketBase requires the old password along with the new one
|
||||||
await pb.collection('users').update(userId, {
|
await pb.collection(collection).update(userId, {
|
||||||
oldPassword: data.currentPassword,
|
oldPassword: data.currentPassword,
|
||||||
password: data.newPassword,
|
password: data.newPassword,
|
||||||
passwordConfirm: data.confirmPassword,
|
passwordConfirm: data.confirmPassword,
|
||||||
@@ -60,18 +84,32 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Password updated",
|
title: "Password updated",
|
||||||
description: "Your password has been changed successfully.",
|
description: "Your password has been changed successfully. You will be logged out in 3 seconds for security.",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset the form
|
// Reset the form
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
|
// Auto logout after successful password change
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log("Auto logout after password change");
|
||||||
|
authService.logout();
|
||||||
|
navigate("/login");
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Password change error:", error);
|
console.error("Password change error:", error);
|
||||||
|
|
||||||
let errorMessage = "Failed to update password. Please try again.";
|
let errorMessage = "Failed to update password. Please try again.";
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes("Failed to authenticate")) {
|
||||||
|
errorMessage = "Current password is incorrect. Please try again.";
|
||||||
|
} else if (error.message.includes("User not found")) {
|
||||||
|
errorMessage = "User account not found. Please contact your administrator.";
|
||||||
|
} else {
|
||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Password change failed",
|
title: "Password change failed",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { User } from "@/services/userService";
|
import { User } from "@/services/userService";
|
||||||
import { UserProfileDetails } from "./UserProfileDetails";
|
import { UserProfileDetails } from "./UserProfileDetails";
|
||||||
@@ -9,11 +9,19 @@ import { UpdateProfileForm } from "./UpdateProfileForm";
|
|||||||
|
|
||||||
interface ProfileContentProps {
|
interface ProfileContentProps {
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
|
onUserUpdated?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileContent({ currentUser }: ProfileContentProps) {
|
export function ProfileContent({ currentUser, onUserUpdated }: ProfileContentProps) {
|
||||||
const [activeTab, setActiveTab] = useState("details");
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
|
|
||||||
|
// When active tab changes, refresh user data if needed
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "details" && onUserUpdated) {
|
||||||
|
onUserUpdated();
|
||||||
|
}
|
||||||
|
}, [activeTab, onUserUpdated]);
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { authService } from "@/services/authService";
|
import { authService } from "@/services/authService";
|
||||||
|
import { AlertCircle, CheckCircle } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
// Profile update form schema
|
// Profile update form schema
|
||||||
const profileFormSchema = z.object({
|
const profileFormSchema = z.object({
|
||||||
@@ -31,7 +34,10 @@ interface UpdateProfileFormProps {
|
|||||||
|
|
||||||
export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
const [updateSuccess, setUpdateSuccess] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Initialize the form with current user data
|
// Initialize the form with current user data
|
||||||
const form = useForm<ProfileFormValues>({
|
const form = useForm<ProfileFormValues>({
|
||||||
@@ -45,21 +51,55 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
|
|
||||||
async function onSubmit(data: ProfileFormValues) {
|
async function onSubmit(data: ProfileFormValues) {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
setUpdateError(null);
|
||||||
|
setUpdateSuccess(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await userService.updateUser(user.id, {
|
console.log("Submitting profile update with data:", data);
|
||||||
|
|
||||||
|
// Detect if email is being changed
|
||||||
|
const isEmailChanged = data.email !== user.email;
|
||||||
|
|
||||||
|
// Create update payload with all fields
|
||||||
|
const updateData = {
|
||||||
full_name: data.full_name,
|
full_name: data.full_name,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
email: data.email,
|
// Only include email if it's changed
|
||||||
|
email: isEmailChanged ? data.email : undefined,
|
||||||
|
// Always set emailVisibility to true if email is changing
|
||||||
|
emailVisibility: isEmailChanged ? true : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending update payload:", updateData);
|
||||||
|
|
||||||
|
// Update user data using the userService
|
||||||
|
await userService.updateUser(user.id, updateData);
|
||||||
|
|
||||||
|
// If email was changed, show success message and auto-logout
|
||||||
|
if (isEmailChanged) {
|
||||||
|
setUpdateSuccess("Email changed successfully! You will be logged out for security reasons. Please log in again with your new email.");
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Email changed successfully",
|
||||||
|
description: "You will be logged out for security reasons. Please log in again with your new email.",
|
||||||
|
variant: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh user data in auth context
|
// Auto-logout after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
authService.logout();
|
||||||
|
navigate("/login");
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
// Refresh user data in auth context for other field changes
|
||||||
await authService.refreshUserData();
|
await authService.refreshUserData();
|
||||||
|
|
||||||
|
setUpdateSuccess("Your profile information has been updated successfully.");
|
||||||
toast({
|
toast({
|
||||||
title: "Profile updated",
|
title: "Profile updated",
|
||||||
description: "Your profile information has been updated successfully.",
|
description: "Your profile information has been updated successfully.",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Profile update error:", error);
|
console.error("Profile update error:", error);
|
||||||
|
|
||||||
@@ -68,6 +108,8 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setUpdateError(errorMessage);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Update failed",
|
title: "Update failed",
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
@@ -81,6 +123,22 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{updateError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{updateError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateSuccess && (
|
||||||
|
<Alert className="bg-green-50 border-green-200 text-green-800">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription>
|
||||||
|
{updateSuccess}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="full_name"
|
name="full_name"
|
||||||
@@ -119,6 +177,11 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
<Input type="email" placeholder="your.email@example.com" {...field} />
|
<Input type="email" placeholder="your.email@example.com" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
{field.value !== user.email && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Changing your email will log you out for security reasons. You will need to log in again with your new email.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
|
|||||||
return user.username[0].toUpperCase();
|
return user.username[0].toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Determine if the user is active based on the status field
|
||||||
|
const isActive = user.status === "Active";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<Avatar className="h-32 w-32">
|
<Avatar className="h-32 w-32">
|
||||||
@@ -54,8 +57,8 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
|
|||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant={user.isActive ? "default" : "outline"} className="px-2 py-1">
|
<Badge variant={isActive ? "default" : "outline"} className="px-2 py-1">
|
||||||
{user.isActive ? "Active" : "Inactive"}
|
{user.status || (isActive ? "Active" : "Inactive")}
|
||||||
</Badge>
|
</Badge>
|
||||||
{user.verified && (
|
{user.verified && (
|
||||||
<Badge className="bg-green-600 hover:bg-green-700 px-2 py-1">
|
<Badge className="bg-green-600 hover:bg-green-700 px-2 py-1">
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Server, CheckCircle, XCircle, AlertTriangle, Pause, Clock } from 'lucide-react';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { Service, UptimeData } from '@/types/service.types';
|
||||||
|
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface ComponentsStatusSectionProps {
|
||||||
|
components: StatusPageComponentRecord[];
|
||||||
|
services: Service[];
|
||||||
|
uptimeData: Record<string, UptimeData[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComponentsStatusSection = ({ components, services, uptimeData }: ComponentsStatusSectionProps) => {
|
||||||
|
const getServiceForComponent = (component: StatusPageComponentRecord) => {
|
||||||
|
return services.find(service => service.id === component.service_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getComponentStatus = (component: StatusPageComponentRecord) => {
|
||||||
|
const service = getServiceForComponent(component);
|
||||||
|
return service?.status || 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUptimePercentage = (serviceId: string) => {
|
||||||
|
const history = uptimeData[serviceId] || [];
|
||||||
|
if (history.length === 0) return 100;
|
||||||
|
|
||||||
|
const upCount = history.filter(record => record.status === 'up').length;
|
||||||
|
return Math.round((upCount / history.length) * 100 * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
|
case 'down':
|
||||||
|
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||||
|
case 'warning':
|
||||||
|
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||||
|
case 'paused':
|
||||||
|
return <Pause className="h-5 w-5 text-gray-500" />;
|
||||||
|
default:
|
||||||
|
return <Server className="h-5 w-5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return (
|
||||||
|
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-200 dark:border-green-800">
|
||||||
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
|
Operational
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case 'down':
|
||||||
|
return (
|
||||||
|
<Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-red-200 dark:border-red-800">
|
||||||
|
<XCircle className="h-3 w-3 mr-1" />
|
||||||
|
Down
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case 'warning':
|
||||||
|
return (
|
||||||
|
<Badge className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800">
|
||||||
|
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||||
|
Degraded
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case 'paused':
|
||||||
|
return (
|
||||||
|
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200 border-gray-200 dark:border-gray-800">
|
||||||
|
<Pause className="h-3 w-3 mr-1" />
|
||||||
|
Maintenance
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||||
|
Unknown
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusDotColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return 'bg-green-500';
|
||||||
|
case 'down':
|
||||||
|
return 'bg-red-500';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-yellow-500';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (components.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="mb-8 bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
System Components
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[
|
||||||
|
{ name: 'API Services', description: 'Core API endpoints and services', status: 'up' },
|
||||||
|
{ name: 'Database', description: 'Primary database systems', status: 'up' },
|
||||||
|
{ name: 'Authentication', description: 'User authentication services', status: 'up' },
|
||||||
|
{ name: 'File Storage', description: 'Media and file hosting', status: 'up' }
|
||||||
|
].map((component, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getStatusIcon(component.status)}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-foreground">{component.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{component.description}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400 font-medium">99.9% uptime</span>
|
||||||
|
<span className="text-xs text-muted-foreground">•</span>
|
||||||
|
<span className="text-xs text-muted-foreground">100ms response</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(component.status)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-8 bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
System Components
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Real-time status of all monitored components
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{components
|
||||||
|
.sort((a, b) => a.display_order - b.display_order)
|
||||||
|
.map((component) => {
|
||||||
|
const service = getServiceForComponent(component);
|
||||||
|
const status = getComponentStatus(component);
|
||||||
|
const uptime = service?.id ? getUptimePercentage(component.service_id) : 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={component.id} className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-5 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-all duration-200">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{getStatusIcon(status)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-foreground text-lg">{component.name}</h3>
|
||||||
|
{service?.responseTime && service.responseTime > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{service.responseTime}ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{component.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">{component.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`h-2 w-2 rounded-full ${getStatusDotColor(status)}`}></div>
|
||||||
|
<span className="font-medium">{uptime}% uptime (90 days)</span>
|
||||||
|
</div>
|
||||||
|
{service?.lastChecked && (
|
||||||
|
<span>Last checked: {format(new Date(service.lastChecked), 'HH:mm:ss')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
{getStatusBadge(status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{component.service_id && (
|
||||||
|
<div className="ml-9">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">90-day uptime history</div>
|
||||||
|
<UptimeHistoryRenderer
|
||||||
|
serviceId={component.service_id}
|
||||||
|
uptimeData={uptimeData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Shield, Clock, CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { Service } from '@/types/service.types';
|
||||||
|
|
||||||
|
interface CurrentStatusSectionProps {
|
||||||
|
page: OperationalPageRecord;
|
||||||
|
components: StatusPageComponentRecord[];
|
||||||
|
services: Service[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActualStatus = (components: StatusPageComponentRecord[], services: Service[]) => {
|
||||||
|
if (components.length === 0) {
|
||||||
|
return 'operational'; // Default if no components
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasDown = false;
|
||||||
|
let hasDegraded = false;
|
||||||
|
let hasMaintenance = false;
|
||||||
|
|
||||||
|
components.forEach(component => {
|
||||||
|
const service = services.find(s => s.id === component.service_id);
|
||||||
|
if (service) {
|
||||||
|
switch (service.status) {
|
||||||
|
case 'down':
|
||||||
|
hasDown = true;
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
hasDegraded = true;
|
||||||
|
break;
|
||||||
|
case 'paused':
|
||||||
|
hasMaintenance = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Priority: down > degraded > maintenance > operational
|
||||||
|
if (hasDown) return 'major_outage';
|
||||||
|
if (hasDegraded) return 'degraded';
|
||||||
|
if (hasMaintenance) return 'maintenance';
|
||||||
|
return 'operational';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusMessage = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return 'All systems are operational';
|
||||||
|
case 'degraded':
|
||||||
|
return 'Some systems are experiencing degraded performance';
|
||||||
|
case 'maintenance':
|
||||||
|
return 'Systems are currently under maintenance';
|
||||||
|
case 'major_outage':
|
||||||
|
return 'We are experiencing a major service outage';
|
||||||
|
default:
|
||||||
|
return 'Status unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return 'text-green-600 dark:text-green-400';
|
||||||
|
case 'degraded':
|
||||||
|
return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
case 'maintenance':
|
||||||
|
return 'text-blue-600 dark:text-blue-400';
|
||||||
|
case 'major_outage':
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return <CheckCircle className="h-6 w-6 text-green-500" />;
|
||||||
|
case 'degraded':
|
||||||
|
return <AlertTriangle className="h-6 w-6 text-yellow-500" />;
|
||||||
|
case 'maintenance':
|
||||||
|
return <Wrench className="h-6 w-6 text-blue-500" />;
|
||||||
|
case 'major_outage':
|
||||||
|
return <XCircle className="h-6 w-6 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return <Shield className="h-6 w-6 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBackground = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
|
||||||
|
case 'degraded':
|
||||||
|
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
|
||||||
|
case 'maintenance':
|
||||||
|
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
|
||||||
|
case 'major_outage':
|
||||||
|
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
|
||||||
|
const actualStatus = getActualStatus(components, services);
|
||||||
|
const displayStatus = actualStatus; // Use actual status for real-time accuracy
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`mb-8 border-2 ${getStatusBackground(displayStatus)}`}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
|
||||||
|
<Shield className="h-6 w-6" />
|
||||||
|
System Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className={`flex items-center justify-between p-6 rounded-lg border-2 ${getStatusBackground(displayStatus)}`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{getStatusIcon(displayStatus)}
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
|
||||||
|
{getStatusMessage(displayStatus)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Status automatically updated based on component health
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`px-4 py-2 rounded-full text-sm font-medium ${
|
||||||
|
displayStatus === 'operational' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
|
||||||
|
displayStatus === 'degraded' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
|
||||||
|
displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
|
||||||
|
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
{displayStatus === 'operational' ? 'All Systems Operational' :
|
||||||
|
displayStatus === 'degraded' ? 'Degraded Performance' :
|
||||||
|
displayStatus === 'maintenance' ? 'Under Maintenance' : 'Major Outage'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm')} UTC</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
<span>Live status monitoring</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { TrendingUp, Calendar, BarChart3 } from 'lucide-react';
|
||||||
|
import { UptimeData } from '@/types/service.types';
|
||||||
|
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||||
|
|
||||||
|
interface OverallUptimeSectionProps {
|
||||||
|
uptimeData: Record<string, UptimeData[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverallUptimeSection = ({ uptimeData }: OverallUptimeSectionProps) => {
|
||||||
|
const getOverallUptime = () => {
|
||||||
|
const allHistories = Object.values(uptimeData);
|
||||||
|
if (allHistories.length === 0) return 99.9;
|
||||||
|
|
||||||
|
let totalRecords = 0;
|
||||||
|
let upRecords = 0;
|
||||||
|
|
||||||
|
allHistories.forEach(history => {
|
||||||
|
totalRecords += history.length;
|
||||||
|
upRecords += history.filter(record => record.status === 'up').length;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalRecords === 0) return 99.9;
|
||||||
|
return Math.round((upRecords / totalRecords) * 100 * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUptimeTrend = () => {
|
||||||
|
const uptime = getOverallUptime();
|
||||||
|
if (uptime >= 99.9) return 'excellent';
|
||||||
|
if (uptime >= 99.5) return 'good';
|
||||||
|
if (uptime >= 95) return 'fair';
|
||||||
|
return 'poor';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIncidentCount = () => {
|
||||||
|
const allHistories = Object.values(uptimeData);
|
||||||
|
let incidents = 0;
|
||||||
|
|
||||||
|
allHistories.forEach(history => {
|
||||||
|
let wasDown = false;
|
||||||
|
history.forEach(record => {
|
||||||
|
if (record.status === 'down' && !wasDown) {
|
||||||
|
incidents++;
|
||||||
|
wasDown = true;
|
||||||
|
} else if (record.status === 'up') {
|
||||||
|
wasDown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return incidents;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBadgeClassName = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'excellent':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'good':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||||
|
case 'fair':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
default:
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrendText = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'excellent':
|
||||||
|
return 'Excellent';
|
||||||
|
case 'good':
|
||||||
|
return 'Good';
|
||||||
|
case 'fair':
|
||||||
|
return 'Fair';
|
||||||
|
default:
|
||||||
|
return 'Needs Improvement';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusMessage = (uptime: number) => {
|
||||||
|
if (uptime >= 99.9) {
|
||||||
|
return "All systems are performing excellently with minimal downtime.";
|
||||||
|
} else if (uptime >= 99.5) {
|
||||||
|
return "Systems are performing well with occasional minor issues.";
|
||||||
|
} else if (uptime >= 95) {
|
||||||
|
return "We're working to improve system reliability and reduce incidents.";
|
||||||
|
} else {
|
||||||
|
return "We apologize for recent service disruptions and are actively working on improvements.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const overallUptime = getOverallUptime();
|
||||||
|
const trend = getUptimeTrend();
|
||||||
|
const incidentCount = getIncidentCount();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-8 bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
Performance Metrics (Last 90 Days)
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Historical performance and reliability statistics
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-sm font-medium text-green-700 dark:text-green-300">Overall Uptime</span>
|
||||||
|
</div>
|
||||||
|
<Badge className={getBadgeClassName(trend)}>
|
||||||
|
{getTrendText(trend)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-green-600 dark:text-green-400">{overallUptime}%</div>
|
||||||
|
<div className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||||
|
Target: 99.9%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Incidents</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">{incidentCount}</div>
|
||||||
|
<div className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||||
|
Last 90 days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Avg Response</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">100ms</div>
|
||||||
|
<div className="text-xs text-purple-700 dark:text-purple-300 mt-1">
|
||||||
|
Response time
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">Uptime History</h4>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 bg-green-500 rounded"></div>
|
||||||
|
<span>Operational</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 bg-yellow-500 rounded"></div>
|
||||||
|
<span>Degraded</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 bg-red-500 rounded"></div>
|
||||||
|
<span>Down</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-background/50 rounded-lg border">
|
||||||
|
{Object.keys(uptimeData).length > 0 ? (
|
||||||
|
<UptimeHistoryRenderer serviceId={Object.keys(uptimeData)[0]} uptimeData={uptimeData} />
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center h-12 text-muted-foreground">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{Array.from({ length: 90 }, (_, i) => (
|
||||||
|
<div key={i} className="h-8 w-1 bg-green-500 rounded-sm"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>90 days ago</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-muted/50 rounded-lg border">
|
||||||
|
<div className="text-sm text-muted-foreground text-center">
|
||||||
|
{getStatusMessage(overallUptime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
import { usePublicStatusPageData } from './hooks/usePublicStatusPageData';
|
||||||
|
import { StatusPageHeader } from './StatusPageHeader';
|
||||||
|
import { CurrentStatusSection } from './CurrentStatusSection';
|
||||||
|
import { ComponentsStatusSection } from './ComponentsStatusSection';
|
||||||
|
import { OverallUptimeSection } from './OverallUptimeSection';
|
||||||
|
import { PublicStatusPageFooter } from './PublicStatusPageFooter';
|
||||||
|
|
||||||
|
export const PublicStatusPage = () => {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
console.log('PublicStatusPage - slug from params:', slug);
|
||||||
|
|
||||||
|
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState(new Date());
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
// The usePublicStatusPageData hook handles data refetching
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply theme to document
|
||||||
|
useEffect(() => {
|
||||||
|
if (page) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Remove any existing theme classes
|
||||||
|
root.classList.remove('dark', 'light');
|
||||||
|
|
||||||
|
// Apply the selected theme
|
||||||
|
if (page.theme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else if (page.theme === 'light') {
|
||||||
|
root.classList.add('light');
|
||||||
|
}
|
||||||
|
// For 'default' theme, don't add any class (uses system preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove('dark', 'light');
|
||||||
|
};
|
||||||
|
}, [page?.theme]);
|
||||||
|
|
||||||
|
console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-lg font-medium text-foreground">Loading Status Page</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Fetching real-time system status...</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !page) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center space-y-6 max-w-md">
|
||||||
|
<div className="mx-auto h-16 w-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Status Page Not Found</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{error || 'The requested status page could not be found or is not publicly accessible.'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button onClick={() => window.history.back()} variant="outline">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => window.location.reload()} className="gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
{/* Header */}
|
||||||
|
<StatusPageHeader page={page} />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
{/* Current Status */}
|
||||||
|
<CurrentStatusSection page={page} components={components} services={services} />
|
||||||
|
|
||||||
|
{/* Components Status */}
|
||||||
|
<ComponentsStatusSection
|
||||||
|
components={components}
|
||||||
|
services={services}
|
||||||
|
uptimeData={uptimeData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overall Uptime History */}
|
||||||
|
<OverallUptimeSection uptimeData={uptimeData} />
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<PublicStatusPageFooter page={page} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Custom CSS */}
|
||||||
|
{page.custom_css && (
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: page.custom_css }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Clock, Shield, Zap, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface PublicStatusPageFooterProps {
|
||||||
|
page: OperationalPageRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PublicStatusPageFooter = ({ page }: PublicStatusPageFooterProps) => {
|
||||||
|
const handleRefresh = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="mt-12 pt-8 border-t border-border">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Information */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6 bg-muted/30 rounded-lg border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Real-time Monitoring</div>
|
||||||
|
<div className="text-sm text-muted-foreground">24/7 automated checks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Zap className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Instant Updates</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Status changes in real-time</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Clock className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Historical Data</div>
|
||||||
|
<div className="text-sm text-muted-foreground">90-day performance history</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm:ss')} UTC</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
<span>Monitoring active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh} className="gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Refresh Status
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disclaimer */}
|
||||||
|
<div className="text-center text-xs text-muted-foreground p-4 bg-muted/20 rounded-lg">
|
||||||
|
<p>
|
||||||
|
This status page provides real-time information about our systems and services.
|
||||||
|
Historical data reflects the last 90 days of monitoring. For support inquiries, please contact our team.
|
||||||
|
</p>
|
||||||
|
{page.custom_domain && (
|
||||||
|
<p className="mt-2">
|
||||||
|
Powered by automated monitoring • Status page for {page.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { Shield, Globe, ExternalLink } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface StatusPageHeaderProps {
|
||||||
|
page: OperationalPageRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusPageHeader = ({ page }: StatusPageHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<header className="bg-background border-b border-border">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{page.logo_url ? (
|
||||||
|
<img
|
||||||
|
src={page.logo_url}
|
||||||
|
alt={`${page.title} logo`}
|
||||||
|
className="h-12 w-12 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-12 w-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Shield className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{page.title}</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">{page.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{page.custom_domain && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a
|
||||||
|
href={`https://${page.custom_domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
Visit Site
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-right text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
<span className="font-medium">Live Status</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">
|
||||||
|
Auto-updated every 30s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
<span>Status Page</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-foreground font-medium">{page.title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { UptimeData } from '@/types/service.types';
|
||||||
|
|
||||||
|
interface UptimeHistoryRendererProps {
|
||||||
|
serviceId: string;
|
||||||
|
uptimeData: Record<string, UptimeData[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UptimeHistoryRenderer = ({ serviceId, uptimeData }: UptimeHistoryRendererProps) => {
|
||||||
|
const renderUptimeHistory = (serviceId: string) => {
|
||||||
|
const history = uptimeData[serviceId] || [];
|
||||||
|
|
||||||
|
// Generate array for the last 90 days
|
||||||
|
const days = Array.from({ length: 90 }, (_, i) => {
|
||||||
|
const daysSinceToday = 90 - i - 1;
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - daysSinceToday);
|
||||||
|
date.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
// Generate mock data if no real data - showing mostly operational with some realistic incidents
|
||||||
|
return days.map((date, i) => {
|
||||||
|
// Simulate some realistic patterns - mostly up with occasional incidents
|
||||||
|
const isUp = Math.random() > 0.02; // 98% uptime simulation
|
||||||
|
const responseTime = isUp ? Math.floor(Math.random() * 200) + 50 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider key={i}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={`h-8 w-1 rounded-sm cursor-pointer ${
|
||||||
|
isUp ? 'bg-green-500 hover:bg-green-600' : 'bg-red-500 hover:bg-red-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">{format(date, 'MMM dd, yyyy')}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Status: {isUp ? 'Operational' : 'Incident - Down'}
|
||||||
|
</div>
|
||||||
|
{isUp && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Response: {responseTime}ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUp && (
|
||||||
|
<div className="text-muted-foreground text-red-400">
|
||||||
|
Service outage detected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of dates to status records for efficient lookup and incident tracking
|
||||||
|
const dateToRecordMap = new Map();
|
||||||
|
const incidentsByDate = new Map();
|
||||||
|
|
||||||
|
history.forEach(record => {
|
||||||
|
const recordDate = new Date(record.timestamp);
|
||||||
|
recordDate.setHours(0, 0, 0, 0); // Normalize to start of day
|
||||||
|
const dateKey = recordDate.toDateString();
|
||||||
|
|
||||||
|
// Track incidents for this date
|
||||||
|
if (!incidentsByDate.has(dateKey)) {
|
||||||
|
incidentsByDate.set(dateKey, []);
|
||||||
|
}
|
||||||
|
incidentsByDate.get(dateKey).push(record);
|
||||||
|
|
||||||
|
// Keep the latest record for each day (or aggregate if needed)
|
||||||
|
if (!dateToRecordMap.has(dateKey) ||
|
||||||
|
new Date(record.timestamp) > new Date(dateToRecordMap.get(dateKey).timestamp)) {
|
||||||
|
dateToRecordMap.set(dateKey, record);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use real uptime data mapped to the correct days with incident details
|
||||||
|
return days.map((date, i) => {
|
||||||
|
const dateKey = date.toDateString();
|
||||||
|
const record = dateToRecordMap.get(dateKey);
|
||||||
|
const incidents = incidentsByDate.get(dateKey) || [];
|
||||||
|
|
||||||
|
// Calculate uptime percentage for the day
|
||||||
|
const uptimePercentage = incidents.length > 0 ?
|
||||||
|
Math.round((incidents.filter(inc => inc.status === 'up').length / incidents.length) * 100) : 100;
|
||||||
|
|
||||||
|
// Determine color based on actual status and incident history
|
||||||
|
const getStatusColor = (status: string, incidents: UptimeData[]) => {
|
||||||
|
const downIncidents = incidents.filter(inc => inc.status === 'down').length;
|
||||||
|
const warningIncidents = incidents.filter(inc => inc.status === 'warning').length;
|
||||||
|
|
||||||
|
if (downIncidents > 0) return 'bg-red-500 hover:bg-red-600';
|
||||||
|
if (warningIncidents > 0) return 'bg-yellow-500 hover:bg-yellow-600';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return 'bg-green-500 hover:bg-green-600';
|
||||||
|
case 'down':
|
||||||
|
return 'bg-red-500 hover:bg-red-600';
|
||||||
|
case 'paused':
|
||||||
|
return 'bg-gray-400 hover:bg-gray-500';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-yellow-500 hover:bg-yellow-600';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor = record ? getStatusColor(record.status, incidents) : 'bg-gray-300 dark:bg-gray-600';
|
||||||
|
const statusText = record ?
|
||||||
|
record.status === 'up' ? (incidents.some(inc => inc.status === 'down') ? 'Recovered' : 'Operational') :
|
||||||
|
record.status === 'down' ? 'Incident - Down' :
|
||||||
|
record.status === 'paused' ? 'Paused' :
|
||||||
|
record.status === 'warning' ? 'Incident - Degraded' : 'Unknown'
|
||||||
|
: 'No Data';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider key={i}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={`h-8 w-1 rounded-sm cursor-pointer ${statusColor}`}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{format(date, 'MMM dd, yyyy')}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Status: {statusText}
|
||||||
|
</div>
|
||||||
|
{incidents.length > 0 && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Uptime: {uptimePercentage}% ({incidents.length} checks)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{incidents.filter(inc => inc.status === 'down').length > 0 && (
|
||||||
|
<div className="text-red-400 text-xs">
|
||||||
|
{incidents.filter(inc => inc.status === 'down').length} incident(s) detected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record && record.status === 'up' && record.responseTime > 0 && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Response: {record.responseTime}ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Last Check: {format(new Date(record.timestamp), 'HH:mm')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml-8 p-3 bg-background/30 rounded-lg border border-border/50">
|
||||||
|
<div className="text-sm font-medium text-foreground mb-2">90-day uptime history</div>
|
||||||
|
<div className="flex items-center gap-1 mb-2">
|
||||||
|
{renderUptimeHistory(serviceId)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>90 days ago</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { Service, UptimeData } from '@/types/service.types';
|
||||||
|
import { operationalPageService } from '@/services/operationalPageService';
|
||||||
|
import { statusPageComponentsService } from '@/services/statusPageComponentsService';
|
||||||
|
import { serviceService } from '@/services/serviceService';
|
||||||
|
import { uptimeService } from '@/services/uptimeService';
|
||||||
|
|
||||||
|
export const usePublicStatusPageData = (slug: string | undefined) => {
|
||||||
|
const [page, setPage] = useState<OperationalPageRecord | null>(null);
|
||||||
|
const [components, setComponents] = useState<StatusPageComponentRecord[]>([]);
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [uptimeData, setUptimeData] = useState<Record<string, UptimeData[]>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPublicPage = async () => {
|
||||||
|
if (!slug) {
|
||||||
|
console.log('No slug provided');
|
||||||
|
setError('No status page slug provided');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Fetching public status page for slug:', slug);
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Fetch operational page
|
||||||
|
console.log('Fetching operational pages...');
|
||||||
|
const pages = await operationalPageService.getOperationalPages();
|
||||||
|
console.log('All pages:', pages);
|
||||||
|
|
||||||
|
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
|
||||||
|
console.log('Found page:', foundPage);
|
||||||
|
|
||||||
|
if (!foundPage) {
|
||||||
|
console.log('Page not found or not public');
|
||||||
|
setError('Status page not found or not public');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(foundPage);
|
||||||
|
console.log('Page set successfully');
|
||||||
|
|
||||||
|
// Fetch components for this page
|
||||||
|
console.log('Fetching components for page:', foundPage.id);
|
||||||
|
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
|
||||||
|
console.log('Components found:', pageComponents);
|
||||||
|
setComponents(pageComponents);
|
||||||
|
|
||||||
|
// Fetch all services
|
||||||
|
console.log('Fetching all services...');
|
||||||
|
const allServices = await serviceService.getServices();
|
||||||
|
console.log('Services found:', allServices);
|
||||||
|
setServices(allServices);
|
||||||
|
|
||||||
|
// Fetch uptime data for each component that has a service
|
||||||
|
console.log('Fetching uptime data...');
|
||||||
|
const uptimePromises = pageComponents
|
||||||
|
.filter(component => component.service_id)
|
||||||
|
.map(async (component) => {
|
||||||
|
try {
|
||||||
|
console.log('Fetching uptime for service:', component.service_id);
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
|
||||||
|
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
|
||||||
|
console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
||||||
|
return { serviceId: component.service_id, history };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
||||||
|
return { serviceId: component.service_id, history: [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const uptimeResults = await Promise.all(uptimePromises);
|
||||||
|
const uptimeMap: Record<string, UptimeData[]> = {};
|
||||||
|
uptimeResults.forEach(result => {
|
||||||
|
uptimeMap[result.serviceId] = result.history;
|
||||||
|
});
|
||||||
|
setUptimeData(uptimeMap);
|
||||||
|
console.log('Uptime data set successfully');
|
||||||
|
|
||||||
|
console.log('All data fetched successfully');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching public page:', err);
|
||||||
|
setError(`Failed to load status page: ${err}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPublicPage();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
components,
|
||||||
|
services,
|
||||||
|
uptimeData,
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IncidentManagementContainer } from './incident-management';
|
||||||
|
|
||||||
|
interface IncidentManagementTabProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentManagementTab = React.memo(({ refreshTrigger = 0 }: IncidentManagementTabProps) => {
|
||||||
|
return <IncidentManagementContainer refreshTrigger={refreshTrigger} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
IncidentManagementTab.displayName = 'IncidentManagementTab';
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, CalendarClock, AlertCircle } from "lucide-react";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
import { ScheduledMaintenanceTab } from "./ScheduledMaintenanceTab";
|
||||||
|
import { IncidentManagementTab } from "./IncidentManagementTab";
|
||||||
|
import { CreateMaintenanceDialog } from './maintenance/CreateMaintenanceDialog';
|
||||||
|
import { CreateIncidentDialog } from './incident/CreateIncidentDialog';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { initMaintenanceNotifications, stopMaintenanceNotifications } from '@/services/maintenance/maintenanceNotificationService';
|
||||||
|
|
||||||
|
export const ScheduleIncidentContent = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [activeTab, setActiveTab] = useState("maintenance");
|
||||||
|
const [createMaintenanceDialogOpen, setCreateMaintenanceDialogOpen] = useState(false);
|
||||||
|
const [createIncidentDialogOpen, setCreateIncidentDialogOpen] = useState(false);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
const [incidentRefreshTrigger, setIncidentRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
|
// Initialize maintenance notifications when the component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("Initializing maintenance notifications");
|
||||||
|
initMaintenanceNotifications();
|
||||||
|
|
||||||
|
// Clean up when the component unmounts
|
||||||
|
return () => {
|
||||||
|
console.log("Cleaning up maintenance notifications");
|
||||||
|
stopMaintenanceNotifications();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateButtonClick = () => {
|
||||||
|
if (activeTab === "maintenance") {
|
||||||
|
setCreateMaintenanceDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
setCreateIncidentDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaintenanceCreated = () => {
|
||||||
|
// Refresh data by incrementing the refresh trigger
|
||||||
|
const newTriggerValue = refreshTrigger + 1;
|
||||||
|
console.log("Maintenance created, refreshing data with new trigger value:", newTriggerValue);
|
||||||
|
setRefreshTrigger(newTriggerValue);
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('maintenanceCreatedSuccess'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncidentCreated = () => {
|
||||||
|
// Refresh data by incrementing the refresh trigger
|
||||||
|
const newTriggerValue = incidentRefreshTrigger + 1;
|
||||||
|
console.log("Incident created, refreshing data with new trigger value:", newTriggerValue);
|
||||||
|
setIncidentRefreshTrigger(newTriggerValue);
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('incidentCreatedSuccess'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 flex flex-col overflow-auto bg-background p-6">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
|
{t('scheduleIncidentManagement')}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
className="text-primary-foreground"
|
||||||
|
onClick={handleCreateButtonClick}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{activeTab === "maintenance" ? t('createMaintenanceWindow') : t('createIncident')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultValue="maintenance"
|
||||||
|
className="w-full"
|
||||||
|
onValueChange={(value) => setActiveTab(value)}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||||
|
<TabsTrigger value="maintenance">
|
||||||
|
<CalendarClock className="w-4 h-4 mr-2" />
|
||||||
|
{t('scheduledMaintenance')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="incidents">
|
||||||
|
<AlertCircle className="w-4 h-4 mr-2" />
|
||||||
|
{t('incidentManagement')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="maintenance" className="space-y-4">
|
||||||
|
<ScheduledMaintenanceTab refreshTrigger={refreshTrigger} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="incidents" className="space-y-4">
|
||||||
|
<IncidentManagementTab refreshTrigger={incidentRefreshTrigger} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Maintenance creation dialog */}
|
||||||
|
<CreateMaintenanceDialog
|
||||||
|
open={createMaintenanceDialogOpen}
|
||||||
|
onOpenChange={setCreateMaintenanceDialogOpen}
|
||||||
|
onMaintenanceCreated={handleMaintenanceCreated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Incident creation dialog */}
|
||||||
|
<CreateIncidentDialog
|
||||||
|
open={createIncidentDialogOpen}
|
||||||
|
onOpenChange={setCreateIncidentDialogOpen}
|
||||||
|
onIncidentCreated={handleIncidentCreated}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { MaintenanceTable, MaintenanceStatusChecker } from './maintenance';
|
||||||
|
import { EmptyMaintenanceState } from './maintenance/EmptyMaintenanceState';
|
||||||
|
import { OverviewCard } from './common/OverviewCard';
|
||||||
|
import { Calendar, Clock, CheckCircle } from 'lucide-react';
|
||||||
|
import { useMaintenanceData } from './hooks/useMaintenanceData';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { LoadingState } from '@/components/services/LoadingState';
|
||||||
|
|
||||||
|
interface ScheduledMaintenanceTabProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMaintenanceTabProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [manualRefresh, setManualRefresh] = React.useState(0);
|
||||||
|
|
||||||
|
// Combine the external refresh trigger with our internal one
|
||||||
|
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
maintenanceData,
|
||||||
|
overviewStats,
|
||||||
|
fetchMaintenanceData,
|
||||||
|
isEmpty,
|
||||||
|
error,
|
||||||
|
initialized
|
||||||
|
} = useMaintenanceData({ refreshTrigger: combinedRefreshTrigger });
|
||||||
|
|
||||||
|
// Display toast when error occurs
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: error,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [error, toast, t]);
|
||||||
|
|
||||||
|
// Force fetch data on initial load
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("ScheduledMaintenanceTab is mounted, fetching data");
|
||||||
|
fetchMaintenanceData(true);
|
||||||
|
}, [fetchMaintenanceData]);
|
||||||
|
|
||||||
|
// Handle maintenance updates
|
||||||
|
const handleMaintenanceUpdated = () => {
|
||||||
|
console.log("Maintenance updated, refreshing data");
|
||||||
|
setManualRefresh(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle tab changes
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setFilter(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show full-page loading state during initial load
|
||||||
|
if (loading && !initialized) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Status checker for automatic updates */}
|
||||||
|
<MaintenanceStatusChecker
|
||||||
|
maintenanceData={maintenanceData}
|
||||||
|
onStatusUpdated={handleMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<OverviewCard
|
||||||
|
title={t('upcomingMaintenance')}
|
||||||
|
value={loading ? '...' : overviewStats.upcoming}
|
||||||
|
icon={<Calendar className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('ongoingMaintenance')}
|
||||||
|
value={loading ? '...' : overviewStats.ongoing}
|
||||||
|
icon={<Clock className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('completedMaintenance')}
|
||||||
|
value={loading ? '...' : overviewStats.completed}
|
||||||
|
icon={<CheckCircle className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading}
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('totalScheduledHours')}
|
||||||
|
value={loading ? '...' : `${overviewStats.totalDuration}h`}
|
||||||
|
icon={<Clock className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('scheduledMaintenance')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('scheduledMaintenanceDesc')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs
|
||||||
|
defaultValue="upcoming"
|
||||||
|
value={filter}
|
||||||
|
className="w-full"
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
>
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="upcoming">{t('upcomingMaintenance')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="ongoing">{t('ongoingMaintenance')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed">{t('completedMaintenance')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="upcoming" className="space-y-4">
|
||||||
|
<MaintenanceTable
|
||||||
|
data={maintenanceData}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="ongoing" className="space-y-4">
|
||||||
|
<MaintenanceTable
|
||||||
|
data={maintenanceData}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="completed" className="space-y-4">
|
||||||
|
<MaintenanceTable
|
||||||
|
data={maintenanceData}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
interface OverviewCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
description?: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
isPositive: boolean;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
valueClassName?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverviewCard = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
trend,
|
||||||
|
className,
|
||||||
|
valueClassName,
|
||||||
|
isLoading = false,
|
||||||
|
color = "blue",
|
||||||
|
}: OverviewCardProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
// Map color prop to gradient colors
|
||||||
|
const getGradientBackground = () => {
|
||||||
|
const colors = {
|
||||||
|
blue: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)",
|
||||||
|
green: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #43a047 0%, #66bb6a 100%)",
|
||||||
|
amber: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #ff9800 0%, #ffb74d 100%)",
|
||||||
|
red: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #e53935 0%, #ef5350 100%)",
|
||||||
|
purple: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(123, 31, 162, 0.8) 0%, rgba(156, 39, 176, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #7b1fa2 0%, #9c27b0 100%)",
|
||||||
|
orange: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(230, 81, 0, 0.8) 0%, rgba(255, 109, 0, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #e65100 0%, #ff6d00 100%)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return colors[color as keyof typeof colors] || colors.blue;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative z-10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: getGradientBackground()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Grid Pattern Overlay */}
|
||||||
|
<div className="absolute inset-0 z-0 opacity-10">
|
||||||
|
<div className="w-full h-full"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '20px 20px'
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-6 relative z-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white/90 mb-1">{title}</p>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-8 w-16 bg-white/20" />
|
||||||
|
) : (
|
||||||
|
<h3 className={cn("text-3xl font-bold text-white", valueClassName)}>{value}</h3>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-white/80 mt-1">{description}</p>
|
||||||
|
)}
|
||||||
|
{trend && (
|
||||||
|
<div className={`flex items-center mt-2 text-sm ${trend.isPositive ? 'text-green-100' : 'text-red-100'}`}>
|
||||||
|
<span className="mr-1">
|
||||||
|
{trend.isPositive ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
<span>{Math.abs(trend.value)}%</span>
|
||||||
|
<span className="ml-1 text-white/70">vs last month</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
export * from './useMaintenanceData';
|
||||||
|
export * from './useIncidentData';
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { incidentService, IncidentItem } from '@/services/incident';
|
||||||
|
|
||||||
|
interface UseIncidentDataProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) => {
|
||||||
|
const [incidents, setIncidents] = useState<IncidentItem[]>([]);
|
||||||
|
const [filter, setFilter] = useState("unresolved");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Use a ref to prevent multiple simultaneous fetch requests
|
||||||
|
const isFetchingRef = useRef(false);
|
||||||
|
// Use a ref to track the refresh trigger
|
||||||
|
const lastRefreshTriggerRef = useRef(refreshTrigger);
|
||||||
|
|
||||||
|
// Simplified fetch function with improved controls to prevent duplicate calls
|
||||||
|
const fetchIncidentData = useCallback(async (force = false) => {
|
||||||
|
// Skip if already fetching
|
||||||
|
if (isFetchingRef.current) {
|
||||||
|
console.log('Already fetching data, skipping additional request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if not forced and already initialized
|
||||||
|
if (initialized && !force) {
|
||||||
|
console.log('Data already initialized and no force refresh, skipping fetch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set fetching flags
|
||||||
|
isFetchingRef.current = true;
|
||||||
|
|
||||||
|
if (!initialized || force) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Fetching incident data (force=${force})`);
|
||||||
|
const allIncidents = await incidentService.getAllIncidents(force);
|
||||||
|
|
||||||
|
if (Array.isArray(allIncidents)) {
|
||||||
|
setIncidents(allIncidents);
|
||||||
|
console.log(`Successfully set ${allIncidents.length} incidents to state`);
|
||||||
|
} else {
|
||||||
|
setIncidents([]);
|
||||||
|
console.warn('No incidents returned from service');
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialized(true);
|
||||||
|
setLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching incident data:', error);
|
||||||
|
setError('Failed to load incident data. Please try again later.');
|
||||||
|
setIncidents([]);
|
||||||
|
setInitialized(true);
|
||||||
|
setLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
|
} finally {
|
||||||
|
// Reset fetching flag after a slight delay to prevent rapid consecutive calls
|
||||||
|
setTimeout(() => {
|
||||||
|
isFetchingRef.current = false;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [initialized]);
|
||||||
|
|
||||||
|
// Only fetch when component mounts or refreshTrigger changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip if the refresh trigger hasn't changed (prevents duplicate effect calls)
|
||||||
|
if (refreshTrigger === lastRefreshTriggerRef.current && initialized) {
|
||||||
|
console.log('Refresh trigger unchanged, skipping fetch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last refresh trigger ref
|
||||||
|
lastRefreshTriggerRef.current = refreshTrigger;
|
||||||
|
|
||||||
|
// Create an abort controller for cleanup
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
console.log(`useIncidentData effect running, refreshTrigger: ${refreshTrigger}`);
|
||||||
|
|
||||||
|
// Use a longer delay to ensure we don't trigger too many API calls
|
||||||
|
const fetchTimer = setTimeout(() => {
|
||||||
|
if (isMounted) {
|
||||||
|
fetchIncidentData(refreshTrigger > 0);
|
||||||
|
}
|
||||||
|
}, 500); // Use a longer delay
|
||||||
|
|
||||||
|
// Cleanup function to abort any in-flight requests and clear timers
|
||||||
|
return () => {
|
||||||
|
console.log('Cleaning up incident data fetch effect');
|
||||||
|
isMounted = false;
|
||||||
|
clearTimeout(fetchTimer);
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [fetchIncidentData, refreshTrigger, initialized]);
|
||||||
|
|
||||||
|
// Filter the data based on the current filter
|
||||||
|
const incidentData = useMemo(() => {
|
||||||
|
if (!initialized || incidents.length === 0) return [];
|
||||||
|
|
||||||
|
console.log(`Filtering incidents by: ${filter}`);
|
||||||
|
|
||||||
|
if (filter === "unresolved") {
|
||||||
|
return incidents.filter(item => {
|
||||||
|
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||||
|
return status !== 'resolved';
|
||||||
|
});
|
||||||
|
} else if (filter === "resolved") {
|
||||||
|
return incidents.filter(item => {
|
||||||
|
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||||
|
return status === 'resolved';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [filter, incidents, initialized]);
|
||||||
|
|
||||||
|
// Calculate stats for overview cards
|
||||||
|
const overviewStats = useMemo(() => {
|
||||||
|
if (!initialized || incidents.length === 0) {
|
||||||
|
return {
|
||||||
|
unresolved: 0,
|
||||||
|
resolved: 0,
|
||||||
|
critical: 0,
|
||||||
|
highPriority: 0,
|
||||||
|
avgResolutionTime: "0h"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const unresolvedIncidents = incidents.filter(item => {
|
||||||
|
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||||
|
return status !== 'resolved';
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedIncidents = incidents.filter(item => {
|
||||||
|
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||||
|
return status === 'resolved';
|
||||||
|
});
|
||||||
|
|
||||||
|
const criticalCount = unresolvedIncidents.filter(item =>
|
||||||
|
(item.priority?.toLowerCase() === 'critical') ||
|
||||||
|
(item.impact?.toLowerCase() === 'critical')
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const highPriorityCount = unresolvedIncidents.filter(item =>
|
||||||
|
(item.priority?.toLowerCase() === 'high')
|
||||||
|
).length;
|
||||||
|
|
||||||
|
let avgResolutionTime = "0h";
|
||||||
|
if (resolvedIncidents.length > 0) {
|
||||||
|
const totalHours = resolvedIncidents.reduce((total, item) => {
|
||||||
|
if (!item.created || !item.resolution_time) return total;
|
||||||
|
|
||||||
|
const createdAt = new Date(item.created).getTime();
|
||||||
|
const resolvedAt = new Date(item.resolution_time).getTime();
|
||||||
|
const durationHours = (resolvedAt - createdAt) / (1000 * 60 * 60);
|
||||||
|
return isNaN(durationHours) ? total : total + durationHours;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
avgResolutionTime = `${(totalHours / resolvedIncidents.length).toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
unresolved: unresolvedIncidents.length,
|
||||||
|
resolved: resolvedIncidents.length,
|
||||||
|
critical: criticalCount,
|
||||||
|
highPriority: highPriorityCount,
|
||||||
|
avgResolutionTime
|
||||||
|
};
|
||||||
|
}, [incidents, initialized]);
|
||||||
|
|
||||||
|
const isEmpty = !incidentData.length && initialized && !loading;
|
||||||
|
|
||||||
|
return {
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
incidentData,
|
||||||
|
overviewStats,
|
||||||
|
fetchIncidentData,
|
||||||
|
isEmpty,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
initialized,
|
||||||
|
isRefreshing
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { maintenanceService, MaintenanceItem } from '@/services/maintenance';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface UseMaintenanceDataProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMaintenanceData = ({ refreshTrigger = 0 }: UseMaintenanceDataProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [allMaintenanceData, setAllMaintenanceData] = useState<MaintenanceItem[]>([]);
|
||||||
|
const [filter, setFilter] = useState("upcoming");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Refs for cleanup and request management
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const currentRequestRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
|
// Memoize categorized data to prevent unnecessary recalculations
|
||||||
|
const categorizedData = useMemo(() => {
|
||||||
|
if (!allMaintenanceData.length) return { upcoming: [], ongoing: [], completed: [] };
|
||||||
|
|
||||||
|
const currentDate = new Date();
|
||||||
|
const upcoming: MaintenanceItem[] = [];
|
||||||
|
const ongoing: MaintenanceItem[] = [];
|
||||||
|
const completed: MaintenanceItem[] = [];
|
||||||
|
|
||||||
|
allMaintenanceData.forEach(item => {
|
||||||
|
const status = item.status?.toLowerCase() || '';
|
||||||
|
const startTime = new Date(item.start_time);
|
||||||
|
const endTime = new Date(item.end_time);
|
||||||
|
|
||||||
|
if (status === 'completed' || status === 'cancelled') {
|
||||||
|
completed.push(item);
|
||||||
|
} else if (status === 'in_progress' ||
|
||||||
|
(status === 'scheduled' && startTime <= currentDate && endTime >= currentDate)) {
|
||||||
|
ongoing.push(item);
|
||||||
|
} else if (status === 'scheduled' && startTime > currentDate) {
|
||||||
|
upcoming.push(item);
|
||||||
|
} else {
|
||||||
|
// Default case: treat as upcoming if we can't determine
|
||||||
|
upcoming.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { upcoming, ongoing, completed };
|
||||||
|
}, [allMaintenanceData]);
|
||||||
|
|
||||||
|
// Simple fetch function - only called when explicitly requested
|
||||||
|
const fetchMaintenanceData = useCallback(async (force = false) => {
|
||||||
|
// Check if component is still mounted
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
// Prevent duplicate requests
|
||||||
|
if (currentRequestRef.current) {
|
||||||
|
await currentRequestRef.current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading state for initial load or forced refresh
|
||||||
|
if (!initialized || force) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const requestPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await maintenanceService.getMaintenanceRecords();
|
||||||
|
|
||||||
|
// Check if component is still mounted before updating state
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
// Update state with fetched data
|
||||||
|
setAllMaintenanceData(data);
|
||||||
|
setInitialized(true);
|
||||||
|
|
||||||
|
// Clear any previous error
|
||||||
|
if (error) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching maintenance data:', err);
|
||||||
|
|
||||||
|
// Only update error state if component is still mounted
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
const errorMessage = 'Failed to load maintenance data. Please try again.';
|
||||||
|
setError(errorMessage);
|
||||||
|
|
||||||
|
// Show toast for errors
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorFetchingMaintenanceData'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// Only update loading state if component is still mounted
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
currentRequestRef.current = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
currentRequestRef.current = requestPromise;
|
||||||
|
await requestPromise;
|
||||||
|
}, [t, toast, error, initialized]);
|
||||||
|
|
||||||
|
// Initial fetch on mount - NO AUTOMATIC POLLING
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
|
||||||
|
// Only fetch initial data, no polling
|
||||||
|
fetchMaintenanceData(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
currentRequestRef.current = null;
|
||||||
|
};
|
||||||
|
}, []); // Remove fetchMaintenanceData from dependencies to prevent re-runs
|
||||||
|
|
||||||
|
// Handle refresh trigger changes - ONLY when explicitly triggered
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger > 0) {
|
||||||
|
fetchMaintenanceData(true); // Force refresh to bypass cache
|
||||||
|
}
|
||||||
|
}, [refreshTrigger, fetchMaintenanceData]);
|
||||||
|
|
||||||
|
// Get filtered data based on current tab
|
||||||
|
const maintenanceData = useMemo(() => {
|
||||||
|
if (!initialized) return [];
|
||||||
|
return categorizedData[filter as keyof typeof categorizedData] || [];
|
||||||
|
}, [filter, categorizedData, initialized]);
|
||||||
|
|
||||||
|
// Calculate overview stats with memoization
|
||||||
|
const overviewStats = useMemo(() => {
|
||||||
|
const { upcoming, ongoing, completed } = categorizedData;
|
||||||
|
|
||||||
|
// Calculate total hours more efficiently
|
||||||
|
const calculateTotalHours = (items: MaintenanceItem[]) => {
|
||||||
|
return items.reduce((total, item) => {
|
||||||
|
try {
|
||||||
|
const start = new Date(item.start_time).getTime();
|
||||||
|
const end = new Date(item.end_time).getTime();
|
||||||
|
const durationHours = (end - start) / (1000 * 60 * 60);
|
||||||
|
return total + (isNaN(durationHours) ? 0 : durationHours);
|
||||||
|
} catch (e) {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}, 0).toFixed(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
upcoming: upcoming.length,
|
||||||
|
ongoing: ongoing.length,
|
||||||
|
completed: completed.length,
|
||||||
|
totalDuration: calculateTotalHours([...upcoming, ...ongoing]),
|
||||||
|
};
|
||||||
|
}, [categorizedData]);
|
||||||
|
|
||||||
|
const isEmpty = !loading && maintenanceData.length === 0 && initialized;
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
maintenanceData,
|
||||||
|
overviewStats,
|
||||||
|
fetchMaintenanceData,
|
||||||
|
isEmpty,
|
||||||
|
error,
|
||||||
|
initialized,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
error: string | null;
|
||||||
|
onRefresh: () => void;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorState: React.FC<ErrorStateProps> = ({
|
||||||
|
error,
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground mb-4">{error}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
|
{t('tryAgain')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { CardDescription, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface HeaderActionsProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeaderActions: React.FC<HeaderActionsProps> = ({ onRefresh, isRefreshing }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{t('incidentManagement')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('incidentsManagementDesc')}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="ml-auto"
|
||||||
|
title={t('refreshData')}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
|
<span className="sr-only">{t('refresh')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
|
||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { useIncidentData } from '../hooks/useIncidentData';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { OverviewCards } from './OverviewCards';
|
||||||
|
import { HeaderActions } from './HeaderActions';
|
||||||
|
import { TabContent } from './TabContent';
|
||||||
|
import { LoadingState } from '@/components/services/LoadingState';
|
||||||
|
import { IncidentDetailDialog } from '../incident/detail-dialog/IncidentDetailDialog';
|
||||||
|
|
||||||
|
interface IncidentManagementContainerProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentManagementContainer: React.FC<IncidentManagementContainerProps> = ({
|
||||||
|
refreshTrigger = 0
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
|
||||||
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||||
|
const [manualRefresh, setManualRefresh] = useState(0);
|
||||||
|
|
||||||
|
// Use a ref to debounce multiple refresh requests
|
||||||
|
const refreshTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Combine the external refresh trigger with our internal one
|
||||||
|
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
|
||||||
|
|
||||||
|
const {
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
incidentData,
|
||||||
|
overviewStats,
|
||||||
|
isEmpty,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
initialized,
|
||||||
|
isRefreshing
|
||||||
|
} = useIncidentData({ refreshTrigger: combinedRefreshTrigger });
|
||||||
|
|
||||||
|
// Handle incident updates with debouncing
|
||||||
|
const handleIncidentUpdated = useCallback(() => {
|
||||||
|
console.log('Incident updated, triggering refresh');
|
||||||
|
|
||||||
|
// Clear any existing timer
|
||||||
|
if (refreshTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(refreshTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a new timer to debounce multiple quick updates
|
||||||
|
refreshTimerRef.current = window.setTimeout(() => {
|
||||||
|
setManualRefresh(prev => prev + 1);
|
||||||
|
refreshTimerRef.current = null;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('incidentUpdated'),
|
||||||
|
description: t('incidentUpdateSuccess'),
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
}, [t, toast]);
|
||||||
|
|
||||||
|
// Handle tab changes
|
||||||
|
const handleTabChange = useCallback((value: string) => {
|
||||||
|
console.log(`Tab changed to: ${value}`);
|
||||||
|
setFilter(value);
|
||||||
|
}, [setFilter]);
|
||||||
|
|
||||||
|
// Handle view incident details
|
||||||
|
const handleViewIncidentDetails = useCallback((incident: IncidentItem) => {
|
||||||
|
setSelectedIncident(incident);
|
||||||
|
setDetailDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle manual refresh
|
||||||
|
const handleManualRefresh = useCallback(() => {
|
||||||
|
console.log('Manual refresh triggered by user');
|
||||||
|
setManualRefresh(prev => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle detail dialog close with refresh
|
||||||
|
const handleDetailDialogClose = useCallback((open: boolean) => {
|
||||||
|
setDetailDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
// When dialog closes, refresh data
|
||||||
|
handleManualRefresh();
|
||||||
|
}
|
||||||
|
}, [handleManualRefresh]);
|
||||||
|
|
||||||
|
// Clean up timer on unmount
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (refreshTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(refreshTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show full-page loading state during initial load
|
||||||
|
if (loading && !initialized) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<OverviewCards
|
||||||
|
overviewStats={overviewStats}
|
||||||
|
loading={loading}
|
||||||
|
initialized={initialized}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<HeaderActions
|
||||||
|
onRefresh={handleManualRefresh}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs value={filter} className="w-full" onValueChange={handleTabChange}>
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="unresolved">{t('unresolvedIncidents')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="resolved">{t('resolvedIncidents')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="unresolved" className="space-y-4">
|
||||||
|
<TabContent
|
||||||
|
error={error}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
data={incidentData}
|
||||||
|
loading={loading}
|
||||||
|
initialized={initialized}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
onIncidentUpdated={handleIncidentUpdated}
|
||||||
|
onViewDetails={handleViewIncidentDetails}
|
||||||
|
onRefresh={handleManualRefresh}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="resolved" className="space-y-4">
|
||||||
|
<TabContent
|
||||||
|
error={error}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
data={incidentData}
|
||||||
|
loading={loading}
|
||||||
|
initialized={initialized}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
onIncidentUpdated={handleIncidentUpdated}
|
||||||
|
onViewDetails={handleViewIncidentDetails}
|
||||||
|
onRefresh={handleManualRefresh}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Incident Detail Dialog */}
|
||||||
|
<IncidentDetailDialog
|
||||||
|
open={detailDialogOpen}
|
||||||
|
onOpenChange={handleDetailDialogClose}
|
||||||
|
incident={selectedIncident}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { AlertCircle, CheckCircle, Clock, AlertTriangle, Flag } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { OverviewCard } from '../common/OverviewCard';
|
||||||
|
|
||||||
|
interface OverviewStatsProps {
|
||||||
|
unresolved: number;
|
||||||
|
resolved: number;
|
||||||
|
critical: number;
|
||||||
|
highPriority: number;
|
||||||
|
avgResolutionTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewCardsProps {
|
||||||
|
overviewStats: OverviewStatsProps;
|
||||||
|
loading: boolean;
|
||||||
|
initialized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverviewCards: React.FC<OverviewCardsProps> = ({
|
||||||
|
overviewStats,
|
||||||
|
loading,
|
||||||
|
initialized
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||||
|
<OverviewCard
|
||||||
|
title={t('activeIncidents')}
|
||||||
|
value={overviewStats.unresolved.toString()}
|
||||||
|
icon={<AlertCircle className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('criticalIssues')}
|
||||||
|
value={overviewStats.critical.toString()}
|
||||||
|
icon={<AlertTriangle className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('highPriority')}
|
||||||
|
value={overviewStats.highPriority.toString()}
|
||||||
|
icon={<Flag className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="orange"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('resolvedIncidents')}
|
||||||
|
value={overviewStats.resolved.toString()}
|
||||||
|
icon={<CheckCircle className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('avgResolutionTime')}
|
||||||
|
value={overviewStats.avgResolutionTime}
|
||||||
|
icon={<Clock className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IncidentTable } from '../incident/table/IncidentTable';
|
||||||
|
import { EmptyIncidentState } from '../incident/EmptyIncidentState';
|
||||||
|
import { ErrorState } from './ErrorState';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
|
||||||
|
interface TabContentProps {
|
||||||
|
error: string | null;
|
||||||
|
isEmpty: boolean;
|
||||||
|
data: IncidentItem[];
|
||||||
|
loading: boolean;
|
||||||
|
initialized: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
onViewDetails: (incident: IncidentItem) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabContent: React.FC<TabContentProps> = ({
|
||||||
|
error,
|
||||||
|
isEmpty,
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
initialized,
|
||||||
|
isRefreshing,
|
||||||
|
onIncidentUpdated,
|
||||||
|
onViewDetails,
|
||||||
|
onRefresh
|
||||||
|
}) => {
|
||||||
|
if (error) {
|
||||||
|
return <ErrorState error={error} onRefresh={onRefresh} isRefreshing={isRefreshing} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return <EmptyIncidentState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IncidentTable
|
||||||
|
data={data}
|
||||||
|
onIncidentUpdated={onIncidentUpdated}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export * from './IncidentManagementContainer';
|
||||||
|
export * from './OverviewCards';
|
||||||
|
export * from './TabContent';
|
||||||
|
export * from './ErrorState';
|
||||||
|
export * from './HeaderActions';
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Form } from '@/components/ui/form';
|
||||||
|
import { useIncidentForm } from './hooks/useIncidentForm';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import {
|
||||||
|
IncidentBasicFields,
|
||||||
|
IncidentAffectedFields,
|
||||||
|
IncidentConfigFields,
|
||||||
|
IncidentDetailsFields,
|
||||||
|
} from './form';
|
||||||
|
|
||||||
|
interface CreateIncidentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onIncidentCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateIncidentDialog: React.FC<CreateIncidentDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onIncidentCreated,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { form, onSubmit } = useIncidentForm(
|
||||||
|
onIncidentCreated,
|
||||||
|
() => onOpenChange(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
|
||||||
|
<ScrollArea className="h-[80vh]">
|
||||||
|
<div className="px-1 py-2">
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle className="text-xl">{t('createIncident')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('createIncidentDesc')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-8 pb-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
|
||||||
|
<IncidentBasicFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
|
||||||
|
<IncidentAffectedFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
|
||||||
|
<IncidentConfigFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
|
||||||
|
<IncidentDetailsFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4 mt-4 border-t">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{form.formState.isSubmitting ? t('creating') : t('create')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Form } from '@/components/ui/form';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { useIncidentEditForm } from './hooks/useIncidentEditForm';
|
||||||
|
import {
|
||||||
|
IncidentBasicFields,
|
||||||
|
IncidentAffectedFields,
|
||||||
|
IncidentConfigFields,
|
||||||
|
IncidentDetailsFields,
|
||||||
|
} from './form';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
|
||||||
|
interface EditIncidentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
incident: IncidentItem;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditIncidentDialog: React.FC<EditIncidentDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
incident,
|
||||||
|
onIncidentUpdated,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { form, onSubmit } = useIncidentEditForm(
|
||||||
|
incident,
|
||||||
|
onIncidentUpdated,
|
||||||
|
handleClose
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
|
||||||
|
<ScrollArea className="h-[80vh]">
|
||||||
|
<div className="px-1 py-2">
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle className="text-xl">{t('editIncident')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('editIncidentDesc')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-8 pb-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
|
||||||
|
<IncidentBasicFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
|
||||||
|
<IncidentAffectedFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
|
||||||
|
<IncidentConfigFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
|
||||||
|
<IncidentDetailsFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4 mt-4 border-t">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{form.formState.isSubmitting ? t('updating') : t('update')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export const EmptyIncidentState = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||||
|
<AlertCircle className="w-12 h-12 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">{t('noIncidents')}</h3>
|
||||||
|
<p className="text-sm text-center max-w-md">
|
||||||
|
{t('noServices')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MoreHorizontal, Eye, Edit, Trash, Check } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { updateIncidentStatus, deleteIncident } from '@/services/incident/incidentOperations';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
|
||||||
|
interface IncidentActionsMenuProps {
|
||||||
|
item: IncidentItem;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
onViewDetails?: (incident: IncidentItem) => void;
|
||||||
|
onEditIncident?: (incident: IncidentItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentActionsMenu = ({
|
||||||
|
item,
|
||||||
|
onIncidentUpdated,
|
||||||
|
onViewDetails,
|
||||||
|
onEditIncident
|
||||||
|
}: IncidentActionsMenuProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleResolveIncident = async () => {
|
||||||
|
try {
|
||||||
|
await updateIncidentStatus(item.id, 'resolved');
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('incidentResolved'),
|
||||||
|
});
|
||||||
|
onIncidentUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resolving incident:', error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorResolvingIncident'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteIncident = async () => {
|
||||||
|
try {
|
||||||
|
await deleteIncident(item.id);
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('incidentDeleted'),
|
||||||
|
});
|
||||||
|
onIncidentUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting incident:', error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorDeletingIncident'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = () => {
|
||||||
|
if (onEditIncident) {
|
||||||
|
onEditIncident(item);
|
||||||
|
} else {
|
||||||
|
console.log(`Edit incident ${item.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">{t('actions')}</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="bg-background">
|
||||||
|
<DropdownMenuLabel>{t('actions')}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{onViewDetails && (
|
||||||
|
<DropdownMenuItem onClick={() => onViewDetails(item)}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{t('view')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={handleEditClick}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
{t('edit')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleResolveIncident}>
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
{t('resolve')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleDeleteIncident}
|
||||||
|
className="text-red-600 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
{t('delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Gauge,
|
||||||
|
Search,
|
||||||
|
Wrench,
|
||||||
|
LucideIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface IncidentStatusBadgeProps {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusConfig = {
|
||||||
|
label: string;
|
||||||
|
variant: 'outline' | 'default' | 'secondary' | 'destructive';
|
||||||
|
icon: LucideIcon;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentStatusBadge = ({ status }: IncidentStatusBadgeProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
// Normalize the status string
|
||||||
|
const normalizedStatus = (status || '').toLowerCase();
|
||||||
|
|
||||||
|
// Status configuration map
|
||||||
|
const statusConfigs: Record<string, StatusConfig> = {
|
||||||
|
'investigating': {
|
||||||
|
label: t('investigating'),
|
||||||
|
variant: 'destructive',
|
||||||
|
icon: Search,
|
||||||
|
className: 'bg-red-100 border-red-200 text-red-700 hover:bg-red-100',
|
||||||
|
},
|
||||||
|
'identified': {
|
||||||
|
label: t('identified'),
|
||||||
|
variant: 'secondary',
|
||||||
|
icon: AlertCircle,
|
||||||
|
className: 'bg-amber-100 border-amber-200 text-amber-700 hover:bg-amber-100',
|
||||||
|
},
|
||||||
|
'found_root_cause': {
|
||||||
|
label: t('rootCauseFound'),
|
||||||
|
variant: 'secondary',
|
||||||
|
icon: AlertCircle,
|
||||||
|
className: 'bg-amber-100 border-amber-200 text-amber-700 hover:bg-amber-100',
|
||||||
|
},
|
||||||
|
'completed': {
|
||||||
|
label: t('completed'),
|
||||||
|
variant: 'default',
|
||||||
|
icon: CheckCircle,
|
||||||
|
className: 'bg-green-100 border-green-200 text-green-700 hover:bg-green-100',
|
||||||
|
},
|
||||||
|
'in_progress': {
|
||||||
|
label: t('inProgress'),
|
||||||
|
variant: 'default',
|
||||||
|
icon: Wrench,
|
||||||
|
className: 'bg-blue-100 border-blue-200 text-blue-700 hover:bg-blue-100',
|
||||||
|
},
|
||||||
|
'inprogress': {
|
||||||
|
label: t('inProgress'),
|
||||||
|
variant: 'default',
|
||||||
|
icon: Wrench,
|
||||||
|
className: 'bg-blue-100 border-blue-200 text-blue-700 hover:bg-blue-100',
|
||||||
|
},
|
||||||
|
'monitoring': {
|
||||||
|
label: t('monitoring'),
|
||||||
|
variant: 'outline',
|
||||||
|
icon: Gauge,
|
||||||
|
className: 'bg-purple-100 border-purple-200 text-purple-700 hover:bg-purple-100',
|
||||||
|
},
|
||||||
|
'resolved': {
|
||||||
|
label: t('resolved'),
|
||||||
|
variant: 'default',
|
||||||
|
icon: CheckCircle,
|
||||||
|
className: 'bg-green-100 border-green-200 text-green-700 hover:bg-green-100',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the appropriate config, defaulting to investigating if not found
|
||||||
|
const getStatusConfig = (): StatusConfig => {
|
||||||
|
for (const [key, config] of Object.entries(statusConfigs)) {
|
||||||
|
if (normalizedStatus.includes(key)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return statusConfigs['investigating'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig();
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config.variant}
|
||||||
|
className={`flex items-center gap-1 ${config.className}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
<span>{config.label}</span>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { AlertCircle, CheckCircle, Gauge, Search, Wrench } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { updateIncidentStatus } from '@/services/incident/incidentOperations';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { IncidentStatusBadge } from './IncidentStatusBadge';
|
||||||
|
|
||||||
|
interface IncidentStatusDropdownProps {
|
||||||
|
status: string;
|
||||||
|
id: string;
|
||||||
|
onStatusUpdated: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentStatusDropdown = ({
|
||||||
|
status,
|
||||||
|
id,
|
||||||
|
onStatusUpdated,
|
||||||
|
disabled = false
|
||||||
|
}: IncidentStatusDropdownProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [localStatus, setLocalStatus] = React.useState(status);
|
||||||
|
|
||||||
|
// Update local status when prop changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLocalStatus(status);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'investigating', label: t('investigating'), icon: <Search className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'identified', label: t('identified'), icon: <AlertCircle className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'found_root_cause', label: t('foundRootCause'), icon: <AlertCircle className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'in_progress', label: t('inProgress'), icon: <Wrench className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'monitoring', label: t('monitoring'), icon: <Gauge className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'resolved', label: t('resolved'), icon: <CheckCircle className="h-4 w-4 mr-2" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus: string) => {
|
||||||
|
try {
|
||||||
|
// Don't update if the status is the same
|
||||||
|
if (localStatus === newStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Changing incident status from ${localStatus} to ${newStatus}`);
|
||||||
|
|
||||||
|
// Optimistically update the UI immediately
|
||||||
|
setLocalStatus(newStatus);
|
||||||
|
|
||||||
|
// Make the API call in the background
|
||||||
|
await updateIncidentStatus(id, newStatus);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('statusUpdated'),
|
||||||
|
description: t('incidentStatusUpdated'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify parent components about the status change
|
||||||
|
onStatusUpdated();
|
||||||
|
console.log('Status update complete, UI refresh triggered');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating incident status:', error);
|
||||||
|
|
||||||
|
// Revert to the original status on error
|
||||||
|
setLocalStatus(status);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('failedToUpdateStatus'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger disabled={disabled} className="w-full cursor-pointer">
|
||||||
|
<IncidentStatusBadge status={localStatus} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="bg-background border border-border shadow-md z-50">
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent event from bubbling to table row click
|
||||||
|
handleStatusChange(option.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.icon}
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
// Re-export the refactored IncidentTable component
|
||||||
|
export { IncidentTable } from './table/IncidentTable';
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { IncidentDetailHeader } from './IncidentDetailHeader';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
BasicInfoSection,
|
||||||
|
TimelineSection,
|
||||||
|
AffectedSystemsSection,
|
||||||
|
ResolutionSection
|
||||||
|
} from './sections';
|
||||||
|
import { IncidentDetailFooter } from './IncidentDetailFooter';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
|
||||||
|
interface IncidentDetailContentProps {
|
||||||
|
incident: IncidentItem;
|
||||||
|
onClose: () => void;
|
||||||
|
assignedUser: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentDetailContent = ({
|
||||||
|
incident,
|
||||||
|
onClose,
|
||||||
|
assignedUser
|
||||||
|
}: IncidentDetailContentProps) => {
|
||||||
|
// Fetch assigned user details if one wasn't provided and there's an assigned_to field
|
||||||
|
const { data: fetchedUser } = useQuery({
|
||||||
|
queryKey: ['user', incident?.assigned_to],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!incident?.assigned_to) return null;
|
||||||
|
try {
|
||||||
|
return await userService.getUser(incident.assigned_to);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch assigned user:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!incident?.assigned_to && !assignedUser,
|
||||||
|
staleTime: 300000 // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the provided assignedUser or the one we fetched
|
||||||
|
const userToDisplay = assignedUser || fetchedUser;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[80vh] print:h-auto print:overflow-visible">
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<div className="print-section header-print">
|
||||||
|
<IncidentDetailHeader incident={incident} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8 print-compact-spacing">
|
||||||
|
<div className="print-section">
|
||||||
|
<BasicInfoSection incident={incident} assignedUser={userToDisplay} />
|
||||||
|
</div>
|
||||||
|
<Separator className="print:border-blue-200" />
|
||||||
|
<div className="print-section">
|
||||||
|
<TimelineSection incident={incident} assignedUser={userToDisplay} />
|
||||||
|
</div>
|
||||||
|
<Separator className="print:border-blue-200" />
|
||||||
|
<div className="print-section">
|
||||||
|
<AffectedSystemsSection incident={incident} assignedUser={userToDisplay} />
|
||||||
|
</div>
|
||||||
|
<Separator className="print:border-blue-200" />
|
||||||
|
<div className="print-section">
|
||||||
|
<ResolutionSection incident={incident} assignedUser={userToDisplay} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IncidentDetailFooter onClose={onClose} incident={incident} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
};
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
import { IncidentDetailContent } from './IncidentDetailContent';
|
||||||
|
|
||||||
|
interface IncidentDetailDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentDetailDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
incident
|
||||||
|
}: IncidentDetailDialogProps) => {
|
||||||
|
// Fetch user data for assigned_to field
|
||||||
|
const { data: users = [] } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const usersList = await userService.getUsers();
|
||||||
|
return Array.isArray(usersList) ? usersList : [];
|
||||||
|
},
|
||||||
|
staleTime: 300000, // Cache for 5 minutes
|
||||||
|
enabled: !!incident?.assigned_to && open // Only run query if there's an assigned_to value and dialog is open
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the assigned user
|
||||||
|
const assignedUser = incident?.assigned_to
|
||||||
|
? users.find(user => user.id === incident.assigned_to)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="dialog-content sm:max-w-[700px] max-h-[90vh] p-0
|
||||||
|
print:max-w-none print:max-h-none print:overflow-visible
|
||||||
|
print:shadow-none print:m-0 print:p-0 print:border-none
|
||||||
|
print:absolute print:left-0 print:top-0 print:w-full print:h-auto"
|
||||||
|
>
|
||||||
|
<IncidentDetailContent
|
||||||
|
incident={incident}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
assignedUser={assignedUser}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { CloseButton, DownloadPdfButton, PrintButton } from './components';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface IncidentDetailFooterProps {
|
||||||
|
onClose: () => void;
|
||||||
|
incident: IncidentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentDetailFooter: React.FC<IncidentDetailFooterProps> = ({
|
||||||
|
onClose,
|
||||||
|
incident,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print:hidden">
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CloseButton onClose={onClose} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<DownloadPdfButton incident={incident} />
|
||||||
|
<PrintButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
|
||||||
|
interface IncidentDetailHeaderProps {
|
||||||
|
incident: IncidentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentDetailHeader = ({ incident }: IncidentDetailHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DialogTitle className="text-xl">{incident.title || 'Incident Details'}</DialogTitle>
|
||||||
|
<span className="text-sm text-muted-foreground">#{incident.id}</span>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
|
||||||
|
// Import all section components from the new location
|
||||||
|
import {
|
||||||
|
BasicInfoSection,
|
||||||
|
TimelineSection,
|
||||||
|
AffectedSystemsSection,
|
||||||
|
ResolutionSection
|
||||||
|
} from './sections';
|
||||||
|
|
||||||
|
// Re-export all section components for compatibility with existing imports
|
||||||
|
export {
|
||||||
|
BasicInfoSection,
|
||||||
|
TimelineSection,
|
||||||
|
AffectedSystemsSection,
|
||||||
|
ResolutionSection
|
||||||
|
};
|
||||||
|
|
||||||
|
// Legacy component - keeping this for backward compatibility with other imports
|
||||||
|
export const IncidentDetailSections = ({ incident }: { incident: IncidentItem | null }) => {
|
||||||
|
// Fetch assigned user details if there's an assigned_to field
|
||||||
|
const { data: assignedUser } = useQuery({
|
||||||
|
queryKey: ['user', incident?.assigned_to],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!incident?.assigned_to) return null;
|
||||||
|
try {
|
||||||
|
return await userService.getUser(incident.assigned_to);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch assigned user:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!incident?.assigned_to,
|
||||||
|
staleTime: 300000 // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BasicInfoSection incident={incident} assignedUser={assignedUser} />
|
||||||
|
<Separator />
|
||||||
|
<TimelineSection incident={incident} assignedUser={assignedUser} />
|
||||||
|
<Separator />
|
||||||
|
<AffectedSystemsSection incident={incident} assignedUser={assignedUser} />
|
||||||
|
<Separator />
|
||||||
|
<ResolutionSection incident={incident} assignedUser={assignedUser} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface CloseButtonProps {
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloseButton: React.FC<CloseButtonProps> = ({
|
||||||
|
onClose,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{t('close', 'common')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { useDownloadIncidentPdf } from '../hooks';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
|
||||||
|
interface DownloadPdfButtonProps {
|
||||||
|
incident: IncidentItem;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadPdfButton: React.FC<DownloadPdfButtonProps> = ({
|
||||||
|
incident,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { handleDownloadPDF } = useDownloadIncidentPdf();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={`flex items-center gap-2 ${className || ''}`}
|
||||||
|
onClick={() => handleDownloadPDF(incident)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{t('downloadPdf', 'incident')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Printer } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { usePrintIncident } from '../hooks';
|
||||||
|
|
||||||
|
interface PrintButtonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrintButton: React.FC<PrintButtonProps> = ({ className }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { handlePrint } = usePrintIncident();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={`flex items-center gap-2 ${className || ''}`}
|
||||||
|
onClick={handlePrint}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
{t('print', 'incident')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export * from './PrintButton';
|
||||||
|
export * from './DownloadPdfButton';
|
||||||
|
export * from './CloseButton';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
export * from './usePrintIncident';
|
||||||
|
export * from './useDownloadIncidentPdf';
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem, incidentService } from '@/services/incident';
|
||||||
|
|
||||||
|
export const useDownloadIncidentPdf = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleDownloadPDF = async (incident: IncidentItem) => {
|
||||||
|
try {
|
||||||
|
await incidentService.generateIncidentPDF(incident);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('pdfDownloaded'),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating PDF:", error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('pdfGenerationFailed'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleDownloadPDF };
|
||||||
|
};
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
export const usePrintIncident = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handlePrint = React.useCallback(() => {
|
||||||
|
try {
|
||||||
|
// Add print-specific stylesheet temporarily
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'print-style';
|
||||||
|
style.textContent = `
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 10mm;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content, .dialog-content * {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
position: absolute !important;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
padding: 15mm !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
background-color: white !important;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: visible !important;
|
||||||
|
display: block !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove any black backgrounds */
|
||||||
|
html, body {
|
||||||
|
background-color: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimize spacing for single page */
|
||||||
|
.print-section {
|
||||||
|
margin-bottom: 2mm !important;
|
||||||
|
page-break-inside: avoid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce vertical spacing */
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 1mm !important;
|
||||||
|
margin-top: 1mm !important;
|
||||||
|
color: #1e40af !important; /* blue-800 */
|
||||||
|
font-weight: bold !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-compact-text {
|
||||||
|
font-size: 9pt !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-compact-spacing > * {
|
||||||
|
margin-top: 1mm !important;
|
||||||
|
margin-bottom: 1mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 1fr 1fr !important;
|
||||||
|
gap: 1mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-print {
|
||||||
|
background-color: #dbeafe !important; /* blue-100 */
|
||||||
|
color: #1e40af !important; /* blue-800 */
|
||||||
|
border: 1px solid #93c5fd !important; /* blue-300 */
|
||||||
|
padding: 0.5mm 1mm !important;
|
||||||
|
margin: 0.5mm !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
font-size: 8pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More compact spacing */
|
||||||
|
.print-compact-margin {
|
||||||
|
margin: 1mm 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-smaller-font {
|
||||||
|
font-size: 8pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-print {
|
||||||
|
background-color: #1e40af !important; /* blue-800 */
|
||||||
|
color: #ffffff !important;
|
||||||
|
padding: 2mm 3mm !important;
|
||||||
|
margin-bottom: 2mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More compact header */
|
||||||
|
.header-print h1 {
|
||||||
|
font-size: 12pt !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-print p {
|
||||||
|
font-size: 9pt !important;
|
||||||
|
margin-top: 1mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer stays at bottom */
|
||||||
|
.footer-print {
|
||||||
|
font-size: 7pt !important;
|
||||||
|
color: #6b7280 !important; /* gray-500 */
|
||||||
|
border-top: 1px solid #e5e7eb !important; /* gray-200 */
|
||||||
|
position: fixed !important;
|
||||||
|
bottom: 10mm !important;
|
||||||
|
left: 15mm !important;
|
||||||
|
right: 15mm !important;
|
||||||
|
padding-top: 2mm !important;
|
||||||
|
background-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide any unnecessary elements */
|
||||||
|
.print-hide {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimize description text */
|
||||||
|
.print-description {
|
||||||
|
max-height: 100px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Wait a bit to ensure styles are applied
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
|
||||||
|
// Remove the style after printing dialog closes
|
||||||
|
setTimeout(() => {
|
||||||
|
const printStyle = document.getElementById('print-style');
|
||||||
|
if (printStyle) {
|
||||||
|
printStyle.remove();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('printJobStarted'),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error printing:", error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('printingFailed'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [t, toast]);
|
||||||
|
|
||||||
|
return { handlePrint };
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export * from './IncidentDetailDialog';
|
||||||
|
export * from './IncidentDetailHeader';
|
||||||
|
export * from './IncidentDetailContent';
|
||||||
|
export * from './IncidentDetailSections';
|
||||||
|
export * from './IncidentDetailFooter';
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { getAffectedSystemsArray } from './utils';
|
||||||
|
|
||||||
|
interface AffectedSystemsSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AffectedSystemsSection: React.FC<AffectedSystemsSectionProps> = ({ incident }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('affectedSystems')}</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('systems')}</h4>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{getAffectedSystemsArray(incident.affected_systems).map((system, idx) => (
|
||||||
|
<Badge key={idx} variant="outline">{system}</Badge>
|
||||||
|
))}
|
||||||
|
{getAffectedSystemsArray(incident.affected_systems).length === 0 &&
|
||||||
|
<span className="text-muted-foreground italic">{t('noSystems')}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={
|
||||||
|
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
incident.impact?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(incident.impact?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={
|
||||||
|
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
incident.priority?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(incident.priority?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { User } from '@/services/userService';
|
||||||
|
import { getUserInitials } from './utils';
|
||||||
|
|
||||||
|
interface AssignmentSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssignmentSection: React.FC<AssignmentSectionProps> = ({ incident, assignedUser }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('assignment')}</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
{assignedUser ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
|
||||||
|
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{assignedUser.full_name || assignedUser.username}</span>
|
||||||
|
</div>
|
||||||
|
) : incident.assigned_to ? (
|
||||||
|
<span>{incident.assigned_to}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { User } from '@/services/userService';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { IncidentStatusBadge } from '../../IncidentStatusBadge';
|
||||||
|
import { getUserInitials } from './utils';
|
||||||
|
|
||||||
|
interface BasicInfoSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BasicInfoSection: React.FC<BasicInfoSectionProps> = ({ incident, assignedUser }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 print-compact-text">
|
||||||
|
<h3 className="font-semibold text-lg">{t('basicInfo')}</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 print-grid">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('title')}</h4>
|
||||||
|
<p className="mt-1">{incident.title || '-'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('status')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<IncidentStatusBadge status={incident.status || incident.impact_status || 'investigating'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('serviceId')}</h4>
|
||||||
|
<p className="mt-1">{incident.service_id || '-'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
{assignedUser ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
|
||||||
|
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{assignedUser.full_name || assignedUser.username}</span>
|
||||||
|
</div>
|
||||||
|
) : incident.assigned_to ? (
|
||||||
|
<span>{incident.assigned_to}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('description')}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line print-description">{incident.description || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface ImpactAnalysisSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImpactAnalysisSection: React.FC<ImpactAnalysisSectionProps> = ({ incident }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('impactAnalysis')}</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={
|
||||||
|
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
incident.impact?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(incident.impact?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={
|
||||||
|
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
incident.priority?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(incident.priority?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
|
||||||
|
interface ResolutionSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResolutionSection: React.FC<ResolutionSectionProps> = ({ incident }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('resolutionDetails')}</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('rootCause')}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line">{incident.root_cause || '-'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionSteps')}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line">{incident.resolution_steps || '-'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('lessonsLearned')}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line">{incident.lessons_learned || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { formatDate } from './utils';
|
||||||
|
|
||||||
|
interface TimelineSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TimelineSection: React.FC<TimelineSectionProps> = ({ incident }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('timeline')}</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('created')}</h4>
|
||||||
|
<p className="mt-1">{formatDate(incident.created)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('lastUpdated')}</h4>
|
||||||
|
<p className="mt-1">{formatDate(incident.updated)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('incidentTime')}</h4>
|
||||||
|
<p className="mt-1">{formatDate(incident.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionTime')}</h4>
|
||||||
|
<p className="mt-1">{formatDate(incident.resolution_time)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
// Export all section components
|
||||||
|
export * from './BasicInfoSection';
|
||||||
|
export * from './TimelineSection';
|
||||||
|
export * from './AffectedSystemsSection';
|
||||||
|
export * from './ResolutionSection';
|
||||||
|
export * from './utils';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
import { User } from '@/services/userService';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
// Helper function to get user initials
|
||||||
|
export const getUserInitials = (user: User): string => {
|
||||||
|
if (user.full_name) {
|
||||||
|
const nameParts = user.full_name.split(' ');
|
||||||
|
if (nameParts.length > 1) {
|
||||||
|
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return user.full_name.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return user.username.substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date helper
|
||||||
|
export const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
try {
|
||||||
|
return format(new Date(dateStr), 'PPp');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', dateStr, error);
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get affected systems helper
|
||||||
|
export const getAffectedSystemsArray = (systems?: string) => {
|
||||||
|
if (!systems) return [];
|
||||||
|
return systems.split(',').map(system => system.trim()).filter(Boolean);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
export const IncidentAffectedFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { control } = useFormContext<IncidentFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="affected_systems"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('affectedSystems')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('enterAffectedSystems')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('separateSystemsWithComma')}</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="root_cause"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('rootCause')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('enterRootCause')}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { X, Users } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
|
||||||
|
export const IncidentBasicFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const form = useFormContext<IncidentFormValues>();
|
||||||
|
const currentAssignedUserId = form.watch('assigned_to');
|
||||||
|
|
||||||
|
// For assigned users functionality
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
// Update selectedUserIds when form value changes (for edit mode)
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentAssignedUserId) {
|
||||||
|
setSelectedUserIds([currentAssignedUserId]);
|
||||||
|
}
|
||||||
|
}, [currentAssignedUserId]);
|
||||||
|
|
||||||
|
// Fetch users for assignment
|
||||||
|
const { data: users = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const usersList = await userService.getUsers();
|
||||||
|
console.log("Fetched users for incident assignment:", usersList);
|
||||||
|
return Array.isArray(usersList) ? usersList : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch users:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 300000 // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add user to assigned_to
|
||||||
|
const addUser = (userId: string) => {
|
||||||
|
// For now, we're using a single user assignment
|
||||||
|
console.log("Setting user ID in form:", userId);
|
||||||
|
form.setValue('assigned_to', userId, { shouldValidate: true, shouldDirty: true });
|
||||||
|
setSelectedUserIds([userId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove assigned user
|
||||||
|
const removeUser = () => {
|
||||||
|
form.setValue('assigned_to', '', { shouldValidate: true, shouldDirty: true });
|
||||||
|
setSelectedUserIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get selected user data
|
||||||
|
const selectedUser = users.find(user => user.id === form.getValues('assigned_to'));
|
||||||
|
|
||||||
|
// Function to get user initials from name
|
||||||
|
const getUserInitials = (user: any): string => {
|
||||||
|
if (user.full_name) {
|
||||||
|
const nameParts = user.full_name.split(' ');
|
||||||
|
if (nameParts.length > 1) {
|
||||||
|
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return user.full_name.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return user.username.substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('title')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('enterIncidentTitle')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('description')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('enterIncidentDescription')}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="service_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('serviceId')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('enterServiceId')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="assigned_to"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" /> {t('assignedTo')}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
id="assigned-user-select"
|
||||||
|
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedValue = e.target.value;
|
||||||
|
console.log("Selected user ID:", selectedValue);
|
||||||
|
if (selectedValue) {
|
||||||
|
addUser(selectedValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
disabled={field.disabled}
|
||||||
|
name={field.name}
|
||||||
|
>
|
||||||
|
<option value="">{t('selectAssignedUser')}</option>
|
||||||
|
{users.map(user => (
|
||||||
|
<option
|
||||||
|
key={user.id}
|
||||||
|
value={user.id}
|
||||||
|
>
|
||||||
|
{user.full_name || user.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{selectedUser ? (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2 border p-2 rounded-md bg-muted/50">
|
||||||
|
<Badge key={selectedUser.id} variant="secondary" className="flex items-center gap-1 py-1 px-2">
|
||||||
|
<Avatar className="h-5 w-5 mr-1">
|
||||||
|
<AvatarImage src={selectedUser.avatar} alt={selectedUser.full_name || selectedUser.username} />
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
{getUserInitials(selectedUser)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{selectedUser.full_name || selectedUser.username}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 p-0 ml-1 hover:bg-transparent hover:opacity-70"
|
||||||
|
onClick={removeUser}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
<span className="sr-only">{t('remove')}</span>
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic p-2">
|
||||||
|
{t('noAssignedUser')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export const IncidentConfigFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { control } = useFormContext<IncidentFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('status')}</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('selectIncidentStatus')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="investigating">{t('investigating')}</SelectItem>
|
||||||
|
<SelectItem value="identified">{t('identified')}</SelectItem>
|
||||||
|
<SelectItem value="found_root_cause">{t('foundRootCause')}</SelectItem>
|
||||||
|
<SelectItem value="in_progress">{t('inProgress')}</SelectItem>
|
||||||
|
<SelectItem value="monitoring">{t('monitoring')}</SelectItem>
|
||||||
|
<SelectItem value="resolved">{t('resolved')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="impact"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-2">
|
||||||
|
<FormLabel>{t('impact')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
className="flex space-x-1"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="minor" id="minor" />
|
||||||
|
<Label htmlFor="minor">{t('minor')}</Label>
|
||||||
|
</div>
|
||||||
|
<span>•</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="major" id="major" />
|
||||||
|
<Label htmlFor="major">{t('major')}</Label>
|
||||||
|
</div>
|
||||||
|
<span>•</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="critical" id="critical" />
|
||||||
|
<Label htmlFor="critical">{t('critical')}</Label>
|
||||||
|
</div>
|
||||||
|
<span>•</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="none" id="none" />
|
||||||
|
<Label htmlFor="none">{t('none')}</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('priority')}</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('selectPriorityLevel')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">{t('low')}</SelectItem>
|
||||||
|
<SelectItem value="medium">{t('medium')}</SelectItem>
|
||||||
|
<SelectItem value="high">{t('high')}</SelectItem>
|
||||||
|
<SelectItem value="critical">{t('critical')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
export const IncidentDetailsFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { control } = useFormContext<IncidentFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="resolution_steps"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('resolutionSteps')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('enterResolutionSteps')}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="lessons_learned"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('lessonsLearned')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('enterLessonsLearned')}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
export * from './IncidentBasicFields';
|
||||||
|
export * from './IncidentAffectedFields';
|
||||||
|
export * from './IncidentConfigFields';
|
||||||
|
export * from './IncidentDetailsFields';
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { incidentService, IncidentItem } from '@/services/incident';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { incidentFormSchema, IncidentFormValues } from './useIncidentForm';
|
||||||
|
|
||||||
|
export const useIncidentEditForm = (
|
||||||
|
incident: IncidentItem,
|
||||||
|
onSuccess: () => void,
|
||||||
|
onClose: () => void
|
||||||
|
) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Initialize form with existing incident data
|
||||||
|
const form = useForm<IncidentFormValues>({
|
||||||
|
resolver: zodResolver(incidentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: incident.title || '',
|
||||||
|
description: incident.description || '',
|
||||||
|
affected_systems: incident.affected_systems || '',
|
||||||
|
status: (incident.status?.toLowerCase() || incident.impact_status?.toLowerCase() || 'investigating') as any,
|
||||||
|
impact: (incident.impact?.toLowerCase() || 'minor') as any,
|
||||||
|
priority: (incident.priority?.toLowerCase() || 'medium') as any,
|
||||||
|
service_id: incident.service_id || '',
|
||||||
|
assigned_to: incident.assigned_to || '',
|
||||||
|
root_cause: incident.root_cause || '',
|
||||||
|
resolution_steps: incident.resolution_steps || '',
|
||||||
|
lessons_learned: incident.lessons_learned || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: IncidentFormValues) => {
|
||||||
|
try {
|
||||||
|
console.log("Form data for update:", data);
|
||||||
|
console.log("Assigned user ID for update:", data.assigned_to);
|
||||||
|
|
||||||
|
await incidentService.updateIncident(incident.id, {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
status: data.status,
|
||||||
|
affected_systems: data.affected_systems,
|
||||||
|
impact: data.impact,
|
||||||
|
priority: data.priority,
|
||||||
|
service_id: data.service_id,
|
||||||
|
assigned_to: data.assigned_to, // This is the user ID from the form
|
||||||
|
root_cause: data.root_cause,
|
||||||
|
resolution_steps: data.resolution_steps,
|
||||||
|
lessons_learned: data.lessons_learned,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('incidentUpdated'),
|
||||||
|
description: t('incidentUpdatedDesc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating incident:', error);
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: `${t('errorUpdatingIncident')}: ${error.message}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorUpdatingIncident'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
onSubmit: form.handleSubmit(onSubmit),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { pb } from '@/lib/pocketbase';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { incidentService, CreateIncidentInput } from '@/services/incident';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
// Define the schema for our incident form
|
||||||
|
export const incidentFormSchema = z.object({
|
||||||
|
title: z.string().min(1, { message: 'Title is required' }),
|
||||||
|
description: z.string().min(1, { message: 'Description is required' }),
|
||||||
|
affected_systems: z.string().min(1, { message: 'Affected systems are required' }),
|
||||||
|
status: z.enum(['investigating', 'found_root_cause', 'in_progress', 'monitoring', 'resolved']),
|
||||||
|
impact: z.enum(['none', 'minor', 'major', 'critical']),
|
||||||
|
priority: z.enum(['low', 'medium', 'high', 'critical']),
|
||||||
|
service_id: z.string().optional(),
|
||||||
|
assigned_to: z.string().optional(),
|
||||||
|
root_cause: z.string().optional(),
|
||||||
|
resolution_steps: z.string().optional(),
|
||||||
|
lessons_learned: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IncidentFormValues = z.infer<typeof incidentFormSchema>;
|
||||||
|
|
||||||
|
export const useIncidentForm = (onSuccess: () => void, onClose: () => void) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<IncidentFormValues>({
|
||||||
|
resolver: zodResolver(incidentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
affected_systems: '',
|
||||||
|
status: 'investigating',
|
||||||
|
impact: 'minor',
|
||||||
|
priority: 'medium',
|
||||||
|
service_id: '',
|
||||||
|
assigned_to: '',
|
||||||
|
root_cause: '',
|
||||||
|
resolution_steps: '',
|
||||||
|
lessons_learned: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: IncidentFormValues) => {
|
||||||
|
try {
|
||||||
|
console.log("Form data before submission:", data);
|
||||||
|
|
||||||
|
const formattedData: CreateIncidentInput = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
status: data.status,
|
||||||
|
affected_systems: data.affected_systems,
|
||||||
|
impact: data.impact,
|
||||||
|
priority: data.priority,
|
||||||
|
service_id: data.service_id,
|
||||||
|
assigned_to: data.assigned_to,
|
||||||
|
root_cause: data.root_cause,
|
||||||
|
resolution_steps: data.resolution_steps,
|
||||||
|
lessons_learned: data.lessons_learned,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
created_by: pb.authStore.model?.id || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Formatted data for API:", formattedData);
|
||||||
|
|
||||||
|
await incidentService.createIncident(formattedData);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('incidentCreated'),
|
||||||
|
description: t('incidentCreatedDesc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Incident created successfully, about to call onSuccess and onClose");
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
onClose();
|
||||||
|
onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating incident:', error);
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('Error details:', error.message);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: `${t('errorCreatingIncident')}: ${error.message}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorCreatingIncident'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
onSubmit: form.handleSubmit(onSubmit),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
export * from './IncidentTable';
|
||||||
|
export * from './IncidentActionsMenu';
|
||||||
|
export * from './IncidentStatusBadge';
|
||||||
|
export * from './IncidentStatusDropdown';
|
||||||
|
export * from './CreateIncidentDialog';
|
||||||
|
export * from './EditIncidentDialog';
|
||||||
|
export * from './EmptyIncidentState';
|
||||||
|
export * from './detail-dialog';
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
import { IncidentTableRow } from './IncidentTableRow';
|
||||||
|
import { IncidentDetailDialog } from '../detail-dialog/IncidentDetailDialog';
|
||||||
|
import { EditIncidentDialog } from '../EditIncidentDialog';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentTableSkeleton } from './IncidentTableSkeleton';
|
||||||
|
|
||||||
|
interface IncidentTableProps {
|
||||||
|
data: IncidentItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
onViewDetails?: (incident: IncidentItem) => void;
|
||||||
|
onEditIncident?: (incident: IncidentItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentTable = ({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
onIncidentUpdated,
|
||||||
|
onViewDetails,
|
||||||
|
onEditIncident
|
||||||
|
}: IncidentTableProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
|
||||||
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
|
|
||||||
|
const formatDate = useCallback((dateString: string | undefined) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), 'PPp');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', dateString, error);
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getAffectedSystemsArray = useCallback((affectedSystems: string | undefined): string[] => {
|
||||||
|
if (!affectedSystems) return [];
|
||||||
|
return affectedSystems.split(',').map(system => system.trim()).filter(Boolean);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewDetails = useCallback((incident: IncidentItem) => {
|
||||||
|
setSelectedIncident(incident);
|
||||||
|
setIsDetailOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditIncident = useCallback((incident: IncidentItem) => {
|
||||||
|
setSelectedIncident(incident);
|
||||||
|
setIsEditOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle status updates efficiently
|
||||||
|
const handleIncidentUpdated = useCallback(() => {
|
||||||
|
console.log("Incident updated in IncidentTable, propagating event");
|
||||||
|
onIncidentUpdated();
|
||||||
|
}, [onIncidentUpdated]);
|
||||||
|
|
||||||
|
// Handle dialog closing
|
||||||
|
const handleDetailDialogClose = useCallback((open: boolean) => {
|
||||||
|
setIsDetailOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
onIncidentUpdated();
|
||||||
|
}
|
||||||
|
}, [onIncidentUpdated]);
|
||||||
|
|
||||||
|
// Handle edit dialog closing
|
||||||
|
const handleEditDialogClose = useCallback((open: boolean) => {
|
||||||
|
setIsEditOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
onIncidentUpdated();
|
||||||
|
}
|
||||||
|
}, [onIncidentUpdated]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <IncidentTableSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a safety check to prevent map of undefined error
|
||||||
|
if (!data || !Array.isArray(data)) {
|
||||||
|
console.error('Data is not an array:', data);
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<p>No incident data available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t('title')}</TableHead>
|
||||||
|
<TableHead>{t('status')}</TableHead>
|
||||||
|
<TableHead>{t('priority')}</TableHead>
|
||||||
|
<TableHead>{t('time')}</TableHead>
|
||||||
|
<TableHead>{t('affected')}</TableHead>
|
||||||
|
<TableHead>{t('impact')}</TableHead>
|
||||||
|
<TableHead>{t('assignedTo')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((item) => (
|
||||||
|
<IncidentTableRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
formatDate={formatDate}
|
||||||
|
getAffectedSystemsArray={getAffectedSystemsArray}
|
||||||
|
onViewDetails={onViewDetails || handleViewDetails}
|
||||||
|
onEditIncident={onEditIncident || handleEditIncident}
|
||||||
|
onIncidentUpdated={handleIncidentUpdated}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Incident detail dialog */}
|
||||||
|
<IncidentDetailDialog
|
||||||
|
open={isDetailOpen}
|
||||||
|
onOpenChange={handleDetailDialogClose}
|
||||||
|
incident={selectedIncident}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit incident dialog */}
|
||||||
|
{selectedIncident && (
|
||||||
|
<EditIncidentDialog
|
||||||
|
open={isEditOpen}
|
||||||
|
onOpenChange={handleEditDialogClose}
|
||||||
|
incident={selectedIncident}
|
||||||
|
onIncidentUpdated={onIncidentUpdated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
|
||||||
|
import React, { memo, useState } from 'react';
|
||||||
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { IncidentStatusDropdown } from '../IncidentStatusDropdown';
|
||||||
|
import { IncidentActionsMenu } from '../IncidentActionsMenu';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { AssignedUserCell } from './IncidentTableUtils';
|
||||||
|
|
||||||
|
interface IncidentTableRowProps {
|
||||||
|
item: IncidentItem;
|
||||||
|
formatDate: (date: string | undefined) => string;
|
||||||
|
getAffectedSystemsArray: (systems: string | undefined) => string[];
|
||||||
|
onViewDetails?: (incident: IncidentItem) => void;
|
||||||
|
onEditIncident?: (incident: IncidentItem) => void;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentTableRow = memo(({
|
||||||
|
item,
|
||||||
|
formatDate,
|
||||||
|
getAffectedSystemsArray,
|
||||||
|
onViewDetails,
|
||||||
|
onEditIncident,
|
||||||
|
onIncidentUpdated,
|
||||||
|
t
|
||||||
|
}: IncidentTableRowProps) => {
|
||||||
|
// Use local state for optimistic UI updates
|
||||||
|
const [localItem, setLocalItem] = useState(item);
|
||||||
|
|
||||||
|
// Update local state when props change
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLocalItem(item);
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
// Handle status updates locally
|
||||||
|
const handleStatusUpdated = () => {
|
||||||
|
console.log("Status updated in TableRow, calling onIncidentUpdated");
|
||||||
|
onIncidentUpdated();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={localItem.id}
|
||||||
|
className="hover:bg-muted/40 cursor-pointer"
|
||||||
|
onClick={() => onViewDetails && onViewDetails(localItem)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium max-w-[200px] truncate">
|
||||||
|
{localItem.title || localItem.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<IncidentStatusDropdown
|
||||||
|
status={localItem.impact_status || localItem.status || 'investigating'}
|
||||||
|
id={localItem.id}
|
||||||
|
onStatusUpdated={handleStatusUpdated}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={
|
||||||
|
localItem.priority?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
localItem.priority?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
localItem.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(localItem.priority?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(localItem.created)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{getAffectedSystemsArray(localItem.affected_systems).map((system, idx) => (
|
||||||
|
<Badge key={`${localItem.id}-system-${idx}`} variant="outline">{system}</Badge>
|
||||||
|
))}
|
||||||
|
{getAffectedSystemsArray(localItem.affected_systems).length === 0 && '-'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={
|
||||||
|
localItem.impact?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
localItem.impact?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
localItem.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(localItem.impact?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AssignedUserCell userId={localItem.assigned_to} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex justify-end items-center space-x-2">
|
||||||
|
{onViewDetails && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewDetails(localItem);
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t('view')}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<IncidentActionsMenu
|
||||||
|
item={localItem}
|
||||||
|
onIncidentUpdated={onIncidentUpdated}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
onEditIncident={onEditIncident}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
IncidentTableRow.displayName = 'IncidentTableRow';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export const IncidentTableRowSkeleton = () => (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[180px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[100px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[80px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[120px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[150px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[80px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[100px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[60px]" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IncidentTableSkeleton = () => (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<TableRow>
|
||||||
|
{Array(3).fill(0).map((_, index) => (
|
||||||
|
<IncidentTableRowSkeleton key={`skeleton-${index}`} />
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user