105 Commits

Author SHA1 Message Date
Tola Leng f88cc32cf9 Merge pull request #52 from operacle/develop
Implement SSL certificate checker in Go instead of TS and feat: Add error message and details columns
2025-06-20 20:24:55 +07:00
Tola Leng 918a248b8f feat: Add error message and details columns
Adds error_message and details columns to the Incident History table in the service detail page.
2025-06-20 20:13:18 +07:00
Tola Leng 458f5f7d82 Update Go Configuration Port and change default timeout to 10s 2025-06-20 19:44:02 +07:00
Tola Leng 5ceaa09710 Add SSL certificate checker
Implement SSL certificate checking functionality
2025-06-20 19:42:24 +07:00
Tola Leng 1eabf66311 Fix: Improve response time chart
Improve the Response Time History chart by separating and ordering the 'down', 'warning', and 'up' statuses to prevent overlap and improve positioning.
2025-06-20 19:09:32 +07:00
Tola Leng a0c0a3ed75 Fix: Separate up/down graph areas
Separate the up and down areas in the response time history graph to improve clarity and correct the display of the down graph.
2025-06-20 18:53:35 +07:00
Tola Leng abb129bde0 Removes the "Add Certificate" button from the SSL Certificates table header. 2025-06-20 17:42:10 +07:00
Tola Leng 220be3bea4 Fix: Improve SSL certificate details and View Action button 2025-06-20 17:26:13 +07:00
Tola Leng 614c10188d feat: Add check interval and check_at update
- Added a "Check Interval" field to the SSL certificate create/edit forms.
- Implemented functionality to update the `check_at` field in Pocketbase with the current time when the "Check" button is clicked.
2025-06-20 17:17:34 +07:00
Tola Leng 11408dcba0 Remove SSL Checker service
Remove the SSL Checker service from the React application, as the SSL checks are now handled by the Go backend. This involves removing the `sslCheckerService.ts` file and related imports.
2025-06-20 16:54:31 +07:00
Tola Leng 9f4e759b01 Merge pull request #51 from operacle/develop
Implement CheckCle Microservice Operation
2025-06-19 17:51:39 +07:00
Tola Leng d3ea5dc77c Implement CheckCle Microservice Operation
A Go-based microservice for service operations including ICMP ping, DNS resolution, and TCP connectivity
2025-06-19 17:49:30 +07:00
Tola Leng 89572c44b7 Updated an entrypoint to start service-operation 2025-06-19 17:43:42 +07:00
Tola Leng dfe98f9fa6 Fix: Pass serviceType to ServiceUptimeHistory and
Refactor service detail page data fetching
2025-06-19 17:35:12 +07:00
Tola Leng 549a3793a5 Fix: Uptime history timestamp and service ID 2025-06-19 15:23:48 +07:00
Tola Leng 9ed4d4c7ac Merge pull request #50 from operacle/develop
feat: New Create Service Form and  Split UptimeBar.tsx into smaller components
2025-06-19 15:04:07 +07:00
Tola Leng cca2338468 Refactor: Split UptimeBar.tsx into smaller components 2025-06-19 15:01:06 +07:00
Tola Leng c1c74bb2a8 Refactor: Split UptimeBar.tsx into smaller components 2025-06-19 14:50:22 +07:00
Tola Leng a2ae66fe76 Fix: Display uptime history in service table 2025-06-19 14:48:58 +07:00
Tola Leng 907fdaa0dc Fix: Display uptime history in service table 2025-06-19 14:47:20 +07:00
Tola Leng 93b58cfa2c feat: Display service details in table
Display host, domain, or URL under the service name in the service table.
2025-06-19 14:22:17 +07:00
Tola Leng 879622b364 feat: Separate host and port for TCP services 2025-06-18 23:16:39 +07:00
Tola Leng 2f52010963 Fix: Expand service form dialog 2025-06-18 23:16:29 +07:00
Tola Leng c90f0dd963 Fix: Expand service form dialog 2025-06-18 23:16:11 +07:00
Tola Leng 4a14f64e07 feat: Separate host and port for TCP services 2025-06-18 23:15:56 +07:00
Tola Leng 19b4de78d1 feat: Separate host and port for TCP services 2025-06-18 23:15:22 +07:00
Tola Leng bdf648c2fe Add custom check interval option 2025-06-18 23:15:08 +07:00
Tola Leng 0705fc89b4 Refactor: Move service type selection 2025-06-18 23:14:55 +07:00
Tola Leng 883a541b9b feat: Separate host and port for TCP services 2025-06-18 23:14:04 +07:00
Tola Leng 0758b1d24b feat: Separate host and port for TCP services 2025-06-18 23:13:14 +07:00
Tola Leng 9a001f6aec Merge pull request #49 from operacle/develop
Remove HTTP service check and Refactor: Split settings API into modules
2025-06-18 22:09:40 +07:00
Tola Leng 9c78def48d Fix: Navigate to service detail page 2025-06-18 16:51:27 +08:00
Tola Leng 94e2c0da77 Remove HTTP service check
Remove HTTP service check functionality as the React application is now frontend only.
2025-06-18 16:47:00 +08:00
Tola Leng 78674f758c Refactor: Split settings API into modules 2025-06-17 21:51:26 +08:00
Tola Leng 3a6ab2258a Fix: Use correct API path for test email 2025-06-17 21:50:46 +08:00
Tola Leng 1daaf067d9 Refactor: Split settings API into modules 2025-06-17 21:50:34 +08:00
Tola Leng 6d1b8387ca Fix: Use correct API path for test email 2025-06-17 21:49:43 +08:00
Tola Leng e3f9111484 Refactor: Move test email button and dialog 2025-06-17 21:39:12 +08:00
Tola Leng 575869c50a Fix: Use correct API endpoint for test email 2025-06-17 21:39:00 +08:00
Tola Leng cfdedb9636 Merge pull request #45 from operacle/develop
Fix: Implement public status page functionality
2025-06-15 20:49:51 +07:00
Tola Leng 08b00728c2 Fix: Import ErrorBoundary correctly 2025-06-15 21:47:03 +08:00
Tola Leng 4486a79e58 Fix: Implement public status page functionality 2025-06-15 21:28:47 +08:00
Tola Leng 41fcaaaf05 Fix: Public status page loading state 2025-06-15 21:28:17 +08:00
Tola Leng ddcabac564 Fix: Syntax errors in public status page 2025-06-15 21:27:58 +08:00
Tola Leng 4e0a9953db Fix: Implement public status page functionality 2025-06-15 21:27:48 +08:00
Tola Leng f25fa7de83 Fix: Syntax errors in public status page 2025-06-15 21:27:33 +08:00
Tola Leng aea70d0023 Fix: Public status page loading state 2025-06-15 21:27:19 +08:00
Tola Leng 4e07c8c932 Refactor: Remove theme options in Edit Operational Page dialogs 2025-06-13 22:03:05 +08:00
Tola Leng 5f18fde775 Refactor: Remove theme options in Create Operational Page dialogs 2025-06-13 22:02:57 +08:00
Tola Leng 9d5fa805c9 Merge pull request #42 from operacle/develop
Implement password reset functionality.
2025-06-12 17:15:08 +07:00
Tola Leng e1d466a638 feat: Implement password reset API calls
Updated the `ForgotPasswordDialog` component to use the provided API paths for requesting and confirming password resets.
2025-06-12 18:08:51 +08:00
Tola Leng 8dc3bd6a0d Implement password reset functionality.
Added password reset request and confirmation to the login page, using the provided API endpoints.
2025-06-12 18:08:36 +08:00
Tola Leng 6d63d9f485 feat: Add logo and remove elements in login page 2025-06-11 20:23:45 +08:00
Tola Leng 68a4ad5478 Merge pull request #38 from operacle/develop
Refactor: Split Sidebar.tsx into smaller components
2025-06-08 21:46:27 +07:00
Tola Leng 895da83999 Fixed Authentication Persistence 2025-06-08 22:44:17 +08:00
Tola Leng f6592d331f Refactor: Split Sidebar.tsx into smaller components
Refactored the Sidebar component into smaller, more manageable files to improve code organization and maintainability.

