Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f88cc32cf9 | |||
| 918a248b8f | |||
| 458f5f7d82 | |||
| 5ceaa09710 | |||
| 1eabf66311 | |||
| a0c0a3ed75 | |||
| abb129bde0 | |||
| 220be3bea4 | |||
| 614c10188d | |||
| 11408dcba0 | |||
| 9f4e759b01 | |||
| d3ea5dc77c | |||
| 89572c44b7 | |||
| dfe98f9fa6 | |||
| 549a3793a5 | |||
| 9ed4d4c7ac | |||
| cca2338468 | |||
| c1c74bb2a8 | |||
| a2ae66fe76 | |||
| 907fdaa0dc | |||
| 93b58cfa2c | |||
| 879622b364 | |||
| 2f52010963 | |||
| c90f0dd963 | |||
| 4a14f64e07 | |||
| 19b4de78d1 | |||
| bdf648c2fe | |||
| 0705fc89b4 | |||
| 883a541b9b | |||
| 0758b1d24b | |||
| 9a001f6aec | |||
| 9c78def48d | |||
| 94e2c0da77 | |||
| 78674f758c | |||
| 3a6ab2258a | |||
| 1daaf067d9 | |||
| 6d1b8387ca | |||
| e3f9111484 | |||
| 575869c50a | |||
| cfdedb9636 | |||
| 08b00728c2 | |||
| 4486a79e58 | |||
| 41fcaaaf05 | |||
| ddcabac564 | |||
| 4e0a9953db | |||
| f25fa7de83 | |||
| aea70d0023 | |||
| 4e07c8c932 | |||
| 5f18fde775 | |||
| 9d5fa805c9 | |||
| e1d466a638 | |||
| 8dc3bd6a0d | |||
| 6d63d9f485 | |||
| 68a4ad5478 | |||
| 895da83999 | |||
| f6592d331f | |||
| da0808cefe | |||
| 1e77f2747e | |||
| 9b1918e25a | |||
| 99e988e316 | |||
| 419e9118f9 | |||
| e2c2705378 | |||
| 82fa9a4ac2 | |||
| 576f2fbb34 | |||
| 554faefe4a | |||
| c92e9fe442 | |||
| ede5c36ac4 | |||
| 9097a618cd | |||
| f4f6cb48db | |||
| 1264d293bf | |||
| 8608b6a263 | |||
| 86e1e70e22 | |||
| dd2c64420d | |||
| ba47c1d953 | |||
| ba6dc41d95 | |||
| 1dc25cc461 | |||
| 11ddc77e32 | |||
| 5a5772f9a4 | |||
| 039a832b79 | |||
| a88b356bf5 | |||
| 2d798e2983 | |||
| 426f41e337 | |||
| 4dc005c7e4 | |||
| 4ef84b5469 | |||
| 0739d6b0ce | |||
| bd17ef7140 | |||
| 518b469c04 | |||
| 6652ac5e76 | |||
| 4ec0c7a8fa | |||
| 0b52e7ffa9 | |||
| dd758b9334 | |||
| 9b1167955e | |||
| 611380a5e4 | |||
| 0b9bc76d37 | |||
| 0562adbef8 | |||
| d4d4040cc8 | |||
| aec7118496 | |||
| c17d902b9a | |||
| a5ad5eef42 | |||
| c7846a2fed | |||
| 4ad001b560 | |||
| d52842a8ea | |||
| 9e02eea0d1 | |||
| a1d2f1a9e0 | |||
| 230f5562c7 |
+18
-14
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
[](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
@@ -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;
|
||||||
@@ -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' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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' },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
export interface SettingsApiRequest {
|
||||||
|
action: string;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsApiResponse {
|
||||||
|
status: number;
|
||||||
|
json: {
|
||||||
|
success: boolean;
|
||||||
|
data?: any;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmtpSettings {
|
||||||
|
enabled?: boolean;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
authMethod?: string;
|
||||||
|
tls?: boolean;
|
||||||
|
localName?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
export const getAuthHeaders = (): Record<string, string> => {
|
||||||
|
const authToken = pb.authStore.token;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBaseUrl = (): string => {
|
||||||
|
return getCurrentEndpoint();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateEmail = (email: string): boolean => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-destructive mb-4">Something went wrong</h2>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
import { Mail, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { getCurrentEndpoint } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
interface ForgotPasswordDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) {
|
||||||
|
const [step, setStep] = useState<'request' | 'confirm'>('request');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleRequestReset = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = getCurrentEndpoint();
|
||||||
|
const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to send reset email');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Reset Email Sent",
|
||||||
|
description: "Please check your email for password reset instructions.",
|
||||||
|
});
|
||||||
|
setStep('confirm');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset request error:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Reset Failed",
|
||||||
|
description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmReset = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Password Mismatch",
|
||||||
|
description: "Passwords do not match. Please try again.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Password Too Short",
|
||||||
|
description: "Password must be at least 6 characters long.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = getCurrentEndpoint();
|
||||||
|
const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
passwordConfirm
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to reset password');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Password Reset Successful",
|
||||||
|
description: "Your password has been reset successfully. You can now log in with your new password.",
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset form state
|
||||||
|
setStep('request');
|
||||||
|
setEmail('');
|
||||||
|
setToken('');
|
||||||
|
setPassword('');
|
||||||
|
setPasswordConfirm('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset confirmation error:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Reset Failed",
|
||||||
|
description: error instanceof Error ? error.message : "Failed to reset password. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset form state when closing
|
||||||
|
setStep('request');
|
||||||
|
setEmail('');
|
||||||
|
setToken('');
|
||||||
|
setPassword('');
|
||||||
|
setPasswordConfirm('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 'request'
|
||||||
|
? 'Enter your email address and we\'ll send you a reset link.'
|
||||||
|
: 'Enter the reset token from your email and your new password.'
|
||||||
|
}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'request' ? (
|
||||||
|
<form onSubmit={handleRequestReset} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="reset-email"
|
||||||
|
placeholder="your.email@provider.com"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !email}
|
||||||
|
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||||
|
>
|
||||||
|
{loading ? 'Sending...' : 'Send Reset Email'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleConfirmReset} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
|
||||||
|
Reset Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="reset-token"
|
||||||
|
placeholder="Enter token from email"
|
||||||
|
type="text"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStep('request')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !token || !password || !passwordConfirm}
|
||||||
|
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||||
|
>
|
||||||
|
{loading ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+3
-2
@@ -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]);
|
||||||
};
|
};
|
||||||
+30
-44
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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 />);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,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
Reference in New Issue
Block a user