Fix sidebar auto-expansion issue
Ensured the sidebar's collapsed state is maintained when navigating between pages.
2025-06-08 21:56:09 +08:00
Tola Leng da0808cefe Updated: Security Improvements Improved and Secure Login.
- Input Validation: Added comprehensive client-side validation with proper error messages
- Rate Limiting: Added attempt tracking to prevent brute force attacks
- Secure Error Handling: Removed detailed error logging and implemented generic error messages
- Production Security: Conditional API endpoint switching (disabled in production)
- Enhanced UX: Better loading states, form validation, and accessibility
- Password Security: Improved password visibility toggle with proper ARIA labels
- Form Security: Added autocomplete attributes and proper form submission handling
2025-06-07 23:20:04 +08:00
Tola Leng 1e77f2747e Added CheckCle Logo into the login page 2025-06-07 23:10:43 +08:00
Tola Leng 9b1918e25a Merge pull request #36 from operacle/develop
feat: Implement Operational and Public Status Page Feature
2025-06-06 19:23:54 +07:00
Tola Leng 99e988e316 📖 Updated one-click installation command 2025-06-06 20:11:28 +08:00
Tola Leng 419e9118f9 Updated Data Structure for Operational Page Features 2025-06-06 18:57:41 +08:00
Tola Leng e2c2705378 feat: Implement Operational Page in menu 2025-06-06 18:54:18 +08:00
Tola Leng 82fa9a4ac2 Add components to operational page form 2025-06-06 18:54:05 +08:00
Tola Leng 576f2fbb34 feat: Display existing components in edit form 2025-06-06 18:53:28 +08:00
Tola Leng 554faefe4a Add delete and edit functionality to Operational Pages 2025-06-06 18:53:12 +08:00
Tola Leng c92e9fe442 Implement view public page functionality 2025-06-06 18:52:35 +08:00
Tola Leng ede5c36ac4 feat: Maintain dashboard layout in Operational Page 2025-06-06 18:52:27 +08:00
Tola Leng 9097a618cd feat: Display existing components in edit form 2025-06-06 18:51:49 +08:00
Tola Leng f4f6cb48db Add delete and edit functionality to Operational Pages 2025-06-06 18:51:21 +08:00
Tola Leng 1264d293bf Implement view public page functionality 2025-06-06 18:49:54 +08:00
Tola Leng 8608b6a263 feat: Display 90-day uptime history 2025-06-06 18:49:11 +08:00
Tola Leng 86e1e70e22 feat: Display 90-day uptime history.
Updated the public status page to display 90 days of uptime history and added detailed incident history information on hover.
2025-06-06 18:48:49 +08:00
Tola Leng dd2c64420d feat: Implement a modern and professional Operational Page 2025-06-06 18:46:50 +08:00
Tola Leng ba47c1d953 feat: Implement Operational Page in menu 2025-06-06 18:43:14 +08:00
Tola Leng ba6dc41d95 📝 Correct and standard version matches the official SPDX MIT License 2025-06-06 18:02:18 +08:00
Tola Leng 1dc25cc461 Merge pull request #35 from operacle/develop
feat: Add mail test functionality
2025-06-06 16:48:46 +07:00
Tola Leng 11ddc77e32 Updated: one-click installation bash script that includes the output information after the container starts, such as the admin web management URL and default login credentials 2025-06-06 17:42:18 +08:00
Tola Leng 5a5772f9a4 Fix: Remove unused test functions and imports 2025-06-05 21:22:47 +08:00
Tola Leng 039a832b79 feat: Add mail test functionality
Adds a dialog to test email settings and integrates with the `/api/settings/test/email` API endpoint.
- Added a "Test Email" button to the Mail Settings tab.
- Implemented a dialog for entering the recipient email and selecting a template.
- Added UI elements for the dialog (input fields, radio buttons, and a send button).
- Updated `src/services/settingsService.ts` to include the test email API call.
- Added UI components for the dialog (using existing UI components).
- Updated `src/components/settings/general/MailSettingsTab.tsx` to include the test email functionality.
2025-06-05 21:06:48 +08:00
Tola Leng a88b356bf5 feat: Add New Translation for mail test functionality in the dialog to test email settings 2025-06-05 21:05:51 +08:00
Tola Leng 2d798e2983 Fix: Test Email dialog and remove Test Connection button 2025-06-05 20:59:46 +08:00
Tola Leng 426f41e337 Updated German translation for SMTP password field in Mail Settings 2025-06-05 18:43:47 +08:00
Tola Leng 4dc005c7e4 Merge pull request #33 from operacle/develop
Add SMTP password field in Mail Settings and  Fix: Ensure Add Service dialog respects theme
2025-06-05 17:40:17 +07:00
Tola Leng 4ef84b5469 Add SMTP password field.
Added a password field to the Mail Settings tab in the General Settings panel, ensuring it is saved and retrieved via the `/api/settings` endpoint.
2025-06-05 18:33:58 +08:00
Tola Leng 0739d6b0ce Updated translation for SMTP password field in Mail Settings 2025-06-05 18:33:13 +08:00
Tola Leng bd17ef7140 Fix: Apply theme to service action dialog.
Ensured the service action dialog (three-dot menu) in uptime monitoring respects the current theme (dark/light mode) by using theme-aware styling.
2025-06-04 22:09:08 +08:00
Tola Leng 518b469c04 Fix: Ensure Add Service dialog respects theme 2025-06-04 21:36:14 +08:00
Tola Leng 6652ac5e76 Fix: Improve service dialog theme support 2025-06-04 21:34:52 +08:00
Tola Leng 4ec0c7a8fa Merge pull request #32 from operacle/develop
Fix: Improve email change flow and auto-logout and feat: Implement uptime monitoring data retention
2025-06-04 16:33:42 +07:00
Tola Leng 0b52e7ffa9 📖 Updated Architecture Support & Project Development Roadmap 2025-06-04 17:15:42 +08:00
Tola Leng dd758b9334 Merge pull request #27 from operacle/develop
feat: Implement uptime monitoring data retention
2025-05-31 17:44:43 +07:00
Tola Leng 9b1167955e feat: Implement uptime monitoring data retention
Ensured data is cleared from uptime_data and ping_data collections based on retention settings. Added separate manual shrink database buttons for uptime and server monitoring retention in the data retention settings panel.
2025-05-29 22:16:50 +08:00
Tola Leng 611380a5e4 Fix: Uptime history respects interval
Refactor UptimeBar to display history respecting the service's interval, ensuring bars represent checks within the set time frame.
2025-05-29 21:31:00 +08:00
Tola Leng 0b9bc76d37 Fix: Improve email change flow and auto-logout
- Improved the email change flow in user management and profile dashboards.
- Added a success message upon successful email change.
- Implemented auto-logout after a successful email change.
- Fixed potential errors during the email change process.
2025-05-29 20:41:00 +08:00
Tola Leng 0562adbef8 Adjusted date range calculation to ensure data is displayed 2025-05-29 20:10:29 +08:00
Tola Leng d4d4040cc8 Removed the date filter component 2025-05-29 20:10:03 +08:00
Tola Leng aec7118496 Merge pull request #25 from operacle/develop
feat: Auto logout after password change
2025-05-28 15:37:53 +07:00
Tola Leng c17d902b9a Fixed: in About System page, make it auto-updates with each new release on GitHub without any hardcoded values 2025-05-28 15:35:36 +08:00
Tola Leng a5ad5eef42 ⛁ Updated Default User 2025-05-28 15:19:25 +08:00
Tola Leng c7846a2fed 📖 Updated Project Development Roadmap 2025-05-28 15:11:06 +08:00
Tola Leng 4ad001b560 feat: Auto logout after password change
Automatically log the user out after a successful password change in the user profile dashboard.
2025-05-28 15:08:39 +08:00
Tola Leng d52842a8ea Refactor: Remove settings fields
Remove "Hide Controls", "Sender Name", and "Sender Email Address" from the System Settings tab in General Settings.
2025-05-27 23:38:02 +08:00
Tola Leng 9e02eea0d1 Updated: one-click installation bash script that includes the output information after the container starts, such as the admin web management URL and default login credentials 2025-05-27 16:51:45 +08:00
Michael Ortner a1d2f1a9e0 [FIX] Update common.ts - fixed typo and forogt some transaltions. 2025-05-27 14:07:20 +07:00
Michael Ortner 230f5562c7 [ADD] Added the german translation for the strings 2025-05-27 14:07:20 +07:00
177 changed files with 11578 additions and 2399 deletions
+18 -14
View File
@@ -1,17 +1,21 @@
The MIT License (MIT) MIT License
Copyright (c) 2025, 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.
+33 -16
View File
@@ -24,8 +24,19 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
## #️⃣ Getting Started ## #️⃣ Getting Started
### Installation with Docker Run and Compose ### Current Architecture Support
1. Copy ready docker run command * ✅ 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
curl -fsSL https://checkcle.io/install.sh | bash
```
2. Install with docker run. Just copy ready docker run command below
```bash ```bash
docker run -d \ docker run -d \
--name checkcle \ --name checkcle \
@@ -36,7 +47,7 @@ docker run -d \
operacle/checkcle:latest operacle/checkcle:latest
``` ```
2. Docker Compose - Recommended 3. Install with Docker compose Configuration.
```bash ```bash
version: '3.9' version: '3.9'
@@ -71,23 +82,25 @@ services:
## 📝 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
- [x] CheckCle Website - CheckCle Website
- [x] CheckCle Demo Server - CheckCle Demo Server
- [x] SSL & Domain Monitoring - SSL & Domain Monitoring
- [x] Schedule Maintenance - Schedule Maintenance
- [x] Incident Management - Incident Management
- [ ] Uptime monitoring (PING - Inprogress) - [ ] Uptime monitoring (PING - Inprogress)
- [ ] Infrastructure Server Monitoring - [ ] Infrastructure Server Monitoring
- [ ] 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)
- [x] Open-source release with full documentation - ✅ Data Retention & Automate Strink (Muti Options to Shrink Data & Database )
- ✅ 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
@@ -122,4 +135,8 @@ CheckCle is released under the MIT License.
--- ---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=operacle/checkcle&type=Date)](https://www.star-history.com/#operacle/checkcle&Date)
Stay informed, stay online, with CheckCle! 🌐 Stay informed, stay online, with CheckCle! 🌐
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

+50 -111
View File
@@ -1,125 +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 SslDomain from "./pages/SslDomain"; import Settings from '@/pages/Settings';
import ScheduleIncident from "./pages/ScheduleIncident"; 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="*" element={<NotFound />} />
<Route </Routes>
path="/service/:id" </Router>
element={ <Toaster />
<ProtectedRoute> </ErrorBoundary>
<ServiceDetail /> </SidebarProvider>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
<Route
path="/ssl-domain"
element={
<ProtectedRoute>
<SslDomain />
</ProtectedRoute>
}
/>
<Route
path="/schedule-incident"
element={
<ProtectedRoute>
<ScheduleIncident />
</ProtectedRoute>
}
/>
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
</LanguageProvider> </LanguageProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
); );
}; }
export default App; export default App;
+2 -2
View File
@@ -17,9 +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') { } else if (path === '/api/settings' || path.startsWith('/api/settings/')) {
console.log("Routing to settings handler"); console.log("Routing to settings handler");
return await settingsApi(body); return await settingsApi(body, path);
} }
// Return 404 for unknown routes // Return 404 for unknown routes
@@ -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' },
};
}
};
+35 -108
View File
@@ -1,114 +1,41 @@
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
const settingsApi = async (body: any) => { import { getSettings } from './actions/getSettings';
try { import { updateSettings } from './actions/updateSettings';
const { action, data } = body; import { testEmailConnection } from './actions/testEmailConnection';
console.log('Settings API called with action:', action, 'data:', data); import { testEmail } from './actions/testEmail';
const authToken = pb.authStore.token; /**
const headers: Record<string, string> = { * Settings API handler
'Content-Type': 'application/json', */
}; 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);
if (authToken) { switch (action) {
headers['Authorization'] = `Bearer ${authToken}`; case 'getSettings':
} return await getSettings();
const baseUrl = getCurrentEndpoint(); case 'updateSettings':
return await updateSettings(body.data);
switch (action) {
case 'getSettings': case 'testEmailConnection':
try { return await testEmailConnection(body.data);
const response = await fetch(`${baseUrl}/api/settings`, {
method: 'GET', default:
headers, console.error('Unknown action:', action);
}); return {
status: 400,
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); json: { success: false, message: 'Unknown action' },
};
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' },
};
}
case 'updateSettings':
try {
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' },
};
}
case 'testEmailConnection':
try {
const response = await fetch(`${baseUrl}/api/settings/test-email`, {
method: 'POST',
headers,
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' },
};
}
default:
return {
status: 400,
json: { success: false, message: 'Invalid action' },
};
}
} catch (error) {
console.error('Unexpected error in settingsApi:', error);
return {
status: 500,
json: { success: false, message: 'Internal server error' },
};
} }
}; };
+25
View File
@@ -0,0 +1,25 @@
export interface SettingsApiRequest {
action: string;
data?: any;
}
export interface SettingsApiResponse {
status: number;
json: {
success: boolean;
data?: any;
message?: string;
};
}
export interface SmtpSettings {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
authMethod?: string;
tls?: boolean;
localName?: string;
}
+24
View File
@@ -0,0 +1,24 @@
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
export const getAuthHeaders = (): Record<string, string> => {
const authToken = pb.authStore.token;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
};
export const getBaseUrl = (): string => {
return getCurrentEndpoint();
};
export const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
@@ -0,0 +1,49 @@
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center p-6">
<h2 className="text-2xl font-bold text-destructive mb-4">Something went wrong</h2>
<p className="text-muted-foreground mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,259 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from '@/components/ui/use-toast';
import { Mail, ArrowLeft } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
import { getCurrentEndpoint } from '@/lib/pocketbase';
interface ForgotPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) {
const [step, setStep] = useState<'request' | 'confirm'>('request');
const [email, setEmail] = useState('');
const [token, setToken] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [loading, setLoading] = useState(false);
const { t } = useLanguage();
const handleRequestReset = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const apiUrl = getCurrentEndpoint();
const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to send reset email');
}
toast({
title: "Reset Email Sent",
description: "Please check your email for password reset instructions.",
});
setStep('confirm');
} catch (error) {
console.error('Password reset request error:', error);
toast({
variant: "destructive",
title: "Reset Failed",
description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.",
});
} finally {
setLoading(false);
}
};
const handleConfirmReset = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== passwordConfirm) {
toast({
variant: "destructive",
title: "Password Mismatch",
description: "Passwords do not match. Please try again.",
});
return;
}
if (password.length < 6) {
toast({
variant: "destructive",
title: "Password Too Short",
description: "Password must be at least 6 characters long.",
});
return;
}
setLoading(true);
try {
const apiUrl = getCurrentEndpoint();
const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
password,
passwordConfirm
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to reset password');
}
toast({
title: "Password Reset Successful",
description: "Your password has been reset successfully. You can now log in with your new password.",
});
onOpenChange(false);
// Reset form state
setStep('request');
setEmail('');
setToken('');
setPassword('');
setPasswordConfirm('');
} catch (error) {
console.error('Password reset confirmation error:', error);
toast({
variant: "destructive",
title: "Reset Failed",
description: error instanceof Error ? error.message : "Failed to reset password. Please try again.",
});
} finally {
setLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset form state when closing
setStep('request');
setEmail('');
setToken('');
setPassword('');
setPasswordConfirm('');
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
</DialogTitle>
<DialogDescription>
{step === 'request'
? 'Enter your email address and we\'ll send you a reset link.'
: 'Enter the reset token from your email and your new password.'
}
</DialogDescription>
</DialogHeader>
{step === 'request' ? (
<form onSubmit={handleRequestReset} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
Email Address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Mail className="h-4 w-4 text-muted-foreground" />
</div>
<Input
id="reset-email"
placeholder="your.email@provider.com"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10"
/>
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !email}
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
>
{loading ? 'Sending...' : 'Send Reset Email'}
</Button>
</div>
</form>
) : (
<form onSubmit={handleConfirmReset} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
Reset Token
</label>
<Input
id="reset-token"
placeholder="Enter token from email"
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
New Password
</label>
<Input
id="new-password"
placeholder="••••••••••••"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
Confirm Password
</label>
<Input
id="confirm-password"
placeholder="••••••••••••"
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required
minLength={6}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setStep('request')}
className="flex-1"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Button
type="submit"
disabled={loading || !token || !password || !passwordConfirm}
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
>
{loading ? 'Resetting...' : 'Reset Password'}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
+13 -132
View File
@@ -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
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) => { return (
setActiveSettingsItem(item); <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`}>
}; <SidebarHeader collapsed={collapsed} />
<MainNavigation collapsed={collapsed} />
const getMenuItemClasses = (isActive: boolean) => { <SettingsPanel collapsed={collapsed} />
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`; </div>
}; );
// New larger icon size for the main menu
const mainIconSize = "h-6 w-6";
return <div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}>
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
<span className="text-white font-bold">C</span>
</div>
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
</div>
<nav className="my-2 mx-1 py-1 px-1">
<Link to="/dashboard" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/dashboard' ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Globe className={`${mainIconSize} text-purple-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("uptimeMonitoring")}</span>}
</Link>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Boxes className={`${mainIconSize} text-blue-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
</div>
<Link to="/ssl-domain" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/ssl-domain' ? 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`}>
<Radar className={`${mainIconSize} text-cyan-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
</Link>
<Link to="/schedule-incident" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/schedule-incident' ? 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`}>
<Calendar className={`${mainIconSize} text-emerald-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</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`}>
<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,17 +84,31 @@ 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) {
errorMessage = error.message; if (error.message.includes("Failed to authenticate")) {
errorMessage = "Current password is incorrect. Please try again.";
} else if (error.message.includes("User not found")) {
errorMessage = "User account not found. Please contact your administrator.";
} else {
errorMessage = error.message;
}
} }
toast({ toast({
@@ -188,4 +226,4 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
</form> </form>
</Form> </Form>
); );
} }
@@ -11,6 +11,7 @@ import { useToast } from "@/hooks/use-toast";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { AlertCircle, CheckCircle } from "lucide-react"; import { AlertCircle, CheckCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; 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({
@@ -33,10 +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 [emailChangeRequested, setEmailChangeRequested] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null); const [updateError, setUpdateError] = useState<string | null>(null);
const [updateSuccess, setUpdateSuccess] = 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>({
@@ -52,7 +53,6 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
setIsSubmitting(true); setIsSubmitting(true);
setUpdateError(null); setUpdateError(null);
setUpdateSuccess(null); setUpdateSuccess(null);
setEmailChangeRequested(false);
try { try {
console.log("Submitting profile update with data:", data); console.log("Submitting profile update with data:", data);
@@ -75,19 +75,25 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
// Update user data using the userService // Update user data using the userService
await userService.updateUser(user.id, updateData); await userService.updateUser(user.id, updateData);
// Refresh user data in auth context // If email was changed, show success message and auto-logout
await authService.refreshUserData();
// If email was changed, show a specific message
if (isEmailChanged) { if (isEmailChanged) {
setEmailChangeRequested(true); setUpdateSuccess("Email changed successfully! You will be logged out for security reasons. Please log in again with your new email.");
setUpdateSuccess("A verification email has been sent to your new email address. Please check your inbox to complete the change.");
toast({ toast({
title: "Email verification sent", title: "Email changed successfully",
description: "A verification email has been sent to your new email address. Please check your inbox and follow the instructions to complete the change.", description: "You will be logged out for security reasons. Please log in again with your new email.",
variant: "default", variant: "default",
}); });
// Auto-logout after 3 seconds
setTimeout(() => {
authService.logout();
navigate("/login");
}, 3000);
} else { } else {
// Refresh user data in auth context for other field changes
await authService.refreshUserData();
setUpdateSuccess("Your profile information has been updated successfully."); setUpdateSuccess("Your profile information has been updated successfully.");
toast({ toast({
title: "Profile updated", title: "Profile updated",
@@ -132,16 +138,6 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{emailChangeRequested && (
<Alert className="bg-yellow-50 border-yellow-200 text-yellow-800">
<AlertCircle className="h-4 w-4 text-yellow-600" />
<AlertDescription>
A verification email has been sent to your new email address.
Please check your inbox and follow the instructions to complete the change.
</AlertDescription>
</Alert>
)}
<FormField <FormField
control={form.control} control={form.control}
@@ -183,7 +179,7 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
<FormMessage /> <FormMessage />
{field.value !== user.email && ( {field.value !== user.email && (
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Changing your email requires verification. A verification email will be sent. Changing your email will log you out for security reasons. You will need to log in again with your new email.
</p> </p>
)} )}
</FormItem> </FormItem>
@@ -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
};
};
@@ -25,10 +25,10 @@ export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps)
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col"> <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl">Create New Service</DialogTitle> <DialogTitle className="text-xl">Create New Service</DialogTitle>
<DialogDescription className="text-gray-400"> <DialogDescription>
Fill in the details to create a new service to monitor. Fill in the details to create a new service to monitor.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -40,4 +40,4 @@ export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps)
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -14,18 +14,17 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { format, subDays, subHours, subMonths, subWeeks, subYears } from "date-fns"; import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react"; import { Calendar as CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type DateRangeOption = '60min' | '24h' | '7d' | '30d' | '1y' | 'custom'; export type DateRangeOption = '24h' | '7d' | '30d' | '1y' | 'custom';
interface DateRangeFilterProps { interface DateRangeFilterProps {
onRangeChange: (startDate: Date, endDate: Date, option: DateRangeOption) => void; onRangeChange: (startDate: Date, endDate: Date, option: DateRangeOption) => void;
selectedOption?: DateRangeOption; selectedOption?: DateRangeOption;
} }
// Define a proper type for the date range
interface DateRange { interface DateRange {
from: Date | undefined; from: Date | undefined;
to: Date | undefined; to: Date | undefined;
@@ -45,57 +44,48 @@ export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateR
const now = new Date(); const now = new Date();
let startDate: Date; let startDate: Date;
let endDate: Date = new Date(now.getTime() + (5 * 60 * 1000)); // Add 5 minutes buffer to future
switch (option) { switch (option) {
case '60min':
// Ensure we're getting exactly 60 minutes ago
startDate = new Date(now.getTime() - 60 * 60 * 1000);
console.log(`60min option selected: ${startDate.toISOString()} to ${now.toISOString()}`);
break;
case '24h': case '24h':
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); startDate = new Date(now.getTime() - (24 * 60 * 60 * 1000));
break; break;
case '7d': case '7d':
startDate = subDays(now, 7); startDate = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
break; break;
case '30d': case '30d':
startDate = subDays(now, 30); startDate = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
break; break;
case '1y': case '1y':
startDate = subYears(now, 1); startDate = new Date(now.getTime() - (365 * 24 * 60 * 60 * 1000));
break; break;
case 'custom': case 'custom':
// Don't trigger onRangeChange for custom until both dates are selected
setIsCalendarOpen(true); setIsCalendarOpen(true);
return; return;
default: default:
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); startDate = new Date(now.getTime() - (24 * 60 * 60 * 1000)); // Default to 24 hours
} }
console.log(`DateRangeFilter: Option changed to ${option}, date range: ${startDate.toISOString()} to ${now.toISOString()}`); console.log(`DateRangeFilter: ${option} selected, range: ${startDate.toISOString()} to ${endDate.toISOString()}`);
onRangeChange(startDate, now, option); onRangeChange(startDate, endDate, option);
}; };
// Handle custom date range selection
const handleCustomRangeSelect = (range: DateRange | undefined) => { const handleCustomRangeSelect = (range: DateRange | undefined) => {
if (!range) { if (!range || !range.from || !range.to) {
return; return;
} }
setCustomDateRange(range); setCustomDateRange(range);
if (range.from && range.to) { const startOfDay = new Date(range.from);
// Ensure that we have both from and to dates before triggering the change startOfDay.setHours(0, 0, 0, 0);
const startOfDay = new Date(range.from);
startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(range.to);
endOfDay.setHours(23, 59, 59, 999);
const endOfDay = new Date(range.to);
endOfDay.setHours(23, 59, 59, 999); console.log(`Custom range: ${startOfDay.toISOString()} to ${endOfDay.toISOString()}`);
onRangeChange(startOfDay, endOfDay, 'custom');
console.log(`DateRangeFilter: Custom range selected: ${startOfDay.toISOString()} to ${endOfDay.toISOString()}`); setIsCalendarOpen(false);
onRangeChange(startOfDay, endOfDay, 'custom');
setIsCalendarOpen(false);
}
}; };
return ( return (
@@ -105,7 +95,6 @@ export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateR
<SelectValue placeholder="Select time range" /> <SelectValue placeholder="Select time range" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="60min">Last 60 minutes</SelectItem>
<SelectItem value="24h">Last 24 hours</SelectItem> <SelectItem value="24h">Last 24 hours</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem> <SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem> <SelectItem value="30d">Last 30 days</SelectItem>
@@ -153,4 +142,4 @@ export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateR
)} )}
</div> </div>
); );
} }
@@ -37,10 +37,31 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
date: format(timestamp, 'MMM dd, yyyy'), date: format(timestamp, 'MMM dd, yyyy'),
value: data.status === "paused" ? null : data.responseTime, value: data.status === "paused" ? null : data.responseTime,
status: data.status, status: data.status,
// Separate values for different statuses with proper positioning
upValue: data.status === "up" ? data.responseTime : null,
downValue: data.status === "down" ? data.responseTime : null,
warningValue: data.status === "warning" ? data.responseTime : null,
}; };
}); });
}, [uptimeData]); }, [uptimeData]);
// Calculate Y-axis domain for better positioning
const yAxisDomain = useMemo(() => {
if (!chartData.length) return ['dataMin - 10', 'dataMax + 10'];
const allValues = chartData
.filter(d => d.value !== null && d.status !== 'paused')
.map(d => d.value);
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1 || 10;
return [Math.max(0, minValue - padding), maxValue + padding];
}, [chartData]);
// Create a custom tooltip for the chart // Create a custom tooltip for the chart
const CustomTooltip = ({ active, payload, label }: any) => { const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
@@ -79,31 +100,6 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
return null; return null;
}; };
// Compute status segments for different areas
const getStatusSegments = () => {
const segments = {
up: [] as any[],
down: [] as any[],
warning: [] as any[]
};
chartData.forEach(point => {
if (point.status === "paused") return;
if (point.status === "up") {
segments.up.push(point);
} else if (point.status === "down") {
segments.down.push(point);
} else if (point.status === "warning") {
segments.warning.push(point);
}
});
return segments;
};
const segments = getStatusSegments();
// Check if we have any data to display - be more lenient by checking raw uptimeData // Check if we have any data to display - be more lenient by checking raw uptimeData
const hasData = uptimeData.length > 0; const hasData = uptimeData.length > 0;
@@ -172,46 +168,40 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
<YAxis <YAxis
stroke={theme === 'dark' ? '#666' : '#9ca3af'} stroke={theme === 'dark' ? '#666' : '#9ca3af'}
allowDecimals={false} allowDecimals={false}
domain={['dataMin - 10', 'dataMax + 10']} domain={yAxisDomain}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
{/* Area charts for different statuses */} {/* Separate area charts for each status - positioned closer together */}
{segments.up.length > 0 && ( <Area
<Area type="monotone"
type="monotone" dataKey="upValue"
dataKey="value" stroke="#10b981"
data={segments.up} strokeWidth={2}
stroke="#10b981" fillOpacity={0.4}
fillOpacity={1} fill="url(#colorUp)"
fill="url(#colorUp)" connectNulls={false}
connectNulls />
/>
)}
{segments.down.length > 0 && ( <Area
<Area type="monotone"
type="monotone" dataKey="downValue"
dataKey="value" stroke="#ef4444"
data={segments.down} strokeWidth={2}
stroke="#ef4444" fillOpacity={0.4}
fillOpacity={1} fill="url(#colorDown)"
fill="url(#colorDown)" connectNulls={false}
connectNulls />
/>
)}
{segments.warning.length > 0 && ( <Area
<Area type="monotone"
type="monotone" dataKey="warningValue"
dataKey="value" stroke="#f59e0b"
data={segments.warning} strokeWidth={2}
stroke="#f59e0b" fillOpacity={0.4}
fillOpacity={1} fill="url(#colorWarning)"
fill="url(#colorWarning)" connectNulls={false}
connectNulls />
/>
)}
{/* Add reference lines for paused periods */} {/* Add reference lines for paused periods */}
{chartData.map((entry, index) => {chartData.map((entry, index) =>
@@ -231,4 +221,4 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
</CardContent> </CardContent>
</Card> </Card>
); );
} }
@@ -55,7 +55,8 @@ export const useRealTimeUpdates = ({
setUptimeData(prev => { setUptimeData(prev => {
const newData: UptimeData = { const newData: UptimeData = {
id: e.record.id, id: e.record.id,
serviceId: e.record.service_id, service_id: e.record.service_id, // Include service_id
serviceId: e.record.service_id, // Keep for backward compatibility
timestamp: e.record.timestamp, timestamp: e.record.timestamp,
status: e.record.status, status: e.record.status,
responseTime: e.record.response_time || 0, responseTime: e.record.response_time || 0,
@@ -84,4 +85,4 @@ export const useRealTimeUpdates = ({
console.error("Error setting up real-time updates:", error); console.error("Error setting up real-time updates:", error);
} }
}, [serviceId, startDate, endDate, setService, setUptimeData]); }, [serviceId, startDate, endDate, setService, setUptimeData]);
}; };
@@ -5,6 +5,7 @@ import { Service, UptimeData } from "@/types/service.types";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { uptimeService } from "@/services/uptimeService"; import { uptimeService } from "@/services/uptimeService";
import { DateRangeOption } from "../../DateRangeFilter";
export const useServiceData = (serviceId: string | undefined, startDate: Date, endDate: Date) => { export const useServiceData = (serviceId: string | undefined, startDate: Date, endDate: Date) => {
const [service, setService] = useState<Service | null>(null); const [service, setService] = useState<Service | null>(null);
@@ -13,15 +14,12 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
// Handler for service status changes
const handleStatusChange = async (newStatus: "up" | "down" | "paused" | "warning") => { const handleStatusChange = async (newStatus: "up" | "down" | "paused" | "warning") => {
if (!service || !serviceId) return; if (!service || !serviceId) return;
try { try {
// Optimistic UI update
setService({ ...service, status: newStatus as Service["status"] }); setService({ ...service, status: newStatus as Service["status"] });
// Update the service status in PocketBase
await pb.collection('services').update(serviceId, { await pb.collection('services').update(serviceId, {
status: newStatus status: newStatus
}); });
@@ -32,7 +30,6 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
}); });
} catch (error) { } catch (error) {
console.error("Failed to update service status:", error); console.error("Failed to update service status:", error);
// Revert the optimistic update
setService(prevService => prevService); setService(prevService => prevService);
toast({ toast({
@@ -43,51 +40,36 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
} }
}; };
// Function to fetch uptime data with date filters const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange?: DateRangeOption | string) => {
const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange: string) => {
try { try {
console.log(`Fetching uptime data from ${start.toISOString()} to ${end.toISOString()}`); if (!service) {
console.log('No service data available for uptime fetch');
return [];
}
console.log(`Fetching uptime data: ${start.toISOString()} to ${end.toISOString()} for range: ${selectedRange}, service type: ${service.type}`);
// Set appropriate limits based on time range to ensure enough granularity let limit = 500; // Default limit
let limit = 200; // default
// Adjust limits based on selected range if (selectedRange === '24h') {
if (selectedRange === '60min') { limit = 300;
limit = 300; // More points for shorter time ranges
} else if (selectedRange === '24h') {
limit = 200;
} else if (selectedRange === '7d') { } else if (selectedRange === '7d') {
limit = 250; limit = 400;
} else if (selectedRange === '30d' || selectedRange === '1y') {
limit = 300; // More points for longer time ranges
} }
console.log(`Using limit ${limit} for range ${selectedRange}`); console.log(`Using limit ${limit} for range ${selectedRange}`);
const history = await uptimeService.getUptimeHistory(serviceId, limit, start, end); // Use the service type to fetch from the correct collection
console.log(`Fetched ${history.length} uptime records for time range ${selectedRange}`); const history = await uptimeService.getUptimeHistory(serviceId, limit, start, end, service.type);
console.log(`Retrieved ${history.length} uptime records from collection for ${service.type} service`);
if (history.length === 0) { // Sort by timestamp (newest first)
console.log("No data returned from API, checking if we need to fetch with a higher limit"); const filteredHistory = [...history].sort((a, b) =>
// If no data, try with a higher limit as fallback
if (limit < 500) {
const extendedHistory = await uptimeService.getUptimeHistory(serviceId, 500, start, end);
console.log(`Fallback: Fetched ${extendedHistory.length} uptime records with higher limit`);
if (extendedHistory.length > 0) {
setUptimeData(extendedHistory);
return extendedHistory;
}
}
}
// Sort data by timestamp (newest first)
const sortedHistory = [...history].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
); );
setUptimeData(sortedHistory); setUptimeData(filteredHistory);
return sortedHistory; return filteredHistory;
} catch (error) { } catch (error) {
console.error("Error fetching uptime data:", error); console.error("Error fetching uptime data:", error);
toast({ toast({
@@ -110,7 +92,6 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
setIsLoading(true); setIsLoading(true);
// Add a timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Request timed out")), 10000); setTimeout(() => reject(new Error("Request timed out")), 10000);
}); });
@@ -122,6 +103,9 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
id: serviceData.id, id: serviceData.id,
name: serviceData.name, name: serviceData.name,
url: serviceData.url || "", url: serviceData.url || "",
host: serviceData.host || "",
port: serviceData.port || undefined,
domain: serviceData.domain || "",
type: serviceData.service_type || serviceData.type || "HTTP", type: serviceData.service_type || serviceData.type || "HTTP",
status: serviceData.status || "paused", status: serviceData.status || "paused",
responseTime: serviceData.response_time || serviceData.responseTime || 0, responseTime: serviceData.response_time || serviceData.responseTime || 0,
@@ -134,10 +118,11 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
alerts: serviceData.alerts || "unmuted" alerts: serviceData.alerts || "unmuted"
}; };
console.log(`Loaded service: ${formattedService.name} (${formattedService.type})`);
setService(formattedService); setService(formattedService);
// Fetch uptime history with date range // Fetch initial uptime history with 24h default - wait for service to be set
await fetchUptimeData(serviceId, startDate, endDate, '24h'); await new Promise(resolve => setTimeout(resolve, 100)); // Small delay to ensure state is updated
} catch (error) { } catch (error) {
console.error("Error fetching service:", error); console.error("Error fetching service:", error);
toast({ toast({
@@ -154,12 +139,13 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
fetchServiceData(); fetchServiceData();
}, [serviceId, navigate, toast]); }, [serviceId, navigate, toast]);
// Update data when date range changes // Update data when date range changes or when service is loaded
useEffect(() => { useEffect(() => {
if (serviceId && !isLoading) { if (serviceId && !isLoading && service) {
console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`);
fetchUptimeData(serviceId, startDate, endDate, '24h'); fetchUptimeData(serviceId, startDate, endDate, '24h');
} }
}, [startDate, endDate]); }, [startDate, endDate, serviceId, isLoading, service]);
return { return {
service, service,
@@ -170,4 +156,4 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
handleStatusChange, handleStatusChange,
fetchUptimeData fetchUptimeData
}; };
}; };
@@ -1,4 +1,3 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { DateRangeOption } from "../DateRangeFilter"; import { DateRangeOption } from "../DateRangeFilter";
@@ -12,13 +11,17 @@ export const ServiceDetailContainer = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
// Ensure we use exact timestamp for startDate // Set default to 24h
const [startDate, setStartDate] = useState<Date>(() => { const [startDate, setStartDate] = useState<Date>(() => {
const date = new Date(); const date = new Date();
date.setHours(date.getHours() - 24); date.setHours(date.getHours() - 24); // Go back 24 hours
return date;
});
const [endDate, setEndDate] = useState<Date>(() => {
const date = new Date();
date.setMinutes(date.getMinutes() + 5); // Add 5 minutes buffer to future
return date; return date;
}); });
const [endDate, setEndDate] = useState<Date>(new Date());
const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h'); const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h');
// State for sidebar collapse functionality (shared with Dashboard) // State for sidebar collapse functionality (shared with Dashboard)
@@ -91,14 +94,16 @@ export const ServiceDetailContainer = () => {
// Handle date range filter changes // Handle date range filter changes
const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => { const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => {
console.log(`Date range changed: ${start.toISOString()} to ${end.toISOString()}, option: ${option}`); console.log(`ServiceDetailContainer: Date range changed: ${start.toISOString()} to ${end.toISOString()}, option: ${option}`);
// Update state which will trigger the useEffect in useServiceData
setStartDate(start); setStartDate(start);
setEndDate(end); setEndDate(end);
setSelectedRange(option); setSelectedRange(option);
// Refetch uptime data with new date range, passing the selected range option // Also explicitly fetch data with the new range to ensure immediate update
if (id) { if (id) {
console.log(`ServiceDetailContainer: Explicitly fetching data for service ${id} with new range`);
fetchUptimeData(id, start, end, option); fetchUptimeData(id, start, end, option);
} }
}, [id, fetchUptimeData]); }, [id, fetchUptimeData]);
@@ -123,4 +128,4 @@ export const ServiceDetailContainer = () => {
)} )}
</ServiceDetailWrapper> </ServiceDetailWrapper>
); );
}; };
@@ -33,10 +33,17 @@ export const ServiceDetailContent = ({
<div className="mb-4 md:mb-6 mt-6 md:mt-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 md:gap-0"> <div className="mb-4 md:mb-6 mt-6 md:mt-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 md:gap-0">
<h2 className="text-lg md:text-xl font-medium">Response Time History</h2> <h2 className="text-lg md:text-xl font-medium">Response Time History</h2>
<DateRangeFilter <div className="flex flex-col md:flex-row items-start md:items-center gap-2">
onRangeChange={onDateRangeChange} <span className="text-sm text-muted-foreground">
selectedOption={selectedDateOption} Collection: {service.type.toLowerCase() === 'ping' || service.type.toLowerCase() === 'icmp' ? 'ping_data' :
/> service.type.toLowerCase() === 'dns' ? 'dns_data' :
service.type.toLowerCase() === 'tcp' ? 'tcp_data' : 'uptime_data'}
</span>
<DateRangeFilter
onRangeChange={onDateRangeChange}
selectedOption={selectedDateOption}
/>
</div>
</div> </div>
{!hasUptimeData && ( {!hasUptimeData && (
@@ -61,4 +68,4 @@ export const ServiceDetailContent = ({
</div> </div>
</div> </div>
); );
}; };
@@ -51,10 +51,10 @@ export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDi
onOpenChange(newOpen); onOpenChange(newOpen);
} }
}}> }}>
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col"> <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl">Edit Service</DialogTitle> <DialogTitle className="text-xl">Edit Service</DialogTitle>
<DialogDescription className="text-gray-400"> <DialogDescription>
Update the details of your monitored service. Update the details of your monitored service.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -74,4 +74,4 @@ export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDi
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -38,6 +38,7 @@ export function ServiceForm({
name: "", name: "",
type: "http", type: "http",
url: "", url: "",
port: "",
interval: "60", interval: "60",
retries: "3", retries: "3",
notificationChannel: "", notificationChannel: "",
@@ -49,19 +50,41 @@ export function ServiceForm({
// Populate form when initialData changes (separate from initialization) // Populate form when initialData changes (separate from initialization)
useEffect(() => { useEffect(() => {
if (initialData && isEdit) { if (initialData && isEdit) {
// Ensure the type is one of the allowed values
const serviceType = (initialData.type || "http").toLowerCase();
const validType = ["http", "ping", "tcp", "dns"].includes(serviceType)
? serviceType as "http" | "ping" | "tcp" | "dns"
: "http";
// For PING services, use host field; for DNS use domain field; for TCP use host field; others use url
let urlValue = "";
let portValue = "";
if (validType === "ping") {
urlValue = initialData.host || "";
} else if (validType === "dns") {
urlValue = initialData.domain || "";
} else if (validType === "tcp") {
urlValue = initialData.host || "";
portValue = String(initialData.port || "");
} else {
urlValue = initialData.url || "";
}
// Reset the form with initial data values // Reset the form with initial data values
form.reset({ form.reset({
name: initialData.name || "", name: initialData.name || "",
type: (initialData.type || "http").toLowerCase(), type: validType,
url: initialData.url || "", url: urlValue,
port: portValue,
interval: String(initialData.interval || 60), interval: String(initialData.interval || 60),
retries: String(initialData.retries || 3), retries: String(initialData.retries || 3),
notificationChannel: initialData.notificationChannel || "", notificationChannel: initialData.notificationChannel === "none" ? "" : initialData.notificationChannel || "",
alertTemplate: initialData.alertTemplate || "", alertTemplate: initialData.alertTemplate === "default" ? "" : initialData.alertTemplate || "",
}); });
// Log for debugging // Log for debugging
console.log("Populating form with URL:", initialData.url); console.log("Populating form with data:", { type: validType, url: urlValue, port: portValue });
} }
}, [initialData, isEdit, form]); }, [initialData, isEdit, form]);
@@ -74,17 +97,28 @@ export function ServiceForm({
try { try {
console.log("Form data being submitted:", data); // Debug log for submitted data console.log("Form data being submitted:", data); // Debug log for submitted data
// Prepare service data with proper field mapping
const serviceData = {
name: data.name,
type: data.type,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel === "none" ? "" : data.notificationChannel,
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
// Map the URL field to appropriate database field based on service type
...(data.type === "dns"
? { domain: data.url, url: "", host: "", port: undefined } // DNS: store in domain field
: data.type === "ping"
? { host: data.url, url: "", domain: "", port: undefined } // PING: store in host field
: data.type === "tcp"
? { host: data.url, port: parseInt(data.port || "80"), url: "", domain: "" } // TCP: store in host and port fields
: { url: data.url, domain: "", host: "", port: undefined } // HTTP: store in url field
)
};
if (isEdit && initialData) { if (isEdit && initialData) {
// Update existing service // Update existing service
await serviceService.updateService(initialData.id, { await serviceService.updateService(initialData.id, serviceData);
name: data.name,
type: data.type,
url: data.url,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel === "none" ? "" : data.notificationChannel,
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
});
toast({ toast({
title: "Service updated", title: "Service updated",
@@ -92,15 +126,7 @@ export function ServiceForm({
}); });
} else { } else {
// Create new service // Create new service
await serviceService.createService({ await serviceService.createService(serviceData);
name: data.name,
type: data.type,
url: data.url,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel === "none" ? undefined : data.notificationChannel,
alertTemplate: data.alertTemplate === "default" ? undefined : data.alertTemplate,
});
toast({ toast({
title: "Service created", title: "Service created",
@@ -127,10 +153,24 @@ export function ServiceForm({
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6">
<ServiceBasicFields form={form} /> <div className="space-y-6">
<ServiceTypeField form={form} /> <div className="space-y-4">
<ServiceConfigFields form={form} /> <h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Basic Information</h3>
<ServiceNotificationFields form={form} /> <ServiceBasicFields form={form} />
<ServiceTypeField form={form} />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Configuration</h3>
<ServiceConfigFields form={form} />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Notifications</h3>
<ServiceNotificationFields form={form} />
</div>
</div>
<ServiceFormActions <ServiceFormActions
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onCancel={onCancel} onCancel={onCancel}
@@ -139,4 +179,4 @@ export function ServiceForm({
</form> </form>
</Form> </Form>
); );
} }
@@ -56,7 +56,13 @@ export const ServiceRow = ({
<ServiceRowResponseTime responseTime={service.responseTime} /> <ServiceRowResponseTime responseTime={service.responseTime} />
</TableCell> </TableCell>
<TableCell className="w-52 py-4"> <TableCell className="w-52 py-4">
<UptimeBar uptime={service.uptime} status={service.status} serviceId={service.id} /> <UptimeBar
uptime={service.uptime}
status={service.status}
serviceId={service.id}
interval={service.interval}
serviceType={service.type}
/>
</TableCell> </TableCell>
<TableCell className="py-4"> <TableCell className="py-4">
<LastCheckedTime <LastCheckedTime
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { UptimeData } from "@/types/service.types"; import { UptimeData, Service } from "@/types/service.types";
import { uptimeService } from "@/services/uptimeService"; import { uptimeService } from "@/services/uptimeService";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -10,20 +10,25 @@ import { Check, X, AlertTriangle, Pause } from "lucide-react";
interface ServiceUptimeHistoryProps { interface ServiceUptimeHistoryProps {
serviceId: string; serviceId: string;
serviceType: string; // Add service type to determine collection
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
} }
export function ServiceUptimeHistory({ export function ServiceUptimeHistory({
serviceId, serviceId,
serviceType,
startDate = new Date(Date.now() - 24 * 60 * 60 * 1000), startDate = new Date(Date.now() - 24 * 60 * 60 * 1000),
endDate = new Date() endDate = new Date()
}: ServiceUptimeHistoryProps) { }: ServiceUptimeHistoryProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const { data: uptimeHistory, isLoading, error } = useQuery({ const { data: uptimeHistory, isLoading, error } = useQuery({
queryKey: ['uptimeHistory', serviceId, startDate?.toISOString(), endDate?.toISOString()], queryKey: ['uptimeHistory', serviceId, serviceType, startDate?.toISOString(), endDate?.toISOString()],
queryFn: () => uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate), queryFn: () => {
enabled: !!serviceId, console.log(`ServiceUptimeHistory: Fetching for service ${serviceId} of type ${serviceType}`);
return uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate, serviceType);
},
enabled: !!serviceId && !!serviceType,
refetchInterval: 5000, // Refresh UI every 5 seconds refetchInterval: 5000, // Refresh UI every 5 seconds
}); });
@@ -135,4 +140,4 @@ export function ServiceUptimeHistory({
</Table> </Table>
</div> </div>
); );
} }
+28 -186
View File
@@ -1,210 +1,52 @@
import React, { useState, useEffect } from "react"; import React from "react";
import { Progress } from "@/components/ui/progress"; import { TooltipProvider } from "@/components/ui/tooltip";
import { Check, X, AlertTriangle, Pause, Clock, Info, RefreshCcw } from "lucide-react"; import { useUptimeData } from "./hooks/useUptimeData";
import { useTheme } from "@/contexts/ThemeContext"; import { UptimeStatusItem } from "./uptime/UptimeStatusItem";
import { import { UptimeSummary } from "./uptime/UptimeSummary";
HoverCard, import { UptimeLoadingState } from "./uptime/UptimeLoadingState";
HoverCardContent, import { UptimeErrorState } from "./uptime/UptimeErrorState";
HoverCardTrigger
} from "@/components/ui/hover-card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { uptimeService } from "@/services/uptimeService";
import { UptimeData } from "@/types/service.types";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
interface UptimeBarProps { interface UptimeBarProps {
uptime: number; uptime: number;
status: string; status: string;
serviceId?: string; // Optional serviceId to fetch history serviceId?: string;
interval?: number; // Service monitoring interval in seconds
serviceType?: string; // Add service type for proper data fetching
} }
export const UptimeBar = ({ uptime, status, serviceId }: UptimeBarProps) => { export const UptimeBar = ({ uptime, status, serviceId, interval = 60, serviceType }: UptimeBarProps) => {
const { theme } = useTheme(); const { displayItems, isLoading, error, isFetching, refetch } = useUptimeData({
const [historyItems, setHistoryItems] = useState<UptimeData[]>([]); serviceId,
serviceType,
// Fetch real uptime history data if serviceId is provided with improved caching and error handling status,
const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({ interval
queryKey: ['uptimeHistory', serviceId],
queryFn: () => serviceId ? uptimeService.getUptimeHistory(serviceId, 20) : Promise.resolve([]),
enabled: !!serviceId,
refetchInterval: 30000, // Refresh every 30 seconds
staleTime: 15000, // Consider data fresh for 15 seconds
placeholderData: (previousData) => previousData, // Show previous data while refetching
retry: 3, // Retry failed requests three times
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff with max 10s
}); });
// Update history items when data changes
useEffect(() => {
if (uptimeData && uptimeData.length > 0) {
setHistoryItems(uptimeData);
} else if (status === "paused" || (uptimeData && uptimeData.length === 0)) {
// For paused services with no history, or empty history data, show all as paused
const statusValue = (status === "up" || status === "down" || status === "warning" || status === "paused")
? status
: "paused"; // Default to paused if not a valid status
const placeholderHistory: UptimeData[] = Array(20).fill(null).map((_, index) => ({
id: `placeholder-${index}`,
serviceId: serviceId || "",
timestamp: new Date().toISOString(),
status: statusValue as "up" | "down" | "warning" | "paused",
responseTime: 0
}));
setHistoryItems(placeholderHistory);
}
}, [uptimeData, serviceId, status]);
// Get appropriate color classes for each status type
const getStatusColor = (itemStatus: string) => {
switch(itemStatus) {
case "up":
return theme === "dark" ? "bg-emerald-500" : "bg-emerald-500";
case "down":
return theme === "dark" ? "bg-red-500" : "bg-red-500";
case "warning":
return theme === "dark" ? "bg-yellow-500" : "bg-yellow-500";
case "paused":
default:
return theme === "dark" ? "bg-gray-500" : "bg-gray-400";
}
};
// Get status label
const getStatusLabel = (itemStatus: string): string => {
switch(itemStatus) {
case "up": return "Online";
case "down": return "Offline";
case "warning": return "Degraded";
case "paused": return "Paused";
default: return "Unknown";
}
};
// Format timestamp for display
const formatTimestamp = (timestamp: string): string => {
try {
return new Date(timestamp).toLocaleString([], {
hour: '2-digit',
minute: '2-digit',
month: 'short',
day: 'numeric'
});
} catch (e) {
return timestamp;
}
};
// If still loading and no history, show improved loading state // If still loading and no history, show improved loading state
if ((isLoading || isFetching) && historyItems.length === 0) { if ((isLoading || isFetching) && displayItems.length === 0) {
// Show skeleton loading UI instead of text return <UptimeLoadingState />;
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`skeleton-${index}`}
className={`h-5 w-1.5 rounded-sm bg-muted animate-pulse`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground w-16 h-4 bg-muted animate-pulse rounded"></span>
<span className="text-muted-foreground w-24 h-4 bg-muted animate-pulse rounded"></span>
</div>
</div>
);
} }
// If there's an error and no history, show improved error state with retry button // If there's an error and no history, show improved error state with retry button
if (error && historyItems.length === 0) { if (error && displayItems.length === 0) {
// Provide visual error state that matches the design system return <UptimeErrorState uptime={uptime} onRetry={refetch} />;
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`error-${index}`}
className={`h-5 w-1.5 rounded-sm bg-gray-700 opacity-40`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{Math.round(uptime)}% uptime</span>
<button
onClick={() => refetch()}
className="text-xs text-red-400 flex items-center gap-1 hover:text-red-300 transition-colors"
>
<X className="h-3 w-3" /> Connection error
<RefreshCcw className="h-3 w-3 ml-1" />
</button>
</div>
</div>
);
} }
// Ensure we always have 20 items by padding with the last known status
const displayItems = [...historyItems];
if (displayItems.length < 20) {
const lastItem = displayItems.length > 0 ? displayItems[displayItems.length - 1] : null;
const lastStatus = lastItem ? lastItem.status :
(status === "up" || status === "down" || status === "warning" || status === "paused") ?
status as "up" | "down" | "warning" | "paused" : "paused";
const paddingItems: UptimeData[] = Array(20 - displayItems.length).fill(null).map((_, index) => ({
id: `padding-${index}`,
serviceId: serviceId || "",
timestamp: new Date().toISOString(),
status: lastStatus,
responseTime: 0
}));
displayItems.push(...paddingItems);
}
// Limit to 20 items for display
const limitedItems = displayItems.slice(0, 20);
return ( return (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div className="flex flex-col w-full gap-1"> <div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6"> <div className="flex items-center space-x-0.5 w-full h-6">
{limitedItems.map((item, index) => ( {displayItems.map((item, index) => (
<Tooltip key={item.id || `status-${index}`}> <UptimeStatusItem
<TooltipTrigger asChild> key={item.id || `status-${index}`}
<div item={item}
className={`h-5 w-1.5 rounded-sm ${getStatusColor(item.status)} cursor-pointer hover:opacity-80 transition-opacity`} index={index}
/> />
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">{getStatusLabel(item.status)}</div>
<div>
{item.status !== "paused" && item.status !== "down" ?
`${item.responseTime}ms` :
"No response"}
</div>
<div className="text-gray-400">
{formatTimestamp(item.timestamp)}
</div>
</div>
</TooltipContent>
</Tooltip>
))} ))}
</div> </div>
<div className="flex items-center justify-between text-xs"> <UptimeSummary uptime={uptime} interval={interval} />
<span className="text-muted-foreground">
{Math.round(uptime)}% uptime
</span>
<span className="text-xs text-muted-foreground">
Last 20 checks
</span>
</div>
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
} }
@@ -10,46 +10,21 @@ interface ServiceBasicFieldsProps {
export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) { export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) {
return ( return (
<> <FormField
<FormField control={form.control}
control={form.control} name="name"
name="name" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>Service Name</FormLabel>
<FormLabel>Service Name</FormLabel> <FormControl>
<FormControl> <Input
<Input placeholder="Enter a descriptive name for your service"
placeholder="Service Name" {...field}
className="bg-black border-gray-700" />
{...field} </FormControl>
/> <FormMessage />
</FormControl> </FormItem>
<FormMessage /> )}
</FormItem> />
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Service URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com"
className="bg-black border-gray-700"
{...field}
onChange={(e) => {
console.log("URL field changed:", e.target.value);
field.onChange(e);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
); );
} }
@@ -1,51 +1,119 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { UseFormReturn } from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types"; import { ServiceFormData } from "./types";
import { ServiceUrlField } from "./ServiceUrlField";
import { useState } from "react";
interface ServiceConfigFieldsProps { interface ServiceConfigFieldsProps {
form: UseFormReturn<ServiceFormData>; form: UseFormReturn<ServiceFormData>;
} }
export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) { export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
const [isCustomInterval, setIsCustomInterval] = useState(false);
const intervalValue = form.watch("interval");
const handleIntervalChange = (value: string) => {
if (value === "custom") {
setIsCustomInterval(true);
form.setValue("interval", "");
} else {
setIsCustomInterval(false);
form.setValue("interval", value);
}
};
return ( return (
<> <div className="space-y-4">
<FormField <ServiceUrlField form={form} />
control={form.control}
name="interval" <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
render={({ field }) => ( <FormField
<FormItem> control={form.control}
<FormLabel>Heartbeat Interval</FormLabel> name="interval"
<FormControl> render={({ field }) => (
<Input <FormItem>
type="number" <FormLabel>Check Interval</FormLabel>
placeholder="60" {!isCustomInterval ? (
className="bg-black border-gray-700" <FormControl>
{...field} <Select onValueChange={handleIntervalChange} value={isCustomInterval ? "custom" : field.value}>
/> <SelectTrigger>
</FormControl> <SelectValue />
</FormItem> </SelectTrigger>
)} <SelectContent>
/> <SelectItem value="30">30 seconds</SelectItem>
<SelectItem value="60">1 minute</SelectItem>
<FormField <SelectItem value="300">5 minutes</SelectItem>
control={form.control} <SelectItem value="900">15 minutes</SelectItem>
name="retries" <SelectItem value="1800">30 minutes</SelectItem>
render={({ field }) => ( <SelectItem value="3600">1 hour</SelectItem>
<FormItem> <SelectItem value="custom">Custom</SelectItem>
<FormLabel>Maximum Retries</FormLabel> </SelectContent>
<FormControl> </Select>
<Input </FormControl>
type="number" ) : (
placeholder="3" <div className="space-y-2">
className="bg-black border-gray-700" <FormControl>
{...field} <Input
/> type="number"
</FormControl> placeholder="Enter interval in seconds"
</FormItem> value={field.value}
)} onChange={field.onChange}
/> min="10"
</> />
</FormControl>
<button
type="button"
onClick={() => {
setIsCustomInterval(false);
form.setValue("interval", "60");
}}
className="text-sm text-blue-600 hover:text-blue-800"
>
Back to presets
</button>
</div>
)}
<FormDescription className="text-xs">
{isCustomInterval
? "Enter custom interval in seconds (minimum 10 seconds)"
: "How often to check the service status"
}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retries"
render={({ field }) => (
<FormItem>
<FormLabel>Retry Attempts</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 attempt</SelectItem>
<SelectItem value="2">2 attempts</SelectItem>
<SelectItem value="3">3 attempts</SelectItem>
<SelectItem value="5">5 attempts</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription className="text-xs">
Number of retry attempts before marking as down
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
); );
} }
@@ -39,6 +39,7 @@ export function ServiceForm({
name: "", name: "",
type: "http", type: "http",
url: "", url: "",
port: "",
interval: "60", interval: "60",
retries: "3", retries: "3",
notificationChannel: "", notificationChannel: "",
@@ -50,11 +51,33 @@ export function ServiceForm({
// Populate form when initialData changes (separate from initialization) // Populate form when initialData changes (separate from initialization)
useEffect(() => { useEffect(() => {
if (initialData && isEdit) { if (initialData && isEdit) {
// Ensure the type is one of the allowed values
const serviceType = (initialData.type || "http").toLowerCase();
const validType = ["http", "ping", "tcp", "dns"].includes(serviceType)
? serviceType as "http" | "ping" | "tcp" | "dns"
: "http";
// For PING services, use host field; for DNS use domain field; for TCP use host field; others use url
let urlValue = "";
let portValue = "";
if (validType === "ping") {
urlValue = initialData.host || "";
} else if (validType === "dns") {
urlValue = initialData.domain || "";
} else if (validType === "tcp") {
urlValue = initialData.host || "";
portValue = String(initialData.port || "");
} else {
urlValue = initialData.url || "";
}
// Reset the form with initial data values // Reset the form with initial data values
form.reset({ form.reset({
name: initialData.name || "", name: initialData.name || "",
type: (initialData.type || "http").toLowerCase(), type: validType,
url: initialData.url || "", url: urlValue,
port: portValue,
interval: String(initialData.interval || 60), interval: String(initialData.interval || 60),
retries: String(initialData.retries || 3), retries: String(initialData.retries || 3),
notificationChannel: initialData.notificationChannel || "", notificationChannel: initialData.notificationChannel || "",
@@ -62,7 +85,7 @@ export function ServiceForm({
}); });
// Log for debugging // Log for debugging
console.log("Populating form with URL:", initialData.url); console.log("Populating form with data:", { type: validType, url: urlValue, port: portValue });
} }
}, [initialData, isEdit, form]); }, [initialData, isEdit, form]);
@@ -75,17 +98,28 @@ export function ServiceForm({
try { try {
console.log("Form data being submitted:", data); // Debug log for submitted data console.log("Form data being submitted:", data); // Debug log for submitted data
// Prepare service data with proper field mapping
const serviceData = {
name: data.name,
type: data.type,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel || undefined,
alertTemplate: data.alertTemplate || undefined,
// Map the URL field to appropriate database field based on service type
...(data.type === "dns"
? { domain: data.url, url: "", host: "", port: undefined } // DNS: store in domain field
: data.type === "ping"
? { host: data.url, url: "", domain: "", port: undefined } // PING: store in host field
: data.type === "tcp"
? { host: data.url, port: parseInt(data.port || "80"), url: "", domain: "" } // TCP: store in host and port fields
: { url: data.url, domain: "", host: "", port: undefined } // HTTP: store in url field
)
};
if (isEdit && initialData) { if (isEdit && initialData) {
// Update existing service // Update existing service
await serviceService.updateService(initialData.id, { await serviceService.updateService(initialData.id, serviceData);
name: data.name,
type: data.type,
url: data.url, // Ensure URL is included here
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel || undefined,
alertTemplate: data.alertTemplate || undefined,
});
toast({ toast({
title: "Service updated", title: "Service updated",
@@ -93,15 +127,7 @@ export function ServiceForm({
}); });
} else { } else {
// Create new service // Create new service
await serviceService.createService({ await serviceService.createService(serviceData);
name: data.name,
type: data.type,
url: data.url, // Ensure URL is included here
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel || undefined,
alertTemplate: data.alertTemplate || undefined,
});
toast({ toast({
title: "Service created", title: "Service created",
@@ -128,10 +154,24 @@ export function ServiceForm({
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-4"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-4">
<ServiceBasicFields form={form} /> <div className="space-y-6">
<ServiceTypeField form={form} /> <div className="space-y-4">
<ServiceConfigFields form={form} /> <h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Basic Information</h3>
<ServiceNotificationFields form={form} /> <ServiceBasicFields form={form} />
<ServiceTypeField form={form} />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Configuration</h3>
<ServiceConfigFields form={form} />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Notifications</h3>
<ServiceNotificationFields form={form} />
</div>
</div>
<ServiceFormActions <ServiceFormActions
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onCancel={onCancel} onCancel={onCancel}
@@ -140,4 +180,4 @@ export function ServiceForm({
</form> </form>
</Form> </Form>
); );
} }
@@ -28,14 +28,12 @@ export function ServiceFormActions({
onClick={handleCancel} onClick={handleCancel}
variant="outline" variant="outline"
disabled={isSubmitting} disabled={isSubmitting}
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
@@ -48,4 +46,4 @@ export function ServiceFormActions({
</Button> </Button>
</div> </div>
); );
} }
@@ -82,10 +82,10 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
}} }}
value={displayValue} value={displayValue}
> >
<SelectTrigger className="bg-black border-gray-700"> <SelectTrigger>
<SelectValue placeholder="Select a notification channel" /> <SelectValue placeholder="Select a notification channel" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700"> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
{alertConfigs.map((config) => ( {alertConfigs.map((config) => (
<SelectItem key={config.id} value={config.id || ""}> <SelectItem key={config.id} value={config.id || ""}>
@@ -119,10 +119,10 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
}} }}
value={displayValue} value={displayValue}
> >
<SelectTrigger className="bg-black border-gray-700"> <SelectTrigger>
<SelectValue placeholder="Select an alert template" /> <SelectValue placeholder="Select an alert template" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700"> <SelectContent>
<SelectItem value="default">Default</SelectItem> <SelectItem value="default">Default</SelectItem>
{templates?.map((template) => ( {templates?.map((template) => (
<SelectItem key={template.id} value={template.id}> <SelectItem key={template.id} value={template.id}>
@@ -1,7 +1,7 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Globe } from "lucide-react"; import { Globe, Wifi, Server, Globe2 } from "lucide-react";
import { UseFormReturn } from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types"; import { ServiceFormData } from "./types";
@@ -10,6 +10,21 @@ interface ServiceTypeFieldProps {
} }
export function ServiceTypeField({ form }: ServiceTypeFieldProps) { export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
const getServiceIcon = (type: string) => {
switch (type) {
case "http":
return <Globe className="w-4 h-4" />;
case "ping":
return <Wifi className="w-4 h-4" />;
case "tcp":
return <Server className="w-4 h-4" />;
case "dns":
return <Globe2 className="w-4 h-4" />;
default:
return <Globe className="w-4 h-4" />;
}
};
return ( return (
<FormField <FormField
control={form.control} control={form.control}
@@ -22,32 +37,62 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={field.value}
> >
<SelectTrigger className="bg-black border-gray-700"> <SelectTrigger>
<SelectValue> <SelectValue>
{field.value === "http" && ( {field.value && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="w-4 h-4" /> {getServiceIcon(field.value)}
<span>HTTP/S</span> <span>{field.value.toUpperCase()}</span>
</div> </div>
)} )}
{field.value !== "http" && "Select a service type"} {!field.value && "Select a service type"}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700"> <SelectContent>
<SelectItem value="http"> <SelectItem value="http">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="w-4 h-4" /> <Globe className="w-4 h-4" />
<span>HTTP/S</span> <span>HTTP/S</span>
</div> </div>
<p className="text-xs text-gray-400 mt-1"> <p className="text-xs text-muted-foreground mt-1">
Monitor websites and REST APIs with HTTP/HTTPS protocol Monitor websites and REST APIs with HTTP/HTTPS Protocol
</p>
</div>
</SelectItem>
<SelectItem value="ping">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Wifi className="w-4 h-4" />
<span>PING</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Monitor host availability with PING Protocol
</p>
</div>
</SelectItem>
<SelectItem value="tcp">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Server className="w-4 h-4" />
<span>TCP</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Monitor TCP port connectivity with TCP Protocol
</p>
</div>
</SelectItem>
<SelectItem value="dns">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Globe2 className="w-4 h-4" />
<span>DNS</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Monitor DNS resolution
</p> </p>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="ping">PING</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="dns">DNS</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
@@ -55,4 +100,4 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
)} )}
/> />
); );
} }
@@ -0,0 +1,111 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceUrlFieldProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
const serviceType = form.watch("type");
const getPlaceholder = () => {
switch (serviceType) {
case "http":
return "https://example.com";
case "ping":
return "example.com or 192.168.1.1";
case "tcp":
return "example.com or 192.168.1.1";
case "dns":
return "example.com";
default:
return "Enter URL or hostname";
}
};
const getDescription = () => {
switch (serviceType) {
case "http":
return "Enter the full URL including protocol (http:// or https://)";
case "ping":
return "Enter hostname or IP address to ping";
case "tcp":
return "Enter hostname or IP address for TCP connection test";
case "dns":
return "Enter domain name for DNS record monitoring (A, AAAA, MX, etc.)";
default:
return "Enter the target URL or hostname for monitoring";
}
};
const getFieldLabel = () => {
switch (serviceType) {
case "dns":
return "Domain Name";
case "ping":
return "Hostname/IP";
case "tcp":
return "Hostname/IP";
default:
return "Target URL/Host";
}
};
return (
<div className="space-y-4">
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{getFieldLabel()}</FormLabel>
<FormControl>
<Input
placeholder={getPlaceholder()}
{...field}
onChange={(e) => {
console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value);
field.onChange(e);
}}
/>
</FormControl>
<FormDescription className="text-xs">
{getDescription()}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{serviceType === "tcp" && (
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
placeholder="8080"
type="number"
{...field}
onChange={(e) => {
console.log("Port field changed:", e.target.value);
field.onChange(e);
}}
/>
</FormControl>
<FormDescription className="text-xs">
Enter the port number for TCP connection test
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
);
}
@@ -3,12 +3,16 @@ import { z } from "zod";
export const serviceSchema = z.object({ export const serviceSchema = z.object({
name: z.string().min(1, "Service name is required"), name: z.string().min(1, "Service name is required"),
type: z.string().min(1, "Service type is required"), type: z.enum(["http", "ping", "tcp", "dns"]),
url: z.string().min(1, "Service URL is required"), url: z.string().min(1, "URL/Domain/Host is required").refine((value) => {
interval: z.string().min(1, "Heartbeat interval is required"), // Basic validation - more specific validation can be added per type
retries: z.string().min(1, "Maximum retries is required"), return value.trim().length > 0;
}, "Please enter a valid URL, hostname, or domain"),
port: z.string().optional(),
interval: z.string(),
retries: z.string(),
notificationChannel: z.string().optional(), notificationChannel: z.string().optional(),
alertTemplate: z.string().optional(), alertTemplate: z.string().optional(),
}); });
export type ServiceFormData = z.infer<typeof serviceSchema>; export type ServiceFormData = z.infer<typeof serviceSchema>;
@@ -0,0 +1,124 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { uptimeService } from '@/services/uptimeService';
import { UptimeData } from '@/types/service.types';
interface UseUptimeDataProps {
serviceId?: string;
serviceType?: string;
status: string;
interval: number;
}
export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseUptimeDataProps) => {
const [historyItems, setHistoryItems] = useState<UptimeData[]>([]);
// Fetch real uptime history data if serviceId is provided
const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({
queryKey: ['uptimeHistory', serviceId, serviceType],
queryFn: () => {
if (!serviceId) {
console.log('No serviceId provided, skipping fetch');
return Promise.resolve([]);
}
console.log(`Fetching uptime data for service ${serviceId} of type ${serviceType}`);
return uptimeService.getUptimeHistory(serviceId, 50, undefined, undefined, serviceType);
},
enabled: !!serviceId,
refetchInterval: 30000,
staleTime: 15000,
placeholderData: (previousData) => previousData,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
});
// Filter and process uptime data
const processUptimeData = (data: UptimeData[], intervalSeconds: number): UptimeData[] => {
if (!data || data.length === 0) return [];
console.log(`Processing ${data.length} uptime records for service ${serviceId}`);
// Sort data by timestamp (newest first)
const sortedData = [...data].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// Take the most recent 20 records to ensure we have enough data
const recentData = sortedData.slice(0, 20);
console.log(`Using ${recentData.length} most recent records`);
return recentData;
};
// Update history items when data changes
useEffect(() => {
if (uptimeData && uptimeData.length > 0) {
console.log(`Received ${uptimeData.length} uptime records for service ${serviceId}`);
const processedData = processUptimeData(uptimeData, interval);
setHistoryItems(processedData);
} else if (!serviceId || (uptimeData && uptimeData.length === 0)) {
// Generate placeholder data when no real data is available
console.log(`No uptime data available for service ${serviceId}, generating placeholder`);
const statusValue = (status === "up" || status === "down" || status === "warning" || status === "paused")
? status
: "paused";
const placeholderHistory: UptimeData[] = Array(20).fill(null).map((_, index) => ({
id: `placeholder-${serviceId}-${index}`,
service_id: serviceId || "", // Include service_id
serviceId: serviceId || "", // Keep for backward compatibility
timestamp: new Date(Date.now() - (index * interval * 1000)).toISOString(),
status: statusValue as "up" | "down" | "warning" | "paused",
responseTime: 0
}));
setHistoryItems(placeholderHistory);
}
}, [uptimeData, serviceId, status, interval]);
// Ensure we always have exactly 20 items for consistent display
const getDisplayItems = (): UptimeData[] => {
const items = [...historyItems];
// If we have fewer than 20 items, pad with older placeholder data
if (items.length < 20) {
const lastItem = items.length > 0 ? items[items.length - 1] : null;
const lastStatus = lastItem ? lastItem.status :
(status === "up" || status === "down" || status === "warning" || status === "paused") ?
status as "up" | "down" | "warning" | "paused" : "paused";
const paddingCount = 20 - items.length;
const paddingItems: UptimeData[] = Array(paddingCount).fill(null).map((_, index) => {
const baseTime = lastItem ? new Date(lastItem.timestamp).getTime() : Date.now();
const timeOffset = (index + 1) * interval * 1000;
return {
id: `padding-${serviceId}-${index}`,
service_id: serviceId || "", // Include service_id
serviceId: serviceId || "", // Keep for backward compatibility
timestamp: new Date(baseTime - timeOffset).toISOString(),
status: lastStatus,
responseTime: 0
};
});
items.push(...paddingItems);
}
// Return exactly 20 items, sorted by timestamp (newest first)
return items.slice(0, 20).sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
};
return {
displayItems: getDisplayItems(),
isLoading,
error,
isFetching,
refetch
};
};
@@ -25,6 +25,8 @@ export function IncidentTable({ incidents }: IncidentTableProps) {
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead> <TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead> <TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead> <TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Error Message</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Details</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -52,10 +54,28 @@ export function IncidentTable({ incidents }: IncidentTableProps) {
? `${check.responseTime}ms` ? `${check.responseTime}ms`
: "N/A"} : "N/A"}
</TableCell> </TableCell>
<TableCell className={`text-sm ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} max-w-[200px]`}>
{check.error_message ? (
<div className="truncate" title={check.error_message}>
{check.error_message}
</div>
) : (
<span className={theme === 'dark' ? 'text-gray-500' : 'text-gray-400'}>-</span>
)}
</TableCell>
<TableCell className={`text-sm ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} max-w-[250px]`}>
{check.details ? (
<div className="truncate" title={check.details}>
{check.details}
</div>
) : (
<span className={theme === 'dark' ? 'text-gray-500' : 'text-gray-400'}>-</span>
)}
</TableCell>
</TableRow> </TableRow>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
); );
} }
@@ -112,10 +112,10 @@ export const ServiceRowActions = ({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className="w-48 bg-gray-900 border border-gray-800 text-white" className="w-48"
> >
<DropdownMenuItem <DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5" className="flex items-center gap-2 cursor-pointer text-base py-2.5"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onViewDetail(service); onViewDetail(service);
@@ -125,7 +125,7 @@ export const ServiceRowActions = ({
<span>View Detail</span> <span>View Detail</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5" className="flex items-center gap-2 cursor-pointer text-base py-2.5"
onClick={handlePauseResume} onClick={handlePauseResume}
> >
{service.status === "paused" ? ( {service.status === "paused" ? (
@@ -141,7 +141,7 @@ export const ServiceRowActions = ({
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5" className="flex items-center gap-2 cursor-pointer text-base py-2.5"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit(service); onEdit(service);
@@ -151,7 +151,7 @@ export const ServiceRowActions = ({
<span>Edit</span> <span>Edit</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5" className="flex items-center gap-2 cursor-pointer text-base py-2.5"
onClick={handleMuteAlerts} onClick={handleMuteAlerts}
> >
{alertsMuted ? ( {alertsMuted ? (
@@ -166,9 +166,9 @@ export const ServiceRowActions = ({
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-gray-700" /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="flex items-center gap-2 text-red-500 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5" className="flex items-center gap-2 text-destructive cursor-pointer text-base py-2.5"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete(service); onDelete(service);
@@ -181,4 +181,4 @@ export const ServiceRowActions = ({
</DropdownMenu> </DropdownMenu>
</div> </div>
); );
}; };
@@ -8,45 +8,56 @@ interface ServiceRowHeaderProps {
} }
export const ServiceRowHeader = ({ service }: ServiceRowHeaderProps) => { export const ServiceRowHeader = ({ service }: ServiceRowHeaderProps) => {
// Display URL for HTTP services, hostname for others
const shouldDisplayFullUrl = service.type.toLowerCase() === "http";
let serviceSubtitle = "";
// Check alerts status - check both fields for backward compatibility // Check alerts status - check both fields for backward compatibility
const alertsMuted = service.alerts === "muted" || service.muteAlerts === true; const alertsMuted = service.alerts === "muted" || service.muteAlerts === true;
if (service.url) { // Determine what to display based on service type
try { const getServiceSubtitle = () => {
const url = service.url; const serviceType = service.type.toLowerCase();
// If the URL doesn't start with http:// or https://, add https:// prefix
const formattedUrl = (!url.startsWith('http://') && !url.startsWith('https://')) if (serviceType === "dns" && service.domain) {
? `https://${url}` return service.domain;
: url;
try {
// Now try to parse it as a URL
const urlObj = new URL(formattedUrl);
if (shouldDisplayFullUrl) {
serviceSubtitle = formattedUrl;
} else {
serviceSubtitle = urlObj.hostname;
}
} catch (urlError) {
// If URL parsing still fails, just show the original URL
serviceSubtitle = url;
}
} catch (e) {
// If any other error occurs, just show the original URL
serviceSubtitle = service.url;
console.log("Error processing URL:", e);
} }
}
if ((serviceType === "ping" || serviceType === "tcp") && service.host) {
if (serviceType === "tcp" && service.port) {
return `${service.host}:${service.port}`;
}
return service.host;
}
if (service.url) {
try {
// If the URL doesn't start with http:// or https://, add https:// prefix
const formattedUrl = (!service.url.startsWith('http://') && !service.url.startsWith('https://'))
? `https://${service.url}`
: service.url;
try {
// Try to parse it as a URL
const urlObj = new URL(formattedUrl);
// For HTTP services, show full URL; for others show hostname
return serviceType === "http" ? formattedUrl : urlObj.hostname;
} catch (urlError) {
// If URL parsing fails, just show the original URL
return service.url;
}
} catch (e) {
// If any other error occurs, just show the original URL
return service.url;
}
}
return "";
};
const serviceSubtitle = getServiceSubtitle();
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div> <div>
<div className="text-base font-medium">{service.name}</div> <div className="text-base font-medium">{service.name}</div>
{service.url && ( {serviceSubtitle && (
<div className="text-sm text-gray-500 mt-1">{serviceSubtitle}</div> <div className="text-sm text-gray-500 mt-1">{serviceSubtitle}</div>
)} )}
</div> </div>
@@ -58,4 +69,4 @@ export const ServiceRowHeader = ({ service }: ServiceRowHeaderProps) => {
)} )}
</div> </div>
); );
}; };
@@ -0,0 +1,33 @@
import React from 'react';
import { X, RefreshCcw } from 'lucide-react';
interface UptimeErrorStateProps {
uptime: number;
onRetry: () => void;
}
export const UptimeErrorState = ({ uptime, onRetry }: UptimeErrorStateProps) => {
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`error-${index}`}
className={`h-5 w-1.5 rounded-sm bg-gray-700 opacity-40`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{Math.round(uptime)}% uptime</span>
<button
onClick={onRetry}
className="text-xs text-red-400 flex items-center gap-1 hover:text-red-300 transition-colors"
>
<X className="h-3 w-3" /> Connection error
<RefreshCcw className="h-3 w-3 ml-1" />
</button>
</div>
</div>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
export const UptimeLoadingState = () => {
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`skeleton-${index}`}
className={`h-5 w-1.5 rounded-sm bg-muted animate-pulse`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground w-16 h-4 bg-muted animate-pulse rounded"></span>
<span className="text-muted-foreground w-24 h-4 bg-muted animate-pulse rounded"></span>
</div>
</div>
);
};
@@ -0,0 +1,80 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { UptimeData } from '@/types/service.types';
import { useTheme } from '@/contexts/ThemeContext';
interface UptimeStatusItemProps {
item: UptimeData;
index: number;
}
export const UptimeStatusItem = ({ item, index }: UptimeStatusItemProps) => {
const { theme } = useTheme();
// Get appropriate color classes for each status type
const getStatusColor = (itemStatus: string) => {
switch(itemStatus) {
case "up":
return theme === "dark" ? "bg-emerald-500" : "bg-emerald-500";
case "down":
return theme === "dark" ? "bg-red-500" : "bg-red-500";
case "warning":
return theme === "dark" ? "bg-yellow-500" : "bg-yellow-500";
case "paused":
default:
return theme === "dark" ? "bg-gray-500" : "bg-gray-400";
}
};
// Get status label
const getStatusLabel = (itemStatus: string): string => {
switch(itemStatus) {
case "up": return "Online";
case "down": return "Offline";
case "warning": return "Degraded";
case "paused": return "Paused";
default: return "Unknown";
}
};
// Format timestamp for display
const formatTimestamp = (timestamp: string): string => {
try {
return new Date(timestamp).toLocaleString([], {
hour: '2-digit',
minute: '2-digit',
month: 'short',
day: 'numeric'
});
} catch (e) {
return timestamp;
}
};
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={`h-5 w-1.5 rounded-sm ${getStatusColor(item.status)} cursor-pointer hover:opacity-80 transition-opacity`}
/>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">{getStatusLabel(item.status)}</div>
<div>
{item.status !== "paused" && item.status !== "down" ?
`${item.responseTime}ms` :
"No response"}
</div>
<div className="text-gray-400">
{formatTimestamp(item.timestamp)}
</div>
</div>
</TooltipContent>
</Tooltip>
);
};
@@ -0,0 +1,20 @@
import React from 'react';
interface UptimeSummaryProps {
uptime: number;
interval: number;
}
export const UptimeSummary = ({ uptime, interval }: UptimeSummaryProps) => {
return (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{Math.round(uptime)}% uptime
</span>
<span className="text-xs text-muted-foreground">
Last 20 checks
</span>
</div>
);
};
@@ -0,0 +1,5 @@
export { UptimeStatusItem } from './UptimeStatusItem';
export { UptimeSummary } from './UptimeSummary';
export { UptimeLoadingState } from './UptimeLoadingState';
export { UptimeErrorState } from './UptimeErrorState';
@@ -1,30 +1,51 @@
import React, { useEffect, useState } from 'react';
import React from 'react'; import { format } from 'date-fns';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card, CardContent, CardDescription, CardHeader, CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Github, FileText, Twitter, MessageCircle, Code2, ServerIcon } from "lucide-react"; import {
Github, FileText, Twitter, MessageCircle, Code2, ServerIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useSystemSettings } from "@/hooks/useSystemSettings"; import { useSystemSettings } from "@/hooks/useSystemSettings";
export const AboutSystem: React.FC = () => { export const AboutSystem: React.FC = () => {
const { const { t } = useLanguage();
t const { theme } = useTheme();
} = useLanguage(); const { systemName } = useSystemSettings();
const {
theme const [version, setVersion] = useState<string>('...');
} = useTheme(); const [releaseDate, setReleaseDate] = useState<string>('...');
const {
systemName useEffect(() => {
} = useSystemSettings(); const fetchLatestRelease = async () => {
return <div className="space-y-6 animate-fade-in"> try {
const res = await fetch('https://api.github.com/repos/operacle/checkcle/releases/latest');
const data = await res.json();
setVersion(data.tag_name || 'v1.x.x');
setReleaseDate(data.published_at ? format(new Date(data.published_at), 'MMMM d, yyyy') : t('unknown'));
} catch (err) {
setVersion('v1.x.x');
setReleaseDate(t('unknown'));
}
};
fetchLatestRelease();
}, [t]);
return (
<div className="space-y-6 animate-fade-in">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1> <h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
<p className="text-muted-foreground text-base leading-relaxed mt-2">{t('aboutCheckcle')}</p> <p className="text-muted-foreground text-base leading-relaxed mt-2">
{t('aboutCheckcle')}
</p>
</div> </div>
<Separator /> <Separator />
<div className="grid gap-8 md:grid-cols-2"> <div className="grid gap-8 md:grid-cols-2">
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md"> <Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
<CardHeader className="bg-muted/50 pb-4"> <CardHeader className="bg-muted/50 pb-4">
@@ -38,7 +59,7 @@ export const AboutSystem: React.FC = () => {
<div className="flex flex-col space-y-3 pt-2"> <div className="flex flex-col space-y-3 pt-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('systemVersion')}</span> <span className="text-muted-foreground">{t('systemVersion')}</span>
<span className="text-foreground font-medium">{t('version')} 1.1.0</span> <span className="text-foreground font-medium">{version}</span>
</div> </div>
<Separator className="my-1" /> <Separator className="my-1" />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@@ -48,20 +69,22 @@ export const AboutSystem: React.FC = () => {
<Separator className="my-1" /> <Separator className="my-1" />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('releasedOn')}</span> <span className="text-muted-foreground">{t('releasedOn')}</span>
<span className="text-foreground font-medium">May 16, 2025</span> <span className="text-foreground font-medium">{releaseDate}</span>
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md"> <Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
<CardHeader className="bg-muted/50 pb-4"> <CardHeader className="bg-muted/50 pb-4">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Code2 className={`h-5 w-5 ${theme === 'dark' ? 'text-emerald-400' : 'text-emerald-600'}`} /> <Code2 className={`h-5 w-5 ${theme === 'dark' ? 'text-emerald-400' : 'text-emerald-600'}`} />
<span>{t('links')}</span> <span>{t('links')}</span>
</CardTitle> </CardTitle>
<CardDescription className="font-medium text-base">{systemName || 'ReamStack'} {t('resources').toLowerCase()}</CardDescription> <CardDescription className="font-medium text-base">
{systemName || 'ReamStack'} {t('resources').toLowerCase()}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 pt-6"> <CardContent className="space-y-4 pt-6">
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
@@ -85,6 +108,8 @@ export const AboutSystem: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>; </div>
);
}; };
export default AboutSystem;
export default AboutSystem;
@@ -0,0 +1,316 @@
import React, { useState, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Database, Trash2, AlertTriangle, Globe, Server } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
import { authService } from "@/services/authService";
import { dataRetentionService } from "@/services/dataRetentionService";
interface RetentionSettings {
uptimeRetentionDays: number;
serverRetentionDays: number;
}
const DataRetentionSettings = () => {
const { t } = useLanguage();
const { toast } = useToast();
const [settings, setSettings] = useState<RetentionSettings>({
uptimeRetentionDays: 30,
serverRetentionDays: 30
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isUptimeShrinking, setIsUptimeShrinking] = useState(false);
const [isServerShrinking, setIsServerShrinking] = useState(false);
const [isFullShrinking, setIsFullShrinking] = useState(false);
const [lastCleanup, setLastCleanup] = useState<string | null>(null);
// Check if user is super admin
const currentUser = authService.getCurrentUser();
const isSuperAdmin = currentUser?.role === "superadmin";
useEffect(() => {
if (isSuperAdmin) {
loadSettings();
}
}, [isSuperAdmin]);
const loadSettings = async () => {
try {
setIsLoading(true);
const result = await dataRetentionService.getRetentionSettings();
if (result) {
setSettings({
uptimeRetentionDays: result.uptimeRetentionDays || 30,
serverRetentionDays: result.serverRetentionDays || 30
});
setLastCleanup(result.lastCleanup);
}
} catch (error) {
console.error("Error loading retention settings:", error);
toast({
title: "Error",
description: "Failed to load retention settings",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
try {
setIsSaving(true);
await dataRetentionService.updateRetentionSettings(settings);
toast({
title: "Settings saved",
description: "Data retention settings have been updated",
});
} catch (error) {
console.error("Error saving retention settings:", error);
toast({
title: "Error",
description: "Failed to save retention settings",
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
const handleUptimeShrink = async () => {
try {
setIsUptimeShrinking(true);
const result = await dataRetentionService.manualUptimeCleanup();
toast({
title: "Uptime cleanup completed",
description: `Deleted ${result.deletedRecords} old uptime records`,
});
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during uptime cleanup:", error);
toast({
title: "Error",
description: "Failed to perform uptime data cleanup",
variant: "destructive",
});
} finally {
setIsUptimeShrinking(false);
}
};
const handleServerShrink = async () => {
try {
setIsServerShrinking(true);
const result = await dataRetentionService.manualServerCleanup();
toast({
title: "Server cleanup completed",
description: `Deleted ${result.deletedRecords} old server records`,
});
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during server cleanup:", error);
toast({
title: "Error",
description: "Failed to perform server data cleanup",
variant: "destructive",
});
} finally {
setIsServerShrinking(false);
}
};
const handleFullShrink = async () => {
try {
setIsFullShrinking(true);
const result = await dataRetentionService.manualCleanup();
toast({
title: "Database cleanup completed",
description: `Deleted ${result.deletedRecords} old records`,
});
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during manual cleanup:", error);
toast({
title: "Error",
description: "Failed to perform database cleanup",
variant: "destructive",
});
} finally {
setIsFullShrinking(false);
}
};
// Show permission notice for admin users
if (!isSuperAdmin) {
return (
<div className="p-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
{t("dataRetention", "settings")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800">
<AlertTriangle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<AlertDescription className="text-blue-700 dark:text-blue-300">
<span className="font-medium">Permission Notice:</span> As an admin user, you do not have access to data retention settings. These settings can only be accessed and modified by Super Admins.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
);
}
if (isLoading) {
return (
<div className="p-4 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Loading retention settings...
</div>
);
}
return (
<div className="p-4 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Data Retention Settings
</CardTitle>
<CardDescription>
Configure how long monitoring data is kept in the system
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div>
<Label htmlFor="uptimeRetention">Uptime Monitoring Retention (days)</Label>
<Input
id="uptimeRetention"
type="number"
min="1"
max="365"
value={settings.uptimeRetentionDays}
onChange={(e) => setSettings(prev => ({
...prev,
uptimeRetentionDays: parseInt(e.target.value) || 30
}))}
className="mt-1"
/>
<p className="text-sm text-muted-foreground mt-1">
Service uptime and incident data older than this will be automatically deleted
</p>
</div>
<div>
<Label htmlFor="serverRetention">Server Monitoring Retention (days)</Label>
<Input
id="serverRetention"
type="number"
min="1"
max="365"
value={settings.serverRetentionDays}
onChange={(e) => setSettings(prev => ({
...prev,
serverRetentionDays: parseInt(e.target.value) || 30
}))}
className="mt-1"
/>
<p className="text-sm text-muted-foreground mt-1">
Server metrics and process data older than this will be automatically deleted
</p>
</div>
</div>
{lastCleanup && (
<Alert>
<Database className="h-4 w-4" />
<AlertDescription>
Last automatic cleanup: {new Date(lastCleanup).toLocaleString()}
</AlertDescription>
</Alert>
)}
</CardContent>
<CardFooter className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2 w-full">
<Button
variant="outline"
onClick={handleUptimeShrink}
disabled={isUptimeShrinking}
className="flex items-center gap-2"
>
{isUptimeShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
Shrink Uptime Data
</Button>
<Button
variant="outline"
onClick={handleServerShrink}
disabled={isServerShrinking}
className="flex items-center gap-2"
>
{isServerShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Server className="h-4 w-4" />
)}
Shrink Server Data
</Button>
<Button
variant="outline"
onClick={handleFullShrink}
disabled={isFullShrinking}
className="flex items-center gap-2"
>
{isFullShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Full Database Shrink
</Button>
</div>
<div className="flex justify-end w-full">
<Button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save Changes
</Button>
</div>
</CardFooter>
</Card>
</div>
);
};
export default DataRetentionSettings;
@@ -0,0 +1,5 @@
import DataRetentionSettings from './DataRetentionSettings';
export default DataRetentionSettings;
export { DataRetentionSettings };
@@ -29,8 +29,6 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = () => {
error, error,
updateSettings, updateSettings,
isUpdating, isUpdating,
testEmailConnection,
isTestingConnection
} = useSystemSettings(); } = useSystemSettings();
const form = useForm<GeneralSettings>({ const form = useForm<GeneralSettings>({
@@ -47,6 +45,7 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = () => {
port: 587, port: 587,
host: '', host: '',
username: '', username: '',
password: '',
authMethod: '', authMethod: '',
tls: true, tls: true,
localName: '' localName: ''
@@ -73,6 +72,7 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = () => {
port: 587, port: 587,
host: '', host: '',
username: '', username: '',
password: '',
authMethod: '', authMethod: '',
tls: true, tls: true,
localName: '' localName: ''
@@ -97,15 +97,6 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = () => {
} }
}; };
const handleTestConnection = async () => {
try {
const smtpConfig = form.getValues('smtp');
await testEmailConnection(smtpConfig);
} catch (error) {
console.error("Error testing connection:", error);
}
};
const handleEditClick = () => { const handleEditClick = () => {
console.log('Edit button clicked, setting isEditing to true'); console.log('Edit button clicked, setting isEditing to true');
setIsEditing(true); setIsEditing(true);
@@ -131,6 +122,7 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = () => {
port: 587, port: 587,
host: '', host: '',
username: '', username: '',
password: '',
authMethod: '', authMethod: '',
tls: true, tls: true,
localName: '' localName: ''
@@ -202,13 +194,10 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = () => {
form={form} form={form}
isEditing={isEditing} isEditing={isEditing}
settings={settings} settings={settings}
handleTestConnection={handleTestConnection}
isTestingConnection={isTestingConnection}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* Save and Cancel buttons - only show when editing */}
{isEditing && ( {isEditing && (
<div className="flex justify-between mt-6"> <div className="flex justify-between mt-6">
<Button type="button" variant="outline" onClick={handleCancelClick} disabled={isUpdating}> <Button type="button" variant="outline" onClick={handleCancelClick} disabled={isUpdating}>
@@ -223,7 +212,6 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = () => {
</Form> </Form>
</CardContent> </CardContent>
{/* Edit button - only show when not editing and outside the form */}
{!isEditing && ( {!isEditing && (
<CardFooter> <CardFooter>
<Button type="button" onClick={handleEditClick}> <Button type="button" onClick={handleEditClick}>
@@ -1,24 +1,61 @@
import React from 'react'; import React, { useState } from 'react';
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { SettingsTabProps } from "./types"; import { SettingsTabProps } from "./types";
import TestEmailDialog, { TestEmailData } from "./TestEmailDialog";
import { Mail } from "lucide-react";
interface MailSettingsTabProps extends SettingsTabProps { interface MailSettingsTabProps extends SettingsTabProps {
handleTestConnection: () => Promise<void>; // Remove handleTestConnection and isTestingConnection props since we're removing the test connection button
isTestingConnection: boolean;
} }
const MailSettingsTab: React.FC<MailSettingsTabProps> = ({ const MailSettingsTab: React.FC<MailSettingsTabProps> = ({
form, form,
isEditing, isEditing
handleTestConnection,
isTestingConnection
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [showTestEmailDialog, setShowTestEmailDialog] = useState(false);
const [isTestingEmail, setIsTestingEmail] = useState(false);
const handleTestEmail = async (data: TestEmailData) => {
try {
setIsTestingEmail(true);
console.log('Testing email with data:', data);
const response = await fetch('/api/settings/test/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: data.email,
template: data.template,
...(data.collection && { collection: data.collection })
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || 'Test email failed');
}
console.log('Test email sent successfully');
} catch (error) {
console.error('Error sending test email:', error);
throw error; // Re-throw to let the dialog handle the error display
} finally {
setIsTestingEmail(false);
}
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -140,6 +177,26 @@ const MailSettingsTab: React.FC<MailSettingsTabProps> = ({
/> />
</div> </div>
<div className="mt-4">
<FormField
control={form.control}
name="smtp.password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("smtpPassword", "settings")}</FormLabel>
<FormControl>
<Input
{...field}
type="password"
disabled={!isEditing || !form.watch('smtp.enabled')}
placeholder="••••••••"
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="mt-4"> <div className="mt-4">
<FormField <FormField
control={form.control} control={form.control}
@@ -196,20 +253,36 @@ const MailSettingsTab: React.FC<MailSettingsTabProps> = ({
)} )}
/> />
</div> </div>
</div>
{isEditing && form.watch('smtp.enabled') && (
<div className="mt-4"> {/* Test Email button - outside the form area and always visible when SMTP is enabled */}
{form.watch('smtp.enabled') && (
<div className="mt-6 pt-4 border-t border-border">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-medium">{t("testEmailSettings", "settings")}</h3>
<p className="text-sm text-muted-foreground">{t("testEmailDescription", "settings")}</p>
</div>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={handleTestConnection} onClick={() => setShowTestEmailDialog(true)}
disabled={isTestingConnection} disabled={isTestingEmail}
className="flex items-center gap-2"
> >
{isTestingConnection ? t("testingConnection", "settings") : t("testConnection", "settings")} <Mail className="h-4 w-4" />
{t("testEmail", "settings")}
</Button> </Button>
</div> </div>
)} </div>
</div> )}
<TestEmailDialog
open={showTestEmailDialog}
onOpenChange={setShowTestEmailDialog}
onSendTest={handleTestEmail}
isTesting={isTestingEmail}
/>
</div> </div>
); );
}; };
@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { SettingsTabProps } from "./types"; import { SettingsTabProps } from "./types";
@@ -48,7 +47,7 @@ const SystemSettingsTab: React.FC<SettingsTabProps> = ({ form, isEditing, settin
<Input <Input
{...field} {...field}
disabled={!isEditing} disabled={!isEditing}
placeholder="https://localhost:8090" placeholder="https://pb-api.k8sops.asia"
className="bg-background border-input text-foreground placeholder:text-muted-foreground disabled:bg-muted disabled:text-muted-foreground" className="bg-background border-input text-foreground placeholder:text-muted-foreground disabled:bg-muted disabled:text-muted-foreground"
/> />
</FormControl> </FormControl>
@@ -57,72 +56,6 @@ const SystemSettingsTab: React.FC<SettingsTabProps> = ({ form, isEditing, settin
/> />
</div> </div>
</div> </div>
<div>
<FormField
control={form.control}
name="meta.senderName"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-foreground">
{t("senderName", "settings")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={!isEditing}
placeholder="System Administrator"
className="bg-background border-input text-foreground placeholder:text-muted-foreground disabled:bg-muted disabled:text-muted-foreground"
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div>
<FormField
control={form.control}
name="meta.senderAddress"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-foreground">
{t("senderEmail", "settings")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={!isEditing}
placeholder="admin@example.com"
type="email"
className="bg-background border-input text-foreground placeholder:text-muted-foreground disabled:bg-muted disabled:text-muted-foreground"
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex items-center space-x-3 pt-4">
<FormField
control={form.control}
name="meta.hideControls"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!isEditing}
/>
</FormControl>
<FormLabel className="text-sm font-medium text-foreground cursor-pointer">
{t("hideControls", "settings")}
</FormLabel>
</FormItem>
)}
/>
</div>
</div> </div>
); );
}; };
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useLanguage } from "@/contexts/LanguageContext";
import { Mail, X, AlertCircle, CheckCircle, Loader2 } from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface TestEmailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSendTest: (data: TestEmailData) => Promise<void>;
isTesting: boolean;
}
export interface TestEmailData {
email: string;
template: string;
collection?: string;
}
const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
open,
onOpenChange,
onSendTest,
isTesting
}) => {
const { t } = useLanguage();
const [email, setEmail] = useState('');
const [template, setTemplate] = useState('verification');
const [collection, setCollection] = useState('_superusers');
const [lastResult, setLastResult] = useState<{ success: boolean; message: string } | null>(null);
const [isInternalTesting, setIsInternalTesting] = useState(false);
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const handleSend = async () => {
if (!email.trim()) {
toast({
title: "Error",
description: "Please enter an email address",
variant: "destructive",
});
return;
}
if (!validateEmail(email)) {
toast({
title: "Error",
description: "Please enter a valid email address",
variant: "destructive",
});
return;
}
try {
setLastResult(null);
setIsInternalTesting(true);
console.log('Sending test email with data:', {
email,
template,
collection: template === 'verification' || template === 'password-reset' ? collection : undefined
});
await onSendTest({
email,
template,
collection: template === 'verification' || template === 'password-reset' ? collection : undefined
});
setLastResult({
success: true,
message: `Test email sent successfully to ${email}`
});
toast({
title: "Success",
description: `Test email sent successfully to ${email}`,
variant: "default",
});
} catch (error) {
console.error('Error sending test email:', error);
const errorMessage = error instanceof Error ? error.message : "Failed to send test email";
setLastResult({
success: false,
message: errorMessage
});
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsInternalTesting(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset form but keep last result for reference
setEmail('');
setTemplate('verification');
setCollection('_superusers');
// Don't reset lastResult immediately to allow user to see the result
setTimeout(() => setLastResult(null), 300);
};
const isLoading = isTesting || isInternalTesting;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
{t("sendTestEmail", "settings")}
</DialogTitle>
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4"
onClick={handleClose}
disabled={isLoading}
>
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Show last result */}
{lastResult && (
<Alert variant={lastResult.success ? "default" : "destructive"}>
{lastResult.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>
{lastResult.message}
</AlertDescription>
</Alert>
)}
{/* Template Selection */}
<div className="space-y-3">
<Label>{t("emailTemplate", "settings")}</Label>
<RadioGroup value={template} onValueChange={setTemplate} disabled={isLoading}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="verification" id="verification" />
<Label htmlFor="verification">{t("verification", "settings")}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="password-reset" id="password-reset" />
<Label htmlFor="password-reset">{t("passwordReset", "settings")}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="email-change" id="email-change" />
<Label htmlFor="email-change">{t("confirmEmailChange", "settings")}</Label>
</div>
</RadioGroup>
</div>
{/* Auth Collection - show for verification and password-reset templates */}
{(template === 'verification' || template === 'password-reset') && (
<div className="space-y-2">
<Label>{t("authCollection", "settings")} *</Label>
<Select value={collection} onValueChange={setCollection} disabled={isLoading}>
<SelectTrigger>
<SelectValue placeholder={t("selectCollection", "settings")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="_superusers">_superusers</SelectItem>
<SelectItem value="users">users</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Email Address */}
<div className="space-y-2">
<Label>{t("toEmailAddress", "settings")} *</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("enterEmailAddress", "settings")}
required
disabled={isLoading}
/>
</div>
{/* Info message */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This will send a test email using your configured SMTP settings. Make sure SMTP is properly configured first.
</AlertDescription>
</Alert>
</div>
<DialogFooter className="flex justify-between">
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
{t("close", "common")}
</Button>
<Button
onClick={handleSend}
disabled={!email || isLoading}
className="flex items-center gap-2"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Mail className="h-4 w-4" />
)}
{isLoading ? t("sending", "settings") : t("send", "common")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default TestEmailDialog;
@@ -4,6 +4,8 @@ import { useToast } from "@/hooks/use-toast";
import { userService, User, UpdateUserData, CreateUserData } from "@/services/userService"; import { userService, User, UpdateUserData, CreateUserData } from "@/services/userService";
import { UserFormValues, NewUserFormValues } from "../userForms"; import { UserFormValues, NewUserFormValues } from "../userForms";
import { avatarOptions } from "../avatarOptions"; import { avatarOptions } from "../avatarOptions";
import { authService } from "@/services/authService";
import { useNavigate } from "react-router-dom";
export const useUserOperations = ( export const useUserOperations = (
fetchUsers: () => Promise<void>, fetchUsers: () => Promise<void>,
@@ -15,6 +17,7 @@ export const useUserOperations = (
newUserFormReset: (values: any) => void newUserFormReset: (values: any) => void
) => { ) => {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate();
const handleDeleteUser = async (userToDelete: User | null) => { const handleDeleteUser = async (userToDelete: User | null) => {
if (!userToDelete) return; if (!userToDelete) return;
@@ -47,6 +50,11 @@ export const useUserOperations = (
setUpdateError(null); setUpdateError(null);
try { try {
// Get current logged-in user to check if we're editing ourselves
const loggedInUser = authService.getCurrentUser();
const isEditingSelf = loggedInUser?.id === currentUser.id;
const isEmailChanged = data.email !== currentUser.email;
// Create update object with only the fields we want to update // Create update object with only the fields we want to update
const updateData: UpdateUserData = { const updateData: UpdateUserData = {
full_name: data.full_name, full_name: data.full_name,
@@ -57,7 +65,6 @@ export const useUserOperations = (
}; };
// For avatar, only include if it's different from current one // For avatar, only include if it's different from current one
// Note: We're still sending the avatar path, but our updated userService will handle it properly
if (data.avatar && data.avatar !== currentUser.avatar) { if (data.avatar && data.avatar !== currentUser.avatar) {
updateData.avatar = data.avatar; updateData.avatar = data.avatar;
} }
@@ -66,9 +73,26 @@ export const useUserOperations = (
await userService.updateUser(currentUser.id, updateData); await userService.updateUser(currentUser.id, updateData);
// After successful update, refresh the auth user data if this is the current user // Handle email change for current user
// In a real app, you'd check if the updated user is the current logged-in user if (isEditingSelf && isEmailChanged) {
toast({
title: "Email changed successfully",
description: "You will be logged out for security reasons. Please log in again with your new email.",
variant: "default",
});
setIsDialogOpen(false);
// Auto-logout after 2 seconds if editing own email
setTimeout(() => {
authService.logout();
navigate("/login");
}, 2000);
return; // Don't continue with normal flow
}
// Normal success flow for other users or non-email changes
toast({ toast({
title: "User updated", title: "User updated",
description: `${data.full_name || data.username}'s profile has been updated.`, description: `${data.full_name || data.username}'s profile has been updated.`,
@@ -155,4 +179,4 @@ export const useUserOperations = (
onSubmit, onSubmit,
onAddUser, onAddUser,
}; };
}; };
@@ -1,3 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -18,7 +19,8 @@ const formSchema = z.object({
domain: z.string().min(1, "Domain is required"), domain: z.string().min(1, "Domain is required"),
warning_threshold: z.coerce.number().int().min(1).max(365), warning_threshold: z.coerce.number().int().min(1).max(365),
expiry_threshold: z.coerce.number().int().min(1).max(30), expiry_threshold: z.coerce.number().int().min(1).max(30),
notification_channel: z.string().min(1, "Notification channel is required") notification_channel: z.string().min(1, "Notification channel is required"),
check_interval: z.coerce.number().int().min(1).max(30).optional()
}); });
interface AddSSLCertificateFormProps { interface AddSSLCertificateFormProps {
@@ -42,7 +44,8 @@ export const AddSSLCertificateForm = ({
domain: "", domain: "",
warning_threshold: 30, warning_threshold: 30,
expiry_threshold: 7, expiry_threshold: 7,
notification_channel: "" notification_channel: "",
check_interval: 1
} }
}); });
@@ -89,7 +92,8 @@ export const AddSSLCertificateForm = ({
domain: values.domain, domain: values.domain,
warning_threshold: values.warning_threshold, warning_threshold: values.warning_threshold,
expiry_threshold: values.expiry_threshold, expiry_threshold: values.expiry_threshold,
notification_channel: values.notification_channel notification_channel: values.notification_channel,
check_interval: values.check_interval
}; };
await onSubmit(certData); await onSubmit(certData);
@@ -117,34 +121,53 @@ export const AddSSLCertificateForm = ({
)} )}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="warning_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>{t('warningThreshold')}</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormDescription>
{t('getNotifiedExpiration')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiry_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>{t('expiryThreshold')}</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormDescription>
{t('getNotifiedCritical')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="warning_threshold" name="check_interval"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('warningThreshold')}</FormLabel> <FormLabel>Check Interval (Days)</FormLabel>
<FormControl> <FormControl>
<Input type="number" {...field} /> <Input type="number" min="1" max="30" {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('getNotifiedExpiration')} How often to check the SSL certificate (in days)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiry_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>{t('expiryThreshold')}</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormDescription>
{t('getNotifiedCritical')}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1,3 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -17,6 +18,7 @@ const formSchema = z.object({
warning_threshold: z.coerce.number().min(1, "Warning threshold must be at least 1 day"), warning_threshold: z.coerce.number().min(1, "Warning threshold must be at least 1 day"),
expiry_threshold: z.coerce.number().min(1, "Expiry threshold must be at least 1 day"), expiry_threshold: z.coerce.number().min(1, "Expiry threshold must be at least 1 day"),
notification_channel: z.string().min(1, "Notification channel is required"), notification_channel: z.string().min(1, "Notification channel is required"),
check_interval: z.coerce.number().int().min(1).max(30).optional(),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -40,6 +42,7 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
warning_threshold: certificate.warning_threshold, warning_threshold: certificate.warning_threshold,
expiry_threshold: certificate.expiry_threshold, expiry_threshold: certificate.expiry_threshold,
notification_channel: certificate.notification_channel, notification_channel: certificate.notification_channel,
check_interval: certificate.check_interval || 1,
}, },
}); });
@@ -78,7 +81,8 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
...data, ...data,
// Ensure values are correctly typed as numbers // Ensure values are correctly typed as numbers
warning_threshold: Number(data.warning_threshold), warning_threshold: Number(data.warning_threshold),
expiry_threshold: Number(data.expiry_threshold) expiry_threshold: Number(data.expiry_threshold),
check_interval: data.check_interval ? Number(data.check_interval) : undefined
}; };
console.log("Submitting updated certificate:", updatedCertificate); console.log("Submitting updated certificate:", updatedCertificate);
@@ -113,7 +117,7 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
)} )}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="warning_threshold" name="warning_threshold"
@@ -157,6 +161,29 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="check_interval"
render={({ field }) => (
<FormItem>
<FormLabel>Check Interval (Days)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min="1"
max="30"
placeholder="1"
/>
</FormControl>
<FormDescription>
How often to check
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<FormField <FormField
@@ -230,4 +257,4 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
</form> </form>
</Form> </Form>
); );
}; };
@@ -0,0 +1,70 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { MoreHorizontal, RefreshCw, Edit, Trash2, Eye } from "lucide-react";
import { SSLCertificate } from "@/types/ssl.types";
import { triggerImmediateCheck } from "@/services/sslCertificateService";
import { toast } from "sonner";
import { useLanguage } from "@/contexts/LanguageContext";
interface SSLCertificateActionsProps {
certificate: SSLCertificate;
onView: (certificate: SSLCertificate) => void;
onEdit: (certificate: SSLCertificate) => void;
onDelete: (certificate: SSLCertificate) => void;
}
export const SSLCertificateActions = ({
certificate,
onView,
onEdit,
onDelete
}: SSLCertificateActionsProps) => {
const { t } = useLanguage();
const handleCheck = async () => {
try {
await triggerImmediateCheck(certificate.id);
} catch (error) {
console.error("Error triggering SSL check:", error);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(certificate)}>
<Eye className="mr-2 h-4 w-4" />
{t('view')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCheck}>
<RefreshCw className="mr-2 h-4 w-4" />
Check
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(certificate)}>
<Edit className="mr-2 h-4 w-4" />
{t('edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(certificate)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,219 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SSLCertificate } from "@/types/ssl.types";
import { SSLStatusBadge } from "./SSLStatusBadge";
import { useLanguage } from "@/contexts/LanguageContext";
interface SSLCertificateDetailDialogProps {
certificate: SSLCertificate | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const SSLCertificateDetailDialog = ({
certificate,
open,
onOpenChange,
}: SSLCertificateDetailDialogProps) => {
const { t } = useLanguage();
if (!certificate) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl font-bold">{t('sslCertificateDetails')}</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('viewDetailedInformation')} {certificate.domain}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Domain & Status Overview */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center justify-between">
<span>{certificate.domain}</span>
<SSLStatusBadge status={certificate.status} />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-muted-foreground">{t('status')}</p>
<SSLStatusBadge status={certificate.status} />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('daysLeft')}</p>
<Badge variant={certificate.days_left <= 7 ? 'destructive' : certificate.days_left <= 30 ? 'secondary' : 'default'}>
{certificate.days_left} {t('days')}
</Badge>
</div>
</div>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-muted-foreground">{t('validFrom')}</p>
<p className="text-sm">
{certificate.valid_from ? new Date(certificate.valid_from).toLocaleString() : 'N/A'}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('validUntil')}</p>
<p className="text-sm">
{certificate.valid_till ? new Date(certificate.valid_till).toLocaleString() : 'N/A'}
</p>
</div>
</div>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-muted-foreground">{t('validityPeriod')}</p>
<p className="text-sm">{certificate.validity_days || 0} {t('days')}</p>
</div>
{certificate.resolved_ip && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('resolvedIP')}</p>
<p className="text-sm font-mono bg-muted px-2 py-1 rounded">{certificate.resolved_ip}</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Certificate Information */}
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('certificateDetails')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-sm font-medium text-muted-foreground">{t('issuedTo')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{certificate.issued_to || 'N/A'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t('issuer')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{certificate.issuer_o || certificate.issuer_cn || 'N/A'}</p>
</div>
{certificate.issuer_cn && certificate.issuer_cn !== certificate.issuer_o && (
<div>
<p className="text-sm font-medium text-muted-foreground">Issuer Common Name</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{certificate.issuer_cn}</p>
</div>
)}
</div>
<div className="space-y-4">
{certificate.serial_number && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('serialNumber')}</p>
<p className="text-xs font-mono bg-muted px-3 py-2 rounded break-all">{certificate.serial_number}</p>
</div>
)}
{certificate.cert_alg && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('algorithm')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{certificate.cert_alg}</p>
</div>
)}
</div>
</div>
{certificate.cert_sans && (
<div className="mt-4">
<p className="text-sm font-medium text-muted-foreground mb-2">{t('subjectAlternativeNames')}</p>
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm whitespace-pre-wrap">{certificate.cert_sans}</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Monitoring Configuration */}
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('monitoringConfiguration')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm font-medium text-muted-foreground">{t('warningThreshold')}</p>
<p className="text-sm font-semibold">{certificate.warning_threshold} {t('days')}</p>
</div>
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm font-medium text-muted-foreground">{t('expiryThreshold')}</p>
<p className="text-sm font-semibold">{certificate.expiry_threshold} {t('days')}</p>
</div>
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm font-medium text-muted-foreground">Check Interval</p>
<p className="text-sm font-semibold">{certificate.check_interval || 1} {t('days')}</p>
</div>
<div className="bg-muted px-3 py-2 rounded">
<p className="text-sm font-medium text-muted-foreground">{t('notificationChannel')}</p>
<p className="text-sm font-semibold">{certificate.notification_channel || 'N/A'}</p>
</div>
</div>
{certificate.last_notified && (
<div className="mt-4">
<p className="text-sm font-medium text-muted-foreground">{t('lastNotified')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{new Date(certificate.last_notified).toLocaleString()}</p>
</div>
)}
</CardContent>
</Card>
{/* System Information */}
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('technicalInformation')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
{certificate.check_at && (
<div>
<p className="text-sm font-medium text-muted-foreground">Next Check</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{new Date(certificate.check_at).toLocaleString()}</p>
</div>
)}
{certificate.created && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('created')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{new Date(certificate.created).toLocaleString()}</p>
</div>
)}
</div>
<div className="space-y-3">
{certificate.updated && (
<div>
<p className="text-sm font-medium text-muted-foreground">{t('lastUpdated')}</p>
<p className="text-sm bg-muted px-3 py-2 rounded">{new Date(certificate.updated).toLocaleString()}</p>
</div>
)}
<div>
<p className="text-sm font-medium text-muted-foreground">Certificate ID</p>
<p className="text-xs font-mono bg-muted px-3 py-2 rounded break-all">{certificate.id}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,362 +1,255 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { format } from "date-fns"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import { Plus } from "lucide-react";
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell
} from "@/components/ui/table";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { RefreshCw, Eye, Edit, Trash2, MoreHorizontal } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SSLCertificate } from "@/types/ssl.types";
import { SSLStatusBadge } from "./SSLStatusBadge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { import {
DropdownMenu, Table,
DropdownMenuContent, TableBody,
DropdownMenuItem, TableCell,
DropdownMenuTrigger, TableHead,
} from "@/components/ui/dropdown-menu"; TableHeader,
import { toast } from "sonner"; TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SSLStatusBadge } from "./SSLStatusBadge";
import { AddSSLCertificateForm } from "./AddSSLCertificateForm";
import { EditSSLCertificateForm } from "./EditSSLCertificateForm";
import { SSLCertificateActions } from "./SSLCertificateActions";
import { SSLCertificateDetailDialog } from "./SSLCertificateDetailDialog";
import { fetchSSLCertificates, addSSLCertificate, deleteSSLCertificate } from "@/services/sslCertificateService";
import { pb } from "@/lib/pocketbase";
import { SSLCertificate } from "@/types/ssl.types";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { toast } from "sonner";
interface SSLCertificatesTableProps { export const SSLCertificatesTable = () => {
certificates: SSLCertificate[];
onRefresh: (id: string) => void;
refreshingId: string | null;
onEdit?: (certificate: SSLCertificate) => void;
onDelete?: (certificate: SSLCertificate) => void;
}
export const SSLCertificatesTable = ({
certificates,
onRefresh,
refreshingId,
onEdit,
onDelete
}: SSLCertificatesTableProps) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [selectedCert, setSelectedCert] = useState<SSLCertificate | null>(null); const queryClient = useQueryClient();
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [showAddDialog, setShowAddDialog] = useState(false);
const [certToDelete, setCertToDelete] = useState<SSLCertificate | null>(null); const [showEditDialog, setShowEditDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const formatDate = (dateString: string | undefined) => { const [showViewDialog, setShowViewDialog] = useState(false);
if (!dateString) return t('unknown'); const [selectedCertificate, setSelectedCertificate] = useState<SSLCertificate | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: certificates = [], isLoading, isError } = useQuery({
queryKey: ['ssl-certificates'],
queryFn: fetchSSLCertificates,
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error loading certificates</div>;
const handleAddCertificate = async (data: any) => {
setIsSubmitting(true);
try { try {
const date = new Date(dateString); await addSSLCertificate(data);
if (isNaN(date.getTime())) { await queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
console.warn("Invalid date for formatting:", dateString); setShowAddDialog(false);
return t('unknown'); toast.success(t('certificateAdded'));
}
return format(date, "MMM dd, yyyy");
} catch (error) { } catch (error) {
console.error("Error formatting date:", error); console.error("Error adding certificate:", error);
return t('unknown'); toast.error(t('failedToAddCertificate'));
} finally {
setIsSubmitting(false);
} }
}; };
const handleViewCertificate = (certificate: SSLCertificate) => { const handleEditCertificate = async (updatedCertificate: SSLCertificate) => {
setSelectedCert(certificate); setIsSubmitting(true);
}; try {
await pb.collection('ssl_certificates').update(updatedCertificate.id, {
const handleEditCertificate = (certificate: SSLCertificate) => { warning_threshold: updatedCertificate.warning_threshold,
if (onEdit) { expiry_threshold: updatedCertificate.expiry_threshold,
onEdit(certificate); notification_channel: updatedCertificate.notification_channel,
} else { check_interval: updatedCertificate.check_interval,
toast.error("Edit functionality not implemented yet"); });
await queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
setShowEditDialog(false);
setSelectedCertificate(null);
toast.success(t('certificateUpdated'));
} catch (error) {
console.error("Error updating certificate:", error);
toast.error(t('failedToUpdateCertificate'));
} finally {
setIsSubmitting(false);
} }
}; };
const handleDeleteCertificate = (certificate: SSLCertificate) => { const handleDeleteCertificate = async () => {
setCertToDelete(certificate); if (!selectedCertificate) return;
setDeleteConfirmOpen(true);
setIsSubmitting(true);
try {
await deleteSSLCertificate(selectedCertificate.id);
await queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
setShowDeleteDialog(false);
setSelectedCertificate(null);
toast.success(t('certificateDeleted'));
} catch (error) {
console.error("Error deleting certificate:", error);
toast.error(t('failedToDeleteCertificate'));
} finally {
setIsSubmitting(false);
}
}; };
const confirmDelete = () => { const openViewDialog = (certificate: SSLCertificate) => {
if (certToDelete && onDelete) { setSelectedCertificate(certificate);
onDelete(certToDelete); setShowViewDialog(true);
setDeleteConfirmOpen(false); };
setCertToDelete(null);
} const openEditDialog = (certificate: SSLCertificate) => {
setSelectedCertificate(certificate);
setShowEditDialog(true);
};
const openDeleteDialog = (certificate: SSLCertificate) => {
setSelectedCertificate(certificate);
setShowDeleteDialog(true);
}; };
return ( return (
<> <>
<div className="rounded-md border relative"> <Card>
{refreshingId && ( <CardContent>
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-10"> <Table>
<div className="bg-background p-4 rounded-md shadow flex items-center gap-2"> <TableHeader>
<RefreshCw className="h-5 w-5 animate-spin text-primary" />
<span>{t('checkingSSLCertificate')}</span>
</div>
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('domain')}</TableHead>
<TableHead>{t('issuer')}</TableHead>
<TableHead>{t('expirationDate')}</TableHead>
<TableHead>{t('daysLeft')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead>{t('lastNotified')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{certificates.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-8"> <TableHead>{t('domain')}</TableHead>
{t('noSSLCertificates')} <TableHead>{t('status')}</TableHead>
</TableCell> <TableHead>{t('issuer')}</TableHead>
<TableHead>{t('validUntil')}</TableHead>
<TableHead>{t('daysLeft')}</TableHead>
<TableHead>Check Interval</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
) : ( </TableHeader>
certificates.map((certificate) => ( <TableBody>
{certificates.map((certificate) => (
<TableRow key={certificate.id}> <TableRow key={certificate.id}>
<TableCell className="font-medium">{certificate.domain}</TableCell> <TableCell className="font-medium">
<TableCell>{certificate.issuer_o || t('unknown')}</TableCell> {certificate.domain}
<TableCell>
{formatDate(certificate.valid_till)}
</TableCell>
<TableCell>
{typeof certificate.days_left === 'number' ? certificate.days_left : t('unknown')}
</TableCell> </TableCell>
<TableCell> <TableCell>
<SSLStatusBadge status={certificate.status} /> <SSLStatusBadge status={certificate.status} />
</TableCell> </TableCell>
<TableCell>{certificate.issuer_o || certificate.issuer_cn || 'Unknown'}</TableCell>
<TableCell> <TableCell>
{certificate.last_notified {certificate.valid_till ? new Date(certificate.valid_till).toLocaleDateString() : 'N/A'}
? formatDate(certificate.last_notified)
: t('never')}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell>
<DropdownMenu> <span className={certificate.days_left <= 7 ? 'text-red-600 font-semibold' : certificate.days_left <= 30 ? 'text-yellow-600 font-semibold' : 'text-green-600'}>
<DropdownMenuTrigger asChild> {certificate.days_left} {t('days')}
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> </span>
<span className="sr-only">Open menu</span> </TableCell>
<MoreHorizontal className="h-4 w-4" /> <TableCell>
</Button> {certificate.check_interval || 1} {t('days')}
</DropdownMenuTrigger> </TableCell>
<DropdownMenuContent align="end" className="bg-background border border-border"> <TableCell>
<DropdownMenuItem <SSLCertificateActions
onClick={() => handleViewCertificate(certificate)} certificate={certificate}
className="cursor-pointer" onView={openViewDialog}
> onEdit={openEditDialog}
<Eye className="mr-2 h-4 w-4" /> {t('view')} onDelete={openDeleteDialog}
</DropdownMenuItem> />
<DropdownMenuItem
onClick={() => {
if (refreshingId === null) {
onRefresh(certificate.id);
}
}}
disabled={refreshingId !== null}
className="cursor-pointer"
>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshingId === certificate.id ? 'animate-spin text-primary' : ''}`} />
{t('check')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleEditCertificate(certificate)}
className="cursor-pointer"
>
<Edit className="mr-2 h-4 w-4" /> {t('edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteCertificate(certificate)}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" /> {t('delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))}
)} </TableBody>
</TableBody> </Table>
</Table>
</div>
{/* SSL Certificate Details Dialog */} {certificates.length === 0 && (
<Dialog open={!!selectedCert} onOpenChange={(open) => !open && setSelectedCert(null)}> <div className="text-center py-8 text-muted-foreground">
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden"> {t('noCertificatesFound')}
<DialogHeader className="p-6 pb-2">
<DialogTitle className="text-xl">
{t('sslCertificateDetails')}
</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
{t('detailedInfo')} {selectedCert?.domain}
</p>
</DialogHeader>
{selectedCert && (
<div className="p-6 pt-2 space-y-4 max-h-[70vh] overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Basic Information */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('basicInformation')}</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('domain')}:</span>
<span>{selectedCert.domain}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('status')}:</span>
<span><SSLStatusBadge status={selectedCert.status} /></span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('issuer')}:</span>
<span>{selectedCert.issued_to || t('unknown')}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">IP:</span>
<span>{selectedCert.resolved_ip || t('unknown')}</span>
</div>
</div>
</div>
{/* Validity */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('validity')}</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('validFrom')}:</span>
<span>{selectedCert.valid_from ? formatDate(selectedCert.valid_from) : t('unknown')}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('validUntil')}:</span>
<span>{formatDate(selectedCert.valid_till)}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('daysLeft')}:</span>
<span>{selectedCert.days_left}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('validityDays')}:</span>
<span>{selectedCert.validity_days || t('unknown')}</span>
</div>
</div>
</div>
{/* Issuer */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('issuerInfo')}</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('organization')}:</span>
<span>{selectedCert.issuer_o || t('unknown')}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('commonName')}:</span>
<span>{selectedCert.issuer_cn || t('unknown')}</span>
</div>
</div>
</div>
{/* Technical Details */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('technicalDetails')}</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('serialNumber')}:</span>
<span>{selectedCert.serial_number || t('unknown')}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<span className="text-muted-foreground">{t('algorithm')}:</span>
<span>{selectedCert.cert_alg || t('unknown')}</span>
</div>
</div>
</div>
</div>
{/* Subject Alternative Names */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('subjectAltNames')}</h3>
<div>
{selectedCert.cert_sans ? (
<p className="break-words">{selectedCert.cert_sans}</p>
) : (
<p>{t('none')}</p>
)}
</div>
</div>
{/* Monitoring Configuration */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('monitoringConfig')}</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="space-y-1">
<div className="text-muted-foreground">{t('warningThreshold')}:</div>
<div>{selectedCert.warning_threshold} {t('daysLeft').toLowerCase()}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('expiryThreshold')}:</div>
<div>{selectedCert.expiry_threshold} {t('daysLeft').toLowerCase()}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('notificationChannel')}:</div>
<div>{selectedCert.notification_channel}</div>
</div>
</div>
</div>
{/* Timestamps */}
<div className="border rounded-md p-4">
<h3 className="font-semibold mb-3">{t('recordInfo')}</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="text-muted-foreground">{t('created')}:</div>
<div>{selectedCert.created ? formatDate(selectedCert.created) : t('unknown')}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('lastUpdated')}:</div>
<div>{selectedCert.updated ? formatDate(selectedCert.updated) : t('unknown')}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('lastNotification')}:</div>
<div>{selectedCert.last_notified ? formatDate(selectedCert.last_notified) : t('never')}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('collectionId')}:</div>
<div>{selectedCert.collectionId || t('unknown')}</div>
</div>
</div>
</div>
</div> </div>
)} )}
</CardContent>
<DialogFooter className="p-6 pt-0 border-t"> </Card>
<Button
variant="default" {/* View Certificate Dialog */}
onClick={() => setSelectedCert(null)} <SSLCertificateDetailDialog
className="w-full sm:w-auto" certificate={selectedCertificate}
> open={showViewDialog}
{t('close')} onOpenChange={setShowViewDialog}
</Button> />
</DialogFooter>
{/* Add Certificate Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('addSSLCertificate')}</DialogTitle>
<DialogDescription>
{t('addCertificateDescription')}
</DialogDescription>
</DialogHeader>
<AddSSLCertificateForm
onSubmit={handleAddCertificate}
onCancel={() => setShowAddDialog(false)}
isPending={isSubmitting}
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete Confirmation Dialog */} {/* Edit Certificate Dialog */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> <Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('deleteSSLCertificate')}</DialogTitle> <DialogTitle>{t('editSSLCertificate')}</DialogTitle>
<DialogDescription>
{t('editCertificateDescription')}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-4"> {selectedCertificate && (
<p>{t('deleteConfirmation')} <strong>{certToDelete?.domain}</strong>?</p> <EditSSLCertificateForm
<p className="text-sm text-muted-foreground mt-2">{t('deleteWarning')}</p> certificate={selectedCertificate}
</div> onSubmit={handleEditCertificate}
<DialogFooter> onCancel={() => setShowEditDialog(false)}
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}>{t('cancel')}</Button> isPending={isSubmitting}
<Button variant="destructive" onClick={confirmDelete}>{t('delete')}</Button> />
</DialogFooter> )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete Certificate Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteCertificate')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteCertificateConfirmation')} {selectedCertificate?.domain}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteCertificate}
disabled={isSubmitting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isSubmitting ? t('deleting') : t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
); );
}; };
@@ -1,3 +1,4 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -242,13 +243,7 @@ export const SSLDomainContent = () => {
<SSLCertificateStatusCards certificates={certificates} /> <SSLCertificateStatusCards certificates={certificates} />
<div className="mt-6 flex-1 flex flex-col pb-6"> <div className="mt-6 flex-1 flex flex-col pb-6">
<SSLCertificatesTable <SSLCertificatesTable />
certificates={certificates}
onRefresh={handleRefreshCertificate}
refreshingId={refreshingId}
onEdit={handleEditCertificate}
onDelete={handleDeleteCertificate}
/>
</div> </div>
</div> </div>
@@ -0,0 +1,38 @@
import React, { createContext, useContext, useState } from 'react';
interface SidebarContextType {
sidebarCollapsed: boolean;
setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebar: () => void;
}
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const toggleSidebar = () => {
setSidebarCollapsed(prev => !prev);
};
const value = {
sidebarCollapsed,
setSidebarCollapsed,
toggleSidebar
};
return (
<SidebarContext.Provider value={value}>
{children}
</SidebarContext.Provider>
);
};
export const useSidebar = () => {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
};
@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { operationalPageService } from '@/services/operationalPageService';
import { OperationalPageRecord } from '@/types/operational.types';
import { toast } from '@/hooks/use-toast';
export const useOperationalPages = () => {
return useQuery({
queryKey: ['operational-pages'],
queryFn: operationalPageService.getOperationalPages,
staleTime: 30000,
});
};
export const useOperationalPage = (id: string) => {
return useQuery({
queryKey: ['operational-page', id],
queryFn: () => operationalPageService.getOperationalPage(id),
enabled: !!id,
staleTime: 30000,
});
};
export const useUpdateOperationalPage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<OperationalPageRecord> }) =>
operationalPageService.updateOperationalPage(id, data),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['operational-pages'] });
queryClient.invalidateQueries({ queryKey: ['operational-page', data.id] });
toast({
title: 'Success',
description: 'Operational page updated successfully',
});
},
onError: (error) => {
toast({
title: 'Error',
description: 'Failed to update operational page',
variant: 'destructive',
});
},
});
};
export const useCreateOperationalPage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: operationalPageService.createOperationalPage,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['operational-pages'] });
toast({
title: 'Success',
description: 'Operational page created successfully',
});
},
onError: (error) => {
toast({
title: 'Error',
description: 'Failed to create operational page',
variant: 'destructive',
});
},
});
};
export const useDeleteOperationalPage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: operationalPageService.deleteOperationalPage,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['operational-pages'] });
toast({
title: 'Success',
description: 'Operational page deleted successfully',
});
},
onError: (error) => {
toast({
title: 'Error',
description: 'Failed to delete operational page',
variant: 'destructive',
});
},
});
};
@@ -0,0 +1,66 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { statusPageComponentsService } from '@/services/statusPageComponentsService';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { toast } from '@/hooks/use-toast';
export const useStatusPageComponents = () => {
return useQuery({
queryKey: ['status-page-components'],
queryFn: statusPageComponentsService.getStatusPageComponents,
staleTime: 30000,
});
};
export const useStatusPageComponentsByOperationalId = (operationalStatusId: string) => {
return useQuery({
queryKey: ['status-page-components', operationalStatusId],
queryFn: () => statusPageComponentsService.getStatusPageComponentsByOperationalId(operationalStatusId),
enabled: !!operationalStatusId,
staleTime: 30000,
});
};
export const useCreateStatusPageComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: statusPageComponentsService.createStatusPageComponent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['status-page-components'] });
toast({
title: 'Success',
description: 'Status page component created successfully',
});
},
onError: (error) => {
toast({
title: 'Error',
description: 'Failed to create status page component',
variant: 'destructive',
});
},
});
};
export const useDeleteStatusPageComponent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: statusPageComponentsService.deleteStatusPageComponent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['status-page-components'] });
toast({
title: 'Success',
description: 'Status page component deleted successfully',
});
},
onError: (error) => {
toast({
title: 'Error',
description: 'Failed to delete status page component',
variant: 'destructive',
});
},
});
};
+1 -1
View File
@@ -2,4 +2,4 @@ import { createRoot } from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import './index.css' import './index.css'
createRoot(document.getElementById("root")!).render(<App />); createRoot(document.getElementById("root")!).render(<App />);
+5 -5
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Header } from "@/components/dashboard/Header"; import { Header } from "@/components/dashboard/Header";
import { Sidebar } from "@/components/dashboard/Sidebar"; import { Sidebar } from "@/components/dashboard/Sidebar";
@@ -8,11 +8,11 @@ import { serviceService } from "@/services/serviceService";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { LoadingState } from "@/components/services/LoadingState"; import { LoadingState } from "@/components/services/LoadingState";
import { useSidebar } from "@/contexts/SidebarContext";
const Dashboard = () => { const Dashboard = () => {
// State for sidebar collapse functionality // Use shared sidebar state
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const { sidebarCollapsed, toggleSidebar } = useSidebar();
const toggleSidebar = () => setSidebarCollapsed(prev => !prev);
// Get current user // Get current user
const currentUser = authService.getCurrentUser(); const currentUser = authService.getCurrentUser();
@@ -71,4 +71,4 @@ const Dashboard = () => {
); );
}; };
export default Dashboard; export default Dashboard;
+20 -5
View File
@@ -1,14 +1,29 @@
// Update this page (the content is just a fallback if you fail to update the page)
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { authService } from '@/services/authService';
const Index = () => { const Index = () => {
const navigate = useNavigate();
useEffect(() => {
// Check if user is authenticated and redirect accordingly
if (authService.isAuthenticated()) {
navigate('/dashboard', { replace: true });
} else {
navigate('/login', { replace: true });
}
}, [navigate]);
// Show a loading state while redirecting
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-100"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center"> <div className="text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to Your Blank App</h1> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-xl text-gray-600">Start building your amazing project here!</p> <p className="text-muted-foreground">Loading...</p>
</div> </div>
</div> </div>
); );
}; };
export default Index; export default Index;
+79 -22
View File
@@ -5,13 +5,15 @@ import { authService } from '@/services/authService';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui/use-toast'; import { toast } from '@/components/ui/use-toast';
import { Eye, EyeOff, Mail, LogIn, Settings } from "lucide-react"; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Eye, EyeOff, Mail, LogIn, Settings, AlertCircle } from "lucide-react";
import { useLanguage } from '@/contexts/LanguageContext'; import { useLanguage } from '@/contexts/LanguageContext';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { API_ENDPOINTS, getCurrentEndpoint, setApiEndpoint } from '@/lib/pocketbase'; import { API_ENDPOINTS, getCurrentEndpoint, setApiEndpoint } from '@/lib/pocketbase';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { ForgotPasswordDialog } from '@/components/auth/ForgotPasswordDialog';
const Login = () => { const Login = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -19,6 +21,8 @@ const Login = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [currentEndpoint, setCurrentEndpoint] = useState(getCurrentEndpoint()); const [currentEndpoint, setCurrentEndpoint] = useState(getCurrentEndpoint());
const [showForgotPassword, setShowForgotPassword] = useState(false);
const [loginError, setLoginError] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useLanguage(); const { t } = useLanguage();
const { theme } = useTheme(); const { theme } = useTheme();
@@ -42,6 +46,7 @@ const Login = () => {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setLoginError(''); // Clear previous errors
try { try {
await authService.login({ email, password }); await authService.login({ email, password });
@@ -52,11 +57,31 @@ const Login = () => {
navigate('/dashboard'); navigate('/dashboard');
} catch (error) { } catch (error) {
console.error("Login error details:", error); console.error("Login error details:", error);
// Set specific error message based on the error
if (error instanceof Error) {
if (error.message.includes('Failed to authenticate') ||
error.message.includes('Authentication failed') ||
error.message.includes('Invalid credentials') ||
error.message.includes('invalid email or password')) {
setLoginError(t("invalidCredentials"));
} else {
setLoginError(error.message);
}
} else {
setLoginError(`${t("authenticationFailed")}. Server: ${currentEndpoint}`);
}
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("loginFailed"), title: t("loginFailed"),
description: error instanceof Error description: error instanceof Error
? error.message ? (error.message.includes('Failed to authenticate') ||
error.message.includes('Authentication failed') ||
error.message.includes('Invalid credentials') ||
error.message.includes('invalid email or password'))
? t("invalidCredentials")
: error.message
: `${t("authenticationFailed")}. Server: ${currentEndpoint}`, : `${t("authenticationFailed")}. Server: ${currentEndpoint}`,
}); });
} finally { } finally {
@@ -77,10 +102,15 @@ const Login = () => {
<div className="flex items-center justify-center min-h-screen bg-background p-4"> <div className="flex items-center justify-center min-h-screen bg-background p-4">
<div className="w-full max-w-md p-4 sm:p-8 space-y-6 rounded-xl bg-card shadow-xl border border-border/20"> <div className="w-full max-w-md p-4 sm:p-8 space-y-6 rounded-xl bg-card shadow-xl border border-border/20">
<div className="text-center relative"> <div className="text-center relative">
{/* Commented out API Endpoint Settings button
<div className="absolute right-0 top-0"> <div className="absolute right-0 top-0">
<Popover> <Popover>
<PopoverTrigger asChild>
{/* <PopoverContent className="w-[260px] sm:w-80"> //this allow test api connection in login page <Button variant="outline" size="icon" aria-label="API Settings">
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] sm:w-80">
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-medium">API Endpoint Settings</h4> <h4 className="font-medium">API Endpoint Settings</h4>
<RadioGroup <RadioGroup
@@ -101,25 +131,35 @@ const Login = () => {
Current endpoint: {currentEndpoint} Current endpoint: {currentEndpoint}
</div> </div>
</div> </div>
</PopoverContent> */} </PopoverContent>
</Popover> </Popover>
</div> </div>
<h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">{t("signInToYourAccount")}</h1> */}
</div> {/* Logo */}
<div className="mb-4">
<div> <img
<div className="mt-4 sm:mt-6 relative"> src="/checkcle_logo.svg"
<div className="absolute inset-0 flex items-center"> alt="CheckCle Logo"
<div className="w-full border-t border-border"></div> className="mx-auto h-16 w-auto"
</div> />
<div className="relative flex justify-center text-xs sm:text-sm">
<span className="px-4 bg-card text-muted-foreground">{t("orContinueWith")}</span>
</div>
</div> </div>
<h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">{t("signInToYourAccount")}</h1>
{/* Removed "Don't have an account? Create one" text */}
</div> </div>
<form onSubmit={handleSubmit} className="mt-4 sm:mt-6 space-y-4 sm:space-y-6"> {/* Removed Google Sign in button section */}
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
{/* Error Alert */}
{loginError && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{loginError}</AlertDescription>
</Alert>
)}
<div className="space-y-1 sm:space-y-2"> <div className="space-y-1 sm:space-y-2">
<label className="text-xs sm:text-sm font-medium text-foreground" htmlFor="email">{t("email")}</label> <label className="text-xs sm:text-sm font-medium text-foreground" htmlFor="email">{t("email")}</label>
<div className="relative"> <div className="relative">
@@ -131,7 +171,10 @@ const Login = () => {
placeholder="your.email@provider.com" placeholder="your.email@provider.com"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
setEmail(e.target.value);
setLoginError(''); // Clear error when user starts typing
}}
required required
className="pl-10 text-sm sm:text-base h-9 sm:h-10" className="pl-10 text-sm sm:text-base h-9 sm:h-10"
/> />
@@ -141,7 +184,13 @@ const Login = () => {
<div className="space-y-1 sm:space-y-2"> <div className="space-y-1 sm:space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs sm:text-sm font-medium text-foreground" htmlFor="password">{t("password")}</label> <label className="text-xs sm:text-sm font-medium text-foreground" htmlFor="password">{t("password")}</label>
<a href="#" className="text-xs sm:text-sm text-emerald-500 hover:text-emerald-400">{t("forgot")}</a> <button
type="button"
onClick={() => setShowForgotPassword(true)}
className="text-xs sm:text-sm text-emerald-500 hover:text-emerald-400"
>
{t("forgot")}
</button>
</div> </div>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
@@ -155,7 +204,10 @@ const Login = () => {
placeholder="••••••••••••" placeholder="••••••••••••"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
setPassword(e.target.value);
setLoginError(''); // Clear error when user starts typing
}}
required required
className="pl-10 text-sm sm:text-base h-9 sm:h-10" className="pl-10 text-sm sm:text-base h-9 sm:h-10"
/> />
@@ -188,8 +240,13 @@ const Login = () => {
</p> </p>
</form> </form>
</div> </div>
<ForgotPasswordDialog
open={showForgotPassword}
onOpenChange={setShowForgotPassword}
/>
</div> </div>
); );
}; };
export default Login; export default Login;
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
import { Header } from "@/components/dashboard/Header";
import { Sidebar } from "@/components/dashboard/Sidebar";
import { OperationalPageContent } from '@/components/operational-page/OperationalPageContent';
import { authService } from "@/services/authService";
import { useNavigate } from "react-router-dom";
import { useSidebar } from "@/contexts/SidebarContext";
const OperationalPage = () => {
// Use shared sidebar state
const { sidebarCollapsed, toggleSidebar } = useSidebar();
// Get current user
const currentUser = authService.getCurrentUser();
const navigate = useNavigate();
// Handle logout
const handleLogout = () => {
authService.logout();
navigate("/login");
};
return (
<div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-col flex-1">
<Header
currentUser={currentUser}
onLogout={handleLogout}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/>
<div className="flex-1 overflow-auto">
<OperationalPageContent />
</div>
</div>
</div>
);
};
export default OperationalPage;
+3 -3
View File
@@ -9,11 +9,11 @@ import { ProfileContent } from "@/components/profile/ProfileContent";
import { User } from "@/services/userService"; import { User } from "@/services/userService";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useSidebar } from "@/contexts/SidebarContext";
const Profile = () => { const Profile = () => {
// State for sidebar collapse functionality // Use shared sidebar state
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const { sidebarCollapsed, toggleSidebar } = useSidebar();
const toggleSidebar = () => setSidebarCollapsed(prev => !prev);
// Get current user // Get current user
const currentUser = authService.getCurrentUser(); const currentUser = authService.getCurrentUser();
@@ -0,0 +1,9 @@
import React from 'react';
import { PublicStatusPage as PublicStatusPageContent } from '@/components/public/PublicStatusPage';
const PublicStatusPage = () => {
return <PublicStatusPageContent />;
};
export default PublicStatusPage;
+4 -6
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect } from 'react';
import { Header } from "@/components/dashboard/Header"; import { Header } from "@/components/dashboard/Header";
import { Sidebar } from "@/components/dashboard/Sidebar"; import { Sidebar } from "@/components/dashboard/Sidebar";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
@@ -8,11 +8,11 @@ import { ScheduleIncidentContent } from "@/components/schedule-incident/Schedule
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { initMaintenanceNotifications, stopMaintenanceNotifications } from "@/services/maintenance/maintenanceNotificationService"; import { initMaintenanceNotifications, stopMaintenanceNotifications } from "@/services/maintenance/maintenanceNotificationService";
import { useSidebar } from "@/contexts/SidebarContext";
const ScheduleIncident = () => { const ScheduleIncident = () => {
// State for sidebar collapse functionality // Use shared sidebar state
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const { sidebarCollapsed, toggleSidebar } = useSidebar();
const toggleSidebar = () => setSidebarCollapsed(prev => !prev);
// Get current theme and language // Get current theme and language
const { theme } = useTheme(); const { theme } = useTheme();
@@ -24,12 +24,10 @@ const ScheduleIncident = () => {
// Initialize maintenance notifications // Initialize maintenance notifications
useEffect(() => { useEffect(() => {
console.log("Initializing maintenance notifications");
initMaintenanceNotifications(); initMaintenanceNotifications();
// Clean up on unmount // Clean up on unmount
return () => { return () => {
console.log("Stopping maintenance notifications");
stopMaintenanceNotifications(); stopMaintenanceNotifications();
}; };
}, []); }, []);
+6 -4
View File
@@ -9,11 +9,12 @@ import UserManagement from "@/components/settings/user-management";
import { NotificationSettings } from "@/components/settings/notification-settings"; import { NotificationSettings } from "@/components/settings/notification-settings";
import { AlertsTemplates } from "@/components/settings/alerts-templates"; import { AlertsTemplates } from "@/components/settings/alerts-templates";
import { AboutSystem } from "@/components/settings/about-system"; import { AboutSystem } from "@/components/settings/about-system";
import DataRetentionSettings from "@/components/settings/data-retention/DataRetentionSettings";
import { useSidebar } from "@/contexts/SidebarContext";
const Settings = () => { const Settings = () => {
// State for sidebar collapse functionality // Use shared sidebar state
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const { sidebarCollapsed, toggleSidebar } = useSidebar();
const toggleSidebar = () => setSidebarCollapsed(prev => !prev);
// Get current user // Get current user
const currentUser = authService.getCurrentUser(); const currentUser = authService.getCurrentUser();
@@ -58,6 +59,7 @@ const Settings = () => {
{activePanel === "users" && <UserManagement />} {activePanel === "users" && <UserManagement />}
{activePanel === "notifications" && <NotificationSettings />} {activePanel === "notifications" && <NotificationSettings />}
{activePanel === "templates" && <AlertsTemplates />} {activePanel === "templates" && <AlertsTemplates />}
{activePanel === "data-retention" && <DataRetentionSettings />}
{activePanel === "about" && <AboutSystem />} {activePanel === "about" && <AboutSystem />}
</div> </div>
</div> </div>
@@ -65,4 +67,4 @@ const Settings = () => {
); );
}; };
export default Settings; export default Settings;
+4 -3
View File
@@ -1,3 +1,4 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
@@ -8,14 +9,14 @@ import { SSLDomainContent } from "@/components/ssl-domain/SSLDomainContent";
import { LoadingState } from "@/components/services/LoadingState"; import { LoadingState } from "@/components/services/LoadingState";
import { fetchSSLCertificates, shouldRunDailyCheck, checkAllCertificatesAndNotify } from "@/services/sslCertificateService"; import { fetchSSLCertificates, shouldRunDailyCheck, checkAllCertificatesAndNotify } from "@/services/sslCertificateService";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { useSidebar } from "@/contexts/SidebarContext";
const SslDomain = () => { const SslDomain = () => {
// Get language context for translations // Get language context for translations
const { t } = useLanguage(); const { t } = useLanguage();
// State for sidebar collapse functionality // Use shared sidebar state
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); const { sidebarCollapsed, toggleSidebar } = useSidebar();
const toggleSidebar = () => setSidebarCollapsed(prev => !prev);
// Get current user // Get current user
const currentUser = authService.getCurrentUser(); const currentUser = authService.getCurrentUser();
@@ -0,0 +1,293 @@
import { pb } from '@/lib/pocketbase';
interface RetentionSettings {
uptimeRetentionDays: number;
serverRetentionDays: number;
lastCleanup?: string;
}
interface CleanupResult {
deletedRecords: number;
collections: string[];
}
export const dataRetentionService = {
async getRetentionSettings(): Promise<RetentionSettings | null> {
try {
// Try to get existing settings from data_settings collection
const records = await pb.collection('data_settings').getFullList({
sort: '-created'
});
if (records.length > 0) {
const settings = records[0];
return {
uptimeRetentionDays: settings.uptime_retention_days || 30,
serverRetentionDays: settings.server_retention_days || 30,
lastCleanup: settings.last_cleanup
};
}
// Return default settings if none exist
return {
uptimeRetentionDays: 30,
serverRetentionDays: 30
};
} catch (error) {
console.error("Error fetching retention settings:", error);
// Return default settings on error
return {
uptimeRetentionDays: 30,
serverRetentionDays: 30
};
}
},
async updateRetentionSettings(settings: RetentionSettings): Promise<void> {
try {
// Check if settings already exist
const existingRecords = await pb.collection('data_settings').getFullList({
sort: '-created'
});
const data = {
uptime_retention_days: settings.uptimeRetentionDays,
server_retention_days: settings.serverRetentionDays,
retention_days: Math.max(settings.uptimeRetentionDays, settings.serverRetentionDays), // General retention days
backup: "auto", // Default backup setting
updated: new Date().toISOString()
};
if (existingRecords.length > 0) {
// Update existing record
await pb.collection('data_settings').update(existingRecords[0].id, data);
} else {
// Create new record
await pb.collection('data_settings').create({
...data,
created: new Date().toISOString()
});
}
console.log("Retention settings updated successfully");
} catch (error) {
console.error("Error updating retention settings:", error);
throw new Error("Failed to update retention settings");
}
},
async manualUptimeCleanup(): Promise<CleanupResult> {
try {
const settings = await this.getRetentionSettings();
if (!settings) {
throw new Error("Could not load retention settings");
}
let totalDeleted = 0;
const cleanedCollections: string[] = [];
// Calculate cutoff date for uptime data
const uptimeCutoffDate = new Date();
uptimeCutoffDate.setDate(uptimeCutoffDate.getDate() - settings.uptimeRetentionDays);
console.log(`Starting uptime cleanup - Cutoff: ${uptimeCutoffDate.toISOString()}`);
// Clean uptime_data collection
try {
const uptimeRecords = await pb.collection('uptime_data').getFullList({
filter: `created < "${uptimeCutoffDate.toISOString()}"`
});
console.log(`Found ${uptimeRecords.length} uptime records to delete`);
for (const record of uptimeRecords) {
await pb.collection('uptime_data').delete(record.id);
totalDeleted++;
}
if (uptimeRecords.length > 0) {
cleanedCollections.push('uptime_data');
}
} catch (error) {
console.error("Error cleaning uptime_data:", error);
}
console.log(`Uptime cleanup completed. Deleted ${totalDeleted} records`);
return {
deletedRecords: totalDeleted,
collections: cleanedCollections
};
} catch (error) {
console.error("Error during uptime cleanup:", error);
throw new Error("Failed to perform uptime data cleanup");
}
},
async manualServerCleanup(): Promise<CleanupResult> {
try {
const settings = await this.getRetentionSettings();
if (!settings) {
throw new Error("Could not load retention settings");
}
let totalDeleted = 0;
const cleanedCollections: string[] = [];
// Calculate cutoff date for server data
const serverCutoffDate = new Date();
serverCutoffDate.setDate(serverCutoffDate.getDate() - settings.serverRetentionDays);
console.log(`Starting server cleanup - Cutoff: ${serverCutoffDate.toISOString()}`);
// Clean ping_data collection
try {
const pingRecords = await pb.collection('ping_data').getFullList({
filter: `created < "${serverCutoffDate.toISOString()}"`
});
console.log(`Found ${pingRecords.length} ping records to delete`);
for (const record of pingRecords) {
await pb.collection('ping_data').delete(record.id);
totalDeleted++;
}
if (pingRecords.length > 0) {
cleanedCollections.push('ping_data');
}
} catch (error) {
console.error("Error cleaning ping_data:", error);
}
console.log(`Server cleanup completed. Deleted ${totalDeleted} records`);
return {
deletedRecords: totalDeleted,
collections: cleanedCollections
};
} catch (error) {
console.error("Error during server cleanup:", error);
throw new Error("Failed to perform server data cleanup");
}
},
async manualCleanup(): Promise<CleanupResult> {
try {
// Get current retention settings
const settings = await this.getRetentionSettings();
if (!settings) {
throw new Error("Could not load retention settings");
}
let totalDeleted = 0;
const cleanedCollections: string[] = [];
// Calculate cutoff dates
const uptimeCutoffDate = new Date();
uptimeCutoffDate.setDate(uptimeCutoffDate.getDate() - settings.uptimeRetentionDays);
const serverCutoffDate = new Date();
serverCutoffDate.setDate(serverCutoffDate.getDate() - settings.serverRetentionDays);
console.log(`Starting manual cleanup - Uptime cutoff: ${uptimeCutoffDate.toISOString()}, Server cutoff: ${serverCutoffDate.toISOString()}`);
// Clean uptime_data collection
try {
const uptimeRecords = await pb.collection('uptime_data').getFullList({
filter: `created < "${uptimeCutoffDate.toISOString()}"`
});
console.log(`Found ${uptimeRecords.length} uptime records to delete`);
for (const record of uptimeRecords) {
await pb.collection('uptime_data').delete(record.id);
totalDeleted++;
}
if (uptimeRecords.length > 0) {
cleanedCollections.push('uptime_data');
}
} catch (error) {
console.error("Error cleaning uptime_data:", error);
}
// Clean ping_data collection
try {
const pingRecords = await pb.collection('ping_data').getFullList({
filter: `created < "${serverCutoffDate.toISOString()}"`
});
console.log(`Found ${pingRecords.length} ping records to delete`);
for (const record of pingRecords) {
await pb.collection('ping_data').delete(record.id);
totalDeleted++;
}
if (pingRecords.length > 0) {
cleanedCollections.push('ping_data');
}
} catch (error) {
console.error("Error cleaning ping_data:", error);
}
// Update last cleanup timestamp
await this.updateLastCleanupTime();
console.log(`Manual cleanup completed. Deleted ${totalDeleted} records from collections: ${cleanedCollections.join(', ')}`);
return {
deletedRecords: totalDeleted,
collections: cleanedCollections
};
} catch (error) {
console.error("Error during manual cleanup:", error);
throw new Error("Failed to perform database cleanup");
}
},
async updateLastCleanupTime(): Promise<void> {
try {
const existingRecords = await pb.collection('data_settings').getFullList({
sort: '-created'
});
const data = {
last_cleanup: new Date().toISOString()
};
if (existingRecords.length > 0) {
await pb.collection('data_settings').update(existingRecords[0].id, data);
}
} catch (error) {
console.error("Error updating last cleanup time:", error);
}
},
async scheduleAutomaticCleanup(): Promise<void> {
try {
const settings = await this.getRetentionSettings();
if (!settings) return;
// Check if enough time has passed since last cleanup (run daily)
if (settings.lastCleanup) {
const lastCleanup = new Date(settings.lastCleanup);
const now = new Date();
const hoursSinceLastCleanup = (now.getTime() - lastCleanup.getTime()) / (1000 * 60 * 60);
if (hoursSinceLastCleanup < 24) {
console.log("Skipping automatic cleanup - last cleanup was less than 24 hours ago");
return;
}
}
console.log("Starting scheduled automatic cleanup");
const result = await this.manualCleanup();
console.log(`Automatic cleanup completed. Deleted ${result.deletedRecords} records`);
} catch (error) {
console.error("Error during automatic cleanup:", error);
}
}
};
@@ -13,7 +13,8 @@ export async function handleServiceUp(service: any, responseTime: number, format
// Create a history record of this check with a more accurate timestamp // Create a history record of this check with a more accurate timestamp
const uptimeData: UptimeData = { const uptimeData: UptimeData = {
serviceId: service.id, service_id: service.id, // Include service_id
serviceId: service.id, // Keep for backward compatibility
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: "up", status: "up",
responseTime: responseTime, responseTime: responseTime,
@@ -92,7 +93,8 @@ export async function handleServiceDown(service: any, formattedTime: string): Pr
// Create a history record of this check // Create a history record of this check
const uptimeData: UptimeData = { const uptimeData: UptimeData = {
serviceId: service.id, service_id: service.id, // Include service_id
serviceId: service.id, // Keep for backward compatibility
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: "down", status: "down",
responseTime: 0, responseTime: 0,
@@ -161,4 +163,4 @@ export async function handleServiceDown(service: any, formattedTime: string): Pr
} catch (error) { } catch (error) {
console.error("Error handling service DOWN state:", error); console.error("Error handling service DOWN state:", error);
} }
} }
+1 -3
View File
@@ -1,6 +1,5 @@
import { monitoringIntervals } from './monitoringIntervals'; import { monitoringIntervals } from './monitoringIntervals';
import { checkHttpService } from './httpChecker';
import { import {
startMonitoringService, startMonitoringService,
pauseMonitoring, pauseMonitoring,
@@ -12,6 +11,5 @@ export const monitoringService = {
startMonitoringService, startMonitoringService,
pauseMonitoring, pauseMonitoring,
resumeMonitoring, resumeMonitoring,
checkHttpService,
startAllActiveServices startAllActiveServices
}; };
@@ -37,6 +37,7 @@ export async function resumeMonitoring(serviceId: string): Promise<void> {
id: service.id, id: service.id,
name: service.name, name: service.name,
url: service.url || "", url: service.url || "",
host: service.host || "", // Include host property
type: service.service_type || service.type || "HTTP", type: service.service_type || service.type || "HTTP",
status: "up", status: "up",
responseTime: service.response_time || 0, responseTime: service.response_time || 0,
@@ -77,4 +78,4 @@ export async function resumeMonitoring(serviceId: string): Promise<void> {
} catch (error) { } catch (error) {
console.error("Error resuming service:", error); console.error("Error resuming service:", error);
} }
} }
@@ -1,7 +1,6 @@
import { pb } from '@/lib/pocketbase'; import { pb } from '@/lib/pocketbase';
import { monitoringIntervals } from '../monitoringIntervals'; import { monitoringIntervals } from '../monitoringIntervals';
import { checkHttpService } from '../httpChecker';
/** /**
* Start monitoring for a specific service * Start monitoring for a specific service
@@ -30,35 +29,21 @@ export async function startMonitoringService(serviceId: string): Promise<void> {
status: "up", status: "up",
}); });
// Start with an immediate check // The actual service checking is now handled by the Go microservice
await checkHttpService(serviceId); // This frontend service just tracks the monitoring state
const intervalMs = (service.heartbeat_interval || 60) * 1000;
console.log(`Service ${service.name} monitoring delegated to backend service`);
// Then schedule regular checks based on the interval // Store a placeholder interval to track that this service is being monitored
const intervalMs = (service.heartbeat_interval || 60) * 1000; // Convert from seconds to milliseconds const intervalId = window.setInterval(() => {
console.log(`Setting check interval for ${service.name} to ${intervalMs}ms (${service.heartbeat_interval || 60} seconds)`); console.log(`Monitoring active for service ${service.name} (handled by backend)`);
// Store the interval ID so we can clear it later if needed
const intervalId = window.setInterval(async () => {
try {
// Check if service has been paused since scheduling
const currentService = await pb.collection('services').getOne(serviceId);
if (currentService.status === "paused") {
console.log(`Service ${serviceId} is now paused. Skipping scheduled check.`);
return;
}
console.log(`Running scheduled check for service ${service.name}`);
await checkHttpService(serviceId);
} catch (error) {
console.error(`Error in scheduled check for ${service.name}:`, error);
}
}, intervalMs); }, intervalMs);
// Store the interval ID for this service // Store the interval ID for this service
monitoringIntervals.set(serviceId, intervalId); monitoringIntervals.set(serviceId, intervalId);
console.log(`Monitoring scheduled for service ${serviceId} every ${service.heartbeat_interval || 60} seconds`); console.log(`Monitoring registered for service ${serviceId}`);
} catch (error) { } catch (error) {
console.error("Error starting service monitoring:", error); console.error("Error starting service monitoring:", error);
} }
} }
@@ -19,6 +19,7 @@ export function prepareServiceForNotification(pbRecord: any, status: string, res
id: pbRecord.id, id: pbRecord.id,
name: pbRecord.name, name: pbRecord.name,
url: pbRecord.url, url: pbRecord.url,
host: pbRecord.host || "", // Include host property with fallback
type: pbRecord.type || pbRecord.service_type || "HTTP", type: pbRecord.type || pbRecord.service_type || "HTTP",
status: status as any, status: status as any,
responseTime: responseTime, responseTime: responseTime,
@@ -56,149 +57,4 @@ export function formatRecordId(id: string): string {
export function formatCurrentTime(): string { export function formatCurrentTime(): string {
const now = new Date(); const now = new Date();
return now.toISOString(); return now.toISOString();
} }
/**
* Make an HTTP request to a service endpoint with retry logic
* Improved to better handle different response scenarios and detection issues
*/
export async function makeHttpRequest(url: string, maxRetries: number = 3): Promise<{ isUp: boolean; responseTime: number }> {
let retries = 0;
let isUp = false;
let responseTime = 0;
let lastError = null;
const startTime = performance.now();
try {
// Ensure URL has proper protocol
const targetUrl = ensureHttpsProtocol(url);
while (retries < maxRetries && !isUp) {
try {
// Use fetch API with a timeout to prevent long-hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
console.log(`Attempt ${retries + 1}/${maxRetries} checking ${targetUrl}`);
// IMPROVED APPROACH: Try multiple detection methods in sequence
// Method 1: Try HEAD request first as it's lightweight
try {
console.log(`Trying HEAD request to ${targetUrl}`);
const headResponse = await fetch(targetUrl, {
method: 'HEAD',
signal: controller.signal,
cache: 'no-cache',
headers: {
'Accept': '*/*',
'User-Agent': 'ServiceMonitor/1.0'
}
});
// If HEAD request succeeds with any status code, service is reachable
isUp = true;
console.log(`HEAD request successful with status ${headResponse.status}`);
} catch (headError) {
console.log(`HEAD request failed: ${headError.message}`);
lastError = headError;
// Method 2: Fall back to standard GET with proper error handling
try {
console.log(`Trying standard GET request to ${targetUrl}`);
const getResponse = await fetch(targetUrl, {
method: 'GET',
signal: controller.signal,
cache: 'no-cache',
headers: {
'Accept': '*/*',
'User-Agent': 'ServiceMonitor/1.0'
}
});
// If GET returns any response, consider the service up
isUp = true;
console.log(`GET request successful with status ${getResponse.status}`);
} catch (getError) {
console.log(`Standard GET request failed: ${getError.message}`);
lastError = getError;
// Method 3: Try with no-cors mode as a last resort
try {
console.log(`Trying no-cors GET request to ${targetUrl}`);
const noCorsResponse = await fetch(targetUrl, {
method: 'GET',
mode: 'no-cors', // This allows requests to succeed even if CORS is restricted
signal: controller.signal,
cache: 'no-cache'
});
// In no-cors mode, we can't read the response, but if we get here without an exception,
// the request succeeded and the service is likely up
isUp = true;
console.log(`No-cors GET request succeeded, assuming service is UP`);
} catch (corsError) {
console.log(`No-cors GET request also failed: ${corsError.message}`);
lastError = corsError;
isUp = false;
}
// Method 4: If all fetches fail but error is CORS-related, consider service up
if (!isUp && lastError &&
(lastError.message.includes('CORS') ||
lastError.message.includes('blocked') ||
lastError.message.includes('policy'))) {
console.log("CORS error detected, but this likely means the service is running.");
console.log("Setting service as UP despite CORS restriction.");
isUp = true;
}
}
}
clearTimeout(timeoutId);
responseTime = Math.round(performance.now() - startTime);
if (isUp) {
console.log(`Service is detected as UP after ${retries + 1} attempts, response time: ${responseTime}ms`);
break;
} else {
console.log(`All connection attempts failed for attempt ${retries + 1}`);
retries++;
// Add a short delay between retries
if (retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
} catch (error) {
console.error(`HTTP request attempt ${retries + 1} failed with error:`, error);
lastError = error;
retries++;
if (retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
} catch (error) {
console.error('Critical error in makeHttpRequest:', error);
isUp = false;
responseTime = Math.round(performance.now() - startTime);
lastError = error;
}
// Final check for status
if (!isUp && lastError) {
console.log("Final connection attempt failed with error:", lastError);
// Check for specific errors that might indicate the site is actually up
if (lastError.name === 'AbortError') {
console.log("Request timed out which may indicate a slow response rather than service down");
}
}
// Log the final result for debugging
console.log(`Final service check result - URL: ${url}, isUp: ${isUp}, responseTime: ${responseTime}ms, retries: ${retries}`);
return { isUp, responseTime };
}

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