From 9d5fb72708371df27476ed120c9c36db54e5332e Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Thu, 24 Jul 2025 07:59:50 +0200 Subject: [PATCH 01/17] [IMP] Added view details (for the names) on the Server Instances and SSL Views --- .../src/components/servers/ServerTable.tsx | 20 +++++++++++-------- .../ssl-domain/SSLCertificateActions.tsx | 10 +++++----- .../ssl-domain/SSLCertificatesTable.tsx | 10 +++++++--- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/application/src/components/servers/ServerTable.tsx b/application/src/components/servers/ServerTable.tsx index 89c948f..afea63c 100644 --- a/application/src/components/servers/ServerTable.tsx +++ b/application/src/components/servers/ServerTable.tsx @@ -269,7 +269,11 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) const isProcessing = pausingServers.has(server.id); return ( - + handleViewDetails(server.id)} + >
{server.name} @@ -325,7 +329,7 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) {new Date(server.last_checked).toLocaleString()}
- + e.stopPropagation()}> - handleViewDetails(server.id)}> + { e.stopPropagation(); handleViewDetails(server.id); }}> View Server Detail {server.docker === 'true' && ( - handleViewContainers(server.id)}> + { e.stopPropagation(); handleViewContainers(server.id); }}> Container Monitoring )} handlePauseResume(server)} + onClick={(e) => { e.stopPropagation(); handlePauseResume(server); }} disabled={isProcessing} > {isPaused ? ( @@ -366,12 +370,12 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) )} - handleEdit(server)}> + { e.stopPropagation(); handleEdit(server); }}> Edit Server handleDelete(server)} + onClick={(e) => { e.stopPropagation(); handleDelete(server); }} className="text-red-600 focus:text-red-600" > @@ -427,4 +431,4 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) ); -}; \ No newline at end of file +}; diff --git a/application/src/components/ssl-domain/SSLCertificateActions.tsx b/application/src/components/ssl-domain/SSLCertificateActions.tsx index a1a2148..68ad461 100644 --- a/application/src/components/ssl-domain/SSLCertificateActions.tsx +++ b/application/src/components/ssl-domain/SSLCertificateActions.tsx @@ -45,20 +45,20 @@ export const SSLCertificateActions = ({ - onView(certificate)}> + { e.stopPropagation(); onView(certificate); }}> {t('view')} - + { e.stopPropagation(); handleCheck(); }}> Check - onEdit(certificate)}> + { e.stopPropagation(); onEdit(certificate); }}> {t('edit')} onDelete(certificate)} + onClick={(e) => { e.stopPropagation(); onDelete(certificate); }} className="text-destructive" > @@ -67,4 +67,4 @@ export const SSLCertificateActions = ({ ); -}; \ No newline at end of file +}; diff --git a/application/src/components/ssl-domain/SSLCertificatesTable.tsx b/application/src/components/ssl-domain/SSLCertificatesTable.tsx index 75df57a..dd28818 100644 --- a/application/src/components/ssl-domain/SSLCertificatesTable.tsx +++ b/application/src/components/ssl-domain/SSLCertificatesTable.tsx @@ -151,7 +151,11 @@ export const SSLCertificatesTable = () => { {certificates.map((certificate) => ( - + openViewDialog(certificate)} + > {certificate.domain} @@ -170,7 +174,7 @@ export const SSLCertificatesTable = () => { {certificate.check_interval || 1} {t('days')} - + e.stopPropagation()}> { ); -}; \ No newline at end of file +}; From e9cedef443c253ecf67881c592c96d81bed677dd Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Fri, 25 Jul 2025 16:59:49 +0700 Subject: [PATCH 02/17] Disabled console log for production --- .../CreateOperationalPageDialog.tsx | 10 +++--- .../components/public/PublicStatusPage.tsx | 4 +-- .../public/hooks/usePublicStatusPageData.ts | 36 +++++++++---------- .../services/add-service/ServiceForm.tsx | 4 +-- .../services/add-service/ServiceUrlField.tsx | 4 +-- .../service-status/pauseMonitoring.ts | 2 +- .../src/services/operationalPageService.ts | 10 +++--- .../services/statusPageComponentsService.ts | 12 +++---- 8 files changed, 41 insertions(+), 41 deletions(-) diff --git a/application/src/components/operational-page/CreateOperationalPageDialog.tsx b/application/src/components/operational-page/CreateOperationalPageDialog.tsx index 4f9f17c..4d3c321 100644 --- a/application/src/components/operational-page/CreateOperationalPageDialog.tsx +++ b/application/src/components/operational-page/CreateOperationalPageDialog.tsx @@ -67,13 +67,13 @@ export const CreateOperationalPageDialog = () => { page_style: data.page_style || '', }; - console.log('Creating operational page with payload:', payload); + // console.log('Creating operational page with payload:', payload); const createdPage = await createMutation.mutateAsync(payload); - console.log('Created page:', createdPage); + // console.log('Created page:', createdPage); // Create components after page is created if (selectedComponents.length > 0) { - console.log('Creating components for page:', createdPage.id); + // console.log('Creating components for page:', createdPage.id); for (const component of selectedComponents) { const componentPayload = { operational_status_id: createdPage.id, @@ -84,7 +84,7 @@ export const CreateOperationalPageDialog = () => { display_order: component.display_order || 1, }; - console.log('Creating component with payload:', componentPayload); + // console.log('Creating component with payload:', componentPayload); await createComponentMutation.mutateAsync(componentPayload); } } @@ -93,7 +93,7 @@ export const CreateOperationalPageDialog = () => { form.reset(); setSelectedComponents([]); } catch (error) { - console.error('Error creating operational page:', error); + // console.error('Error creating operational page:', error); } }; diff --git a/application/src/components/public/PublicStatusPage.tsx b/application/src/components/public/PublicStatusPage.tsx index 8f775f0..d6f7bc5 100644 --- a/application/src/components/public/PublicStatusPage.tsx +++ b/application/src/components/public/PublicStatusPage.tsx @@ -12,7 +12,7 @@ import { PublicStatusPageFooter } from './PublicStatusPageFooter'; export const PublicStatusPage = () => { const { slug } = useParams<{ slug: string }>(); - console.log('PublicStatusPage - slug from params:', slug); +// console.log('PublicStatusPage - slug from params:', slug); const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug); const [lastUpdated, setLastUpdated] = useState(new Date()); @@ -51,7 +51,7 @@ export const PublicStatusPage = () => { }; }, [page?.theme]); - console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length }); + // console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length }); if (loading) { return ( diff --git a/application/src/components/public/hooks/usePublicStatusPageData.ts b/application/src/components/public/hooks/usePublicStatusPageData.ts index 75983ac..a92e85b 100644 --- a/application/src/components/public/hooks/usePublicStatusPageData.ts +++ b/application/src/components/public/hooks/usePublicStatusPageData.ts @@ -19,61 +19,61 @@ export const usePublicStatusPageData = (slug: string | undefined) => { useEffect(() => { const fetchPublicPage = async () => { if (!slug) { - console.log('No slug provided'); + // console.log('No slug provided'); setError('No status page slug provided'); setLoading(false); return; } try { - console.log('Fetching public status page for slug:', slug); + // console.log('Fetching public status page for slug:', slug); setLoading(true); setError(null); // Fetch operational page - console.log('Fetching operational pages...'); + // console.log('Fetching operational pages...'); const pages = await operationalPageService.getOperationalPages(); - console.log('All pages:', pages); + // console.log('All pages:', pages); const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true'); - console.log('Found page:', foundPage); + // console.log('Found page:', foundPage); if (!foundPage) { - console.log('Page not found or not public'); + // 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'); + // console.log('Page set successfully'); // Fetch components for this page - console.log('Fetching components for page:', foundPage.id); + // console.log('Fetching components for page:', foundPage.id); const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id); - console.log('Components found:', pageComponents); + // console.log('Components found:', pageComponents); setComponents(pageComponents); // Fetch all services - console.log('Fetching all services...'); + // console.log('Fetching all services...'); const allServices = await serviceService.getServices(); - console.log('Services found:', allServices); + // console.log('Services found:', allServices); setServices(allServices); // Fetch uptime data for each component that has a service - console.log('Fetching uptime data...'); + // 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); + // 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'); + // 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); + // console.error(`Error fetching uptime for service ${component.service_id}:`, error); return { serviceId: component.service_id, history: [] }; } }); @@ -84,12 +84,12 @@ export const usePublicStatusPageData = (slug: string | undefined) => { uptimeMap[result.serviceId] = result.history; }); setUptimeData(uptimeMap); - console.log('Uptime data set successfully'); + // console.log('Uptime data set successfully'); - console.log('All data fetched successfully'); + // console.log('All data fetched successfully'); } catch (err) { - console.error('Error fetching public page:', err); + // console.error('Error fetching public page:', err); setError(`Failed to load status page: ${err}`); } finally { setLoading(false); diff --git a/application/src/components/services/add-service/ServiceForm.tsx b/application/src/components/services/add-service/ServiceForm.tsx index 28ba8e9..a888b11 100644 --- a/application/src/components/services/add-service/ServiceForm.tsx +++ b/application/src/components/services/add-service/ServiceForm.tsx @@ -70,10 +70,10 @@ export function ServiceForm({ if (onSubmitStart) onSubmitStart(); try { - console.log("Form data being submitted:", data); + // console.log("Form data being submitted:", data); const serviceData = mapFormDataToServiceData(data); - console.log("Service data being sent:", serviceData); + // console.log("Service data being sent:", serviceData); if (isEdit && initialData) { // Update existing service diff --git a/application/src/components/services/add-service/ServiceUrlField.tsx b/application/src/components/services/add-service/ServiceUrlField.tsx index 0284f35..f8419c4 100644 --- a/application/src/components/services/add-service/ServiceUrlField.tsx +++ b/application/src/components/services/add-service/ServiceUrlField.tsx @@ -67,7 +67,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) { placeholder={getPlaceholder()} {...field} onChange={(e) => { - console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value); + // console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value); field.onChange(e); }} /> @@ -93,7 +93,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) { type="number" {...field} onChange={(e) => { - console.log("Port field changed:", e.target.value); + // console.log("Port field changed:", e.target.value); field.onChange(e); }} /> diff --git a/application/src/services/monitoring/service-status/pauseMonitoring.ts b/application/src/services/monitoring/service-status/pauseMonitoring.ts index 677b09a..9fac22e 100644 --- a/application/src/services/monitoring/service-status/pauseMonitoring.ts +++ b/application/src/services/monitoring/service-status/pauseMonitoring.ts @@ -13,7 +13,7 @@ export async function pauseMonitoring(serviceId: string): Promise { if (intervalId) { clearInterval(intervalId); monitoringIntervals.delete(serviceId); - console.log(`Monitoring paused for service ${serviceId}`); + // console.log(`Monitoring paused for service ${serviceId}`); } // Get current timestamp formatted as a string diff --git a/application/src/services/operationalPageService.ts b/application/src/services/operationalPageService.ts index 28d905b..0417755 100644 --- a/application/src/services/operationalPageService.ts +++ b/application/src/services/operationalPageService.ts @@ -27,7 +27,7 @@ export const operationalPageService = { const result = await response.json(); return result.items || []; } catch (error) { - console.error('Error fetching operational pages:', error); + // console.error('Error fetching operational pages:', error); throw error; } }, @@ -55,7 +55,7 @@ export const operationalPageService = { return await response.json(); } catch (error) { - console.error('Error fetching operational page:', error); + // console.error('Error fetching operational page:', error); throw error; } }, @@ -84,7 +84,7 @@ export const operationalPageService = { return await response.json(); } catch (error) { - console.error('Error updating operational page:', error); + // console.error('Error updating operational page:', error); throw error; } }, @@ -113,7 +113,7 @@ export const operationalPageService = { return await response.json(); } catch (error) { - console.error('Error creating operational page:', error); + // console.error('Error creating operational page:', error); throw error; } }, @@ -139,7 +139,7 @@ export const operationalPageService = { throw new Error(`HTTP error! status: ${response.status}`); } } catch (error) { - console.error('Error deleting operational page:', error); + // console.error('Error deleting operational page:', error); throw error; } } diff --git a/application/src/services/statusPageComponentsService.ts b/application/src/services/statusPageComponentsService.ts index d55f334..6e91d10 100644 --- a/application/src/services/statusPageComponentsService.ts +++ b/application/src/services/statusPageComponentsService.ts @@ -27,7 +27,7 @@ export const statusPageComponentsService = { const result = await response.json(); return result.items || []; } catch (error) { - console.error('Error fetching status page components:', error); + // console.error('Error fetching status page components:', error); throw error; } }, @@ -56,7 +56,7 @@ export const statusPageComponentsService = { const result = await response.json(); return result.items || []; } catch (error) { - console.error('Error fetching status page components by operational ID:', error); + // console.error('Error fetching status page components by operational ID:', error); throw error; } }, @@ -79,7 +79,7 @@ export const statusPageComponentsService = { headers['Authorization'] = `Bearer ${authToken}`; } - console.log('Creating status page component with data:', data); + // console.log('Creating status page component with data:', data); const baseUrl = getCurrentEndpoint(); const response = await fetch(`${baseUrl}/api/collections/status_page_components/records`, { @@ -95,10 +95,10 @@ export const statusPageComponentsService = { } const result = await response.json(); - console.log('Created component:', result); + // console.log('Created component:', result); return result; } catch (error) { - console.error('Error creating status page component:', error); + // console.error('Error creating status page component:', error); throw error; } }, @@ -124,7 +124,7 @@ export const statusPageComponentsService = { throw new Error(`HTTP error! status: ${response.status}`); } } catch (error) { - console.error('Error deleting status page component:', error); + // console.error('Error deleting status page component:', error); throw error; } } From 07dc10a4228492ab935255d90e579b0e6343ca7a Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Fri, 25 Jul 2025 17:34:37 +0700 Subject: [PATCH 03/17] Remove Quick Add Template - This section provided pre-defined component types for quick addition, but is no longer needed. --- .../operational-page/ComponentsSelector.tsx | 40 +------------------ 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/application/src/components/operational-page/ComponentsSelector.tsx b/application/src/components/operational-page/ComponentsSelector.tsx index e291a9f..7f744eb 100644 --- a/application/src/components/operational-page/ComponentsSelector.tsx +++ b/application/src/components/operational-page/ComponentsSelector.tsx @@ -1,3 +1,4 @@ + import { useState } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -6,7 +7,7 @@ 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 { Plus, X, Server } from 'lucide-react'; import { StatusPageComponentRecord } from '@/types/statusPageComponents.types'; import { useQuery } from '@tanstack/react-query'; import { serviceService } from '@/services/serviceService'; @@ -17,12 +18,6 @@ interface ComponentsSelectorProps { 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({ @@ -206,37 +201,6 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC )} - -
- -
- {componentTypes.map((type) => { - const Icon = type.icon; - return ( - - ); - })} -
-
); From 8d279aa0dac5319cd3b6c08edc24c8ee3548a3f6 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Fri, 25 Jul 2025 17:37:29 +0700 Subject: [PATCH 04/17] Fix: Resolve invalid DOM nesting in OperationalPageCard - Resolved the `validateDOMNesting` warning. The `Badge` will be rendered directly within a `
` or other appropriate block-level element. --- .../src/components/operational-page/OperationalPageCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/components/operational-page/OperationalPageCard.tsx b/application/src/components/operational-page/OperationalPageCard.tsx index 8c8949d..8c11dfd 100644 --- a/application/src/components/operational-page/OperationalPageCard.tsx +++ b/application/src/components/operational-page/OperationalPageCard.tsx @@ -41,11 +41,11 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
Public: -

+

{page.is_public === 'true' ? 'Yes' : 'No'} -

+
Updated: From 86e707803eb02883ee092d57abf0ed02a5e7514b Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Fri, 25 Jul 2025 17:42:25 +0700 Subject: [PATCH 05/17] Fix: Improve dropdown theme support - Update dropdown themes by ensuring its background color adapts correctly. --- .../src/components/operational-page/ComponentsSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/components/operational-page/ComponentsSelector.tsx b/application/src/components/operational-page/ComponentsSelector.tsx index 7f744eb..9478fc7 100644 --- a/application/src/components/operational-page/ComponentsSelector.tsx +++ b/application/src/components/operational-page/ComponentsSelector.tsx @@ -164,7 +164,7 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC - + {services.map((service) => (
From fd3b95996679ef7b8722903789ce3f38433da2d5 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Sun, 27 Jul 2025 19:21:32 +0700 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=93=96=20Updated=20README=20-=20Ref?= =?UTF-8?q?actor=20the=20project=20developement=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 36 +++++++++--------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d9cbe4f..aeef6ac 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s User: admin@example.com | Passwd: Admin123456 ## ๐ŸŒŸ Core Features +### ๐Ÿ“ Roadmap : [DEVELOPMENT_ROADMAP](docs/DEVELOPMENT_ROADMAP.md) -### Uptime Services & Infrastructure Server Monitoring +### Uptime Services & Infrastructure Server Monitoring - Monitor HTTP, DNS, and Ping protocols - Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP) - Track detail uptime, response times, and performance issues @@ -118,28 +119,6 @@ services: ![Schedule Maintenance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-schedule-maintenance.png) -## ๐Ÿ“ Development Roadmap - -- โœ… Health check & uptime monitoring (HTTP) -- โœ… Dashboard UI with live stats -- โœ… Auth with Multi-users system (admin) -- โœ… Notifications (Telegram) -- โœ… Docker containerization -- โœ… CheckCle Website -- โœ… CheckCle Demo Server -- โœ… SSL & Domain Monitoring -- โœ… Schedule Maintenance -- โœ… Incident Management -- โœ… Infrastructure Server Monitoring -- โœ… Operational Status / Public Status Pages -- โœ… Uptime monitoring (HTTP, TCP, PING, DNS) Full functionality -- โœ… Distributed Regional Monitoring Agent -- โœ… System Setting Panel and Mail Settings -- โœ… User Permission Roles -- [ ] Notifications (Email/Slack/Discord/Signal) -- โœ… Data Retention & Automate Strink (Muti Options to Shrink Data & Database ) -- โœ… Open-source release with full documentation - ## ๐ŸŒŸ CheckCle for Communities? - **Built with Passion**: Created by an open-source enthusiast for the community - **Free & Open Source**: Completely free to use with no hidden costs @@ -147,6 +126,13 @@ services: --- +## ๐Ÿ‘ฅ Contributors +Thank you for contributing and continuously making CheckCle better, you're awesome ๐Ÿซถ + +[![](https://contrib.rocks/image?repo=operacle/checkcle)](https://github.com/operacle/checkcle/graphs/contributors) + +--- + ## ๐Ÿค Ways to Contribute Here are some ways you can help improve CheckCle: @@ -163,7 +149,6 @@ Here are some ways you can help improve CheckCle: - Website: [checkcle.io](https://checkcle.io) - Documentation: [docs.checkcle.io](https://docs.checkcle.io) - GitHub Repository: โญ [CheckCle](https://github.com/operacle/checkcle.git) -- Reddit Community: [reddit](https://www.reddit.com/r/checkcle) - Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX) - X: [@tlengoss](https://x.com/tlengoss) @@ -172,8 +157,5 @@ Here are some ways you can help improve CheckCle: CheckCle is released under the MIT License. --- -## ๐Ÿ‘ฅ Contributors -Thank you for contributing and continuously making CheckCle better, you're awesome ๐Ÿซถ -[![](https://contrib.rocks/image?repo=operacle/checkcle)](https://github.com/operacle/checkcle/graphs/contributors) From c329425ac106fba27af5d59affce54c5eedbb296 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Sun, 27 Jul 2025 19:22:03 +0700 Subject: [PATCH 07/17] Refactor the project developement roadmap --- docs/DEVELOPMENT_ROADMAP.md | 118 ++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/DEVELOPMENT_ROADMAP.md diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/DEVELOPMENT_ROADMAP.md new file mode 100644 index 0000000..abf3a9e --- /dev/null +++ b/docs/DEVELOPMENT_ROADMAP.md @@ -0,0 +1,118 @@ +# ๐Ÿ“ Development Roadmap + +Welcome to the official development roadmap for this project. This document outlines our plans, priorities, and direction for upcoming releases. It is intended to keep contributors, users, and stakeholders informed about the projectโ€™s evolution. + +> ๐Ÿ”„ **Note**: This roadmap is subject to change based on feedback, urgency, or shifts in priorities. + +--- + +## โœ… Goals and Vision + +Our mission is to deliver a robust, scalable, and user-friendly solution for "uptime monitoring and server infrastructure insights". + +**Core objectives:** +- Provide high performance with low resource usage +- Ensure scalability and modularity +- Focus on excellent user experience (UX) +- Maintain open-source transparency and community-driven features + +--- + +## ๐Ÿงฉ Key Main Features + +#### Delivery Status: +- โœ… Setup authentication and user management +- โœ… Core monitoring dashboard +- โœ… Agent communication with backend +- โœ… Docker containerization +- โœ… CheckCle Website +- โœ… CheckCle Demo Server +- โœ… SSL & Domain Monitoring +- โœ… Schedule Maintenance +- โœ… Incident Management +- โœ… Infrastructure Server Monitoring +- โœ… Operational Status / Public Status Pages +- โœ… Uptime Monitoring (HTTP, TCP, PING, DNS) +- โœ… Distributed Regional Monitoring Agent +- โœ… System Setting Panel and Mail Settings +- โœ… Data Retention & Automate Shrink +- โœ… Open-source release with documentation + +--- +## ๐Ÿšฆ Roadmap Stages + +The roadmap is divided into the following stages: + +| Stage | Description | +|-------|-------------| +| ๐ŸŽฏ Planned | Approved features or improvements that are on the schedule | +| ๐Ÿ”ง In Progress | Actively being developed | +| โœ… Completed | Fully implemented and tested | +| โณ Backlog | On hold or pending prioritization | +| ๐Ÿงช Experimental | Testing new concepts or prototypes | + +--- + +## ๐Ÿ—‚ Milestone Overview + +### ๐Ÿ“ฆ v1.0.0-1.3.0 โ€“ Initial Release _(Target: Q2 2025)_ +**Status:** โœ… Completed +**Goal:** MVP feature completion, API stability, and core use-case readiness. + +#### Key Deliverables: https://github.com/operacle/checkcle/releases + +--- + +### ๐Ÿš€ v1.4 - 1.5 โ€“ Feature Enhancements _(Target: Q3 2025)_ +**Status:** ๐Ÿ”ง In Progress +#### Tentative Features: +- [ ] โœ… Server and Service Table row clickable to detail page. +- [ ] ๐Ÿ”ง Server Agent (RPM, Docker container, and general binary package) +- [ ] ๐Ÿ”ง Notification System (Webhook, Telegram, Discord, Slack, Email, Google Chat) +- [ ] ๐Ÿ”ง Improve Uptime Service and Server connection update based on status and notification. +- [ ] ๐Ÿ”ง Improve SSL perform the initial check automatically after creation +- [ ] ๐Ÿ”ง Rate limiting and abuse protection +- [ ] ๐ŸŽฏ Improve the Operational status page +- [ ] ๐ŸŽฏ Improve the Schedule and Incident for automation +- [ ] ๐ŸŽฏ Server Windows Agent +- [ ] ๐ŸŽฏ More Uptime Service Type (HTTP keyword, HTTP json) +- [ ] ๐ŸŽฏ Server support with Multiple Network Interfaces +- [ ] ๐ŸŽฏ Server support with cpu temperature +- [ ] ๐ŸŽฏ Server upport with multiple disks +- [ ] ๐ŸŽฏ Add 2FA support +- [ ] ๐ŸŽฏ Rate limiting and abuse protection + +--- + +## ๐Ÿง  Ideas / Community Wishlist + +These are community-suggested or experimental features under review: + +- [ ] Grouping uptime services +- [ ] OAuth2 integration +- [ ] Mobile PWA support +- [ ] Cloud-native Helm/Kubernetes deployment + +Youโ€™re welcome to propose features via [GitHub Discussions](https://github.com/operacle/checkcle/discussions) or open an issue with the `feature-request` template. + +--- + +## ๐Ÿ“Œ How to Contribute + +We encourage contributions from the community! +To get involved: + +- Review [CONTRIBUTING.md](../CONTRIBUTING.md) | https://docs.checkcle.io +- Check out the [Open Issues](https://github.com/operacle/checkcle/issues) +- Join us in shaping the roadmap via [Discussions](https://github.com/operacle/checkcle/discussions) + +--- + +## ๐Ÿ“… Last Updated +_This roadmap was last updated on **July 26, 2025**._ + +--- + +With โค๏ธ from the CheckCle +[Website](https://checkcle.io) | [GitHub](https://github.com/operacle/checkcle) + From e2f41fdef1d69d6f6e954386af00717ece1bbdb5 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Sun, 27 Jul 2025 20:07:59 +0700 Subject: [PATCH 08/17] feat: Add pagination to SSL table - Implement pagination for the SSL dashboard table, includes adding a dropdown for selecting the number of records per page (10, 30, 50). --- .../ssl-domain/SSLCertificatesTable.tsx | 128 +++++++++-------- .../ssl-domain/SSLDomainContent.tsx | 20 +-- .../components/ssl-domain/SSLPagination.tsx | 131 ++++++++++++++++++ application/src/hooks/useSSLPagination.ts | 52 +++++++ 4 files changed, 267 insertions(+), 64 deletions(-) create mode 100644 application/src/components/ssl-domain/SSLPagination.tsx create mode 100644 application/src/hooks/useSSLPagination.ts diff --git a/application/src/components/ssl-domain/SSLCertificatesTable.tsx b/application/src/components/ssl-domain/SSLCertificatesTable.tsx index dd28818..a1e5008 100644 --- a/application/src/components/ssl-domain/SSLCertificatesTable.tsx +++ b/application/src/components/ssl-domain/SSLCertificatesTable.tsx @@ -33,11 +33,13 @@ import { AddSSLCertificateForm } from "./AddSSLCertificateForm"; import { EditSSLCertificateForm } from "./EditSSLCertificateForm"; import { SSLCertificateActions } from "./SSLCertificateActions"; import { SSLCertificateDetailDialog } from "./SSLCertificateDetailDialog"; +import { SSLPagination } from "./SSLPagination"; import { fetchSSLCertificates, addSSLCertificate, deleteSSLCertificate } from "@/services/sslCertificateService"; import { pb } from "@/lib/pocketbase"; import { SSLCertificate } from "@/types/ssl.types"; import { useLanguage } from "@/contexts/LanguageContext"; import { useTheme } from "@/contexts/ThemeContext"; +import { useSSLPagination } from "@/hooks/useSSLPagination"; import { toast } from "sonner"; export const SSLCertificatesTable = () => { @@ -56,6 +58,16 @@ export const SSLCertificatesTable = () => { queryFn: fetchSSLCertificates, }); + const { + paginatedCertificates, + currentPage, + totalPages, + pageSize, + totalItems, + handlePageChange, + handlePageSizeChange, + } = useSSLPagination({ certificates }); + if (isLoading) return
Loading...
; if (isError) return
Error loading certificates
; @@ -67,7 +79,6 @@ export const SSLCertificatesTable = () => { setShowAddDialog(false); toast.success(t('certificateAdded')); } catch (error) { - // console.error("Error adding certificate:", error); toast.error(t('failedToAddCertificate')); } finally { setIsSubmitting(false); @@ -89,7 +100,6 @@ export const SSLCertificatesTable = () => { setSelectedCertificate(null); toast.success(t('certificateUpdated')); } catch (error) { - // console.error("Error updating certificate:", error); toast.error(t('failedToUpdateCertificate')); } finally { setIsSubmitting(false); @@ -107,7 +117,6 @@ export const SSLCertificatesTable = () => { setSelectedCertificate(null); toast.success(t('certificateDeleted')); } catch (error) { - // console.error("Error deleting certificate:", error); toast.error(t('failedToDeleteCertificate')); } finally { setIsSubmitting(false); @@ -136,57 +145,68 @@ export const SSLCertificatesTable = () => { {t('noCertificatesFound')}
) : ( -
- - - - {t('domain')} - {t('status')} - {t('issuer')} - {t('validUntil')} - {t('daysLeft')} - Check Interval - Actions - - - - {certificates.map((certificate) => ( - openViewDialog(certificate)} - > - - {certificate.domain} - - - - - {certificate.issuer_o || certificate.issuer_cn || 'Unknown'} - - {certificate.valid_till ? new Date(certificate.valid_till).toLocaleDateString() : 'N/A'} - - - - {certificate.days_left} {t('days')} - - - - {certificate.check_interval || 1} {t('days')} - - e.stopPropagation()}> - - + <> +
+
+ + + {t('domain')} + {t('status')} + {t('issuer')} + {t('validUntil')} + {t('daysLeft')} + Check Interval + Actions - ))} - -
-
+ + + {paginatedCertificates.map((certificate) => ( + openViewDialog(certificate)} + > + + {certificate.domain} + + + + + {certificate.issuer_o || certificate.issuer_cn || 'Unknown'} + + {certificate.valid_till ? new Date(certificate.valid_till).toLocaleDateString() : 'N/A'} + + + + {certificate.days_left} {t('days')} + + + + {certificate.check_interval || 1} {t('days')} + + e.stopPropagation()}> + + + + ))} + + +
+ + + )} {/* View Certificate Dialog */} @@ -256,4 +276,4 @@ export const SSLCertificatesTable = () => { ); -}; +}; \ No newline at end of file diff --git a/application/src/components/ssl-domain/SSLDomainContent.tsx b/application/src/components/ssl-domain/SSLDomainContent.tsx index bb38980..6fe1b60 100644 --- a/application/src/components/ssl-domain/SSLDomainContent.tsx +++ b/application/src/components/ssl-domain/SSLDomainContent.tsx @@ -30,12 +30,12 @@ export const SSLDomainContent = () => { queryKey: ['ssl-certificates'], queryFn: async () => { try { - console.log("Fetching SSL certificates from SSLDomainContent..."); + // console.log("Fetching SSL certificates from SSLDomainContent..."); const result = await fetchSSLCertificates(); - console.log("Received SSL certificates:", result); + // console.log("Received SSL certificates:", result); return result; } catch (error) { - console.error("Error fetching certificates:", error); + // console.error("Error fetching certificates:", error); toast.error(t('failedToLoadCertificates')); throw error; } @@ -61,7 +61,7 @@ export const SSLDomainContent = () => { // Edit certificate mutation - Updated to ensure thresholds are properly updated const editMutation = useMutation({ mutationFn: async (certificate: SSLCertificate) => { - console.log("Updating certificate with data:", certificate); + // console.log("Updating certificate with data:", certificate); // Create the update data object const updateData = { @@ -70,12 +70,12 @@ export const SSLDomainContent = () => { notification_channel: certificate.notification_channel, }; - console.log("Update data to be sent:", updateData); + // console.log("Update data to be sent:", updateData); // Update certificate in the database using PocketBase directly const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData); - console.log("PocketBase update response:", updated); + // console.log("PocketBase update response:", updated); // After updating the settings, refresh the certificate to ensure it's up to date // This will also check if notification needs to be sent based on updated thresholds @@ -90,7 +90,7 @@ export const SSLDomainContent = () => { toast.success(t('sslCertificateUpdated')); }, onError: (error) => { - console.error("Error updating SSL certificate:", error); + // console.error("Error updating SSL certificate:", error); toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate')); } }); @@ -103,7 +103,7 @@ export const SSLDomainContent = () => { toast.success(t('sslCertificateDeleted')); }, onError: (error) => { - console.error("Error deleting SSL certificate:", error); + // console.error("Error deleting SSL certificate:", error); toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate')); } }); @@ -117,7 +117,7 @@ export const SSLDomainContent = () => { // Removed individual success toast notification }, onError: (error) => { - console.error("Error refreshing SSL certificate:", error); + // console.error("Error refreshing SSL certificate:", error); setRefreshingId(null); // Still refresh the data to show any partial information that was saved @@ -142,7 +142,7 @@ export const SSLDomainContent = () => { } }, onError: (error) => { - console.error("Error refreshing all certificates:", error); + // console.error("Error refreshing all certificates:", error); toast.error(t('failedToCheckCertificate')); setIsRefreshingAll(false); diff --git a/application/src/components/ssl-domain/SSLPagination.tsx b/application/src/components/ssl-domain/SSLPagination.tsx new file mode 100644 index 0000000..2ee6721 --- /dev/null +++ b/application/src/components/ssl-domain/SSLPagination.tsx @@ -0,0 +1,131 @@ + +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious +} from "@/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { SSLPageSize } from "@/hooks/useSSLPagination"; +import { useLanguage } from "@/contexts/LanguageContext"; + +interface SSLPaginationProps { + currentPage: number; + totalPages: number; + pageSize: SSLPageSize; + totalItems: number; + onPageChange: (page: number) => void; + onPageSizeChange: (size: SSLPageSize) => void; +} + +export function SSLPagination({ + currentPage, + totalPages, + pageSize, + totalItems, + onPageChange, + onPageSizeChange, +}: SSLPaginationProps) { + const { t } = useLanguage(); + + // Generate page numbers to display + const getPageNumbers = () => { + const pages = []; + const maxVisiblePages = 5; + const halfVisible = Math.floor(maxVisiblePages / 2); + + let startPage = Math.max(1, currentPage - halfVisible); + let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); + + // Adjust start page if we don't have enough pages at the end + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(1, endPage - maxVisiblePages + 1); + } + + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + return pages; + }; + + const startItem = Math.min((currentPage - 1) * pageSize + 1, totalItems); + const endItem = Math.min(currentPage * pageSize, totalItems); + + return ( +
+
+ + {t("rowsPerPage") || "Rows per page"}: + + + + {totalItems > 0 + ? `${startItem}-${endItem} of ${totalItems} ${t("certificates") || "certificates"}` + : `0 ${t("certificates") || "certificates"}` + } + +
+ + {totalPages > 1 && ( + + + + onPageChange(Math.max(1, currentPage - 1))} + className={ + currentPage === 1 + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + {getPageNumbers().map((page) => ( + + onPageChange(page)} + className="cursor-pointer" + > + {page} + + + ))} + + + onPageChange(Math.min(totalPages, currentPage + 1))} + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + + )} +
+ ); +} \ No newline at end of file diff --git a/application/src/hooks/useSSLPagination.ts b/application/src/hooks/useSSLPagination.ts new file mode 100644 index 0000000..25bc9e0 --- /dev/null +++ b/application/src/hooks/useSSLPagination.ts @@ -0,0 +1,52 @@ + +import { useState, useMemo } from 'react'; +import { SSLCertificate } from '@/types/ssl.types'; + +export type SSLPageSize = 10 | 30 | 50; + +interface UseSSLPaginationProps { + certificates: SSLCertificate[]; + initialPageSize?: SSLPageSize; +} + +export const useSSLPagination = ({ + certificates, + initialPageSize = 10 +}: UseSSLPaginationProps) => { + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(initialPageSize); + + const { paginatedCertificates, totalPages } = useMemo(() => { + const totalItems = certificates.length; + const pages = Math.ceil(totalItems / pageSize); + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + + return { + paginatedCertificates: certificates.slice(startIndex, endIndex), + totalPages: Math.max(1, pages) + }; + }, [certificates, currentPage, pageSize]); + + // Reset to first page when page size changes or certificates change + const handlePageSizeChange = (newPageSize: SSLPageSize) => { + setPageSize(newPageSize); + setCurrentPage(1); + }; + + // Reset to first page if current page exceeds total pages + const handlePageChange = (page: number) => { + const validPage = Math.min(Math.max(1, page), totalPages); + setCurrentPage(validPage); + }; + + return { + paginatedCertificates, + currentPage, + totalPages, + pageSize, + totalItems: certificates.length, + handlePageChange, + handlePageSizeChange, + }; +}; \ No newline at end of file From be57688a5ef0acfa1116828006545c770c089aaf Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Mon, 28 Jul 2025 01:27:29 +0700 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=93=96=20Update=20X=20(twitter)=20A?= =?UTF-8?q?ccount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/index.html | 4 ++-- application/src/components/dashboard/Header.tsx | 2 +- .../src/components/settings/about-system/AboutSystem.tsx | 2 +- docs/DEVELOPMENT_ROADMAP.md | 1 + docs/README_ja.md | 2 +- docs/README_km.md | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/application/index.html b/application/index.html index 200a3e1..bf65603 100644 --- a/application/index.html +++ b/application/index.html @@ -4,7 +4,7 @@ - CheckCle is an open-source monitoring stack + CheckCle @@ -14,7 +14,7 @@ - + diff --git a/application/src/components/dashboard/Header.tsx b/application/src/components/dashboard/Header.tsx index 143789a..c937d2e 100644 --- a/application/src/components/dashboard/Header.tsx +++ b/application/src/components/dashboard/Header.tsx @@ -162,7 +162,7 @@ export const Header = ({ variant="outline" size="icon" className="rounded-full w-8 h-8 border-border" - onClick={() => window.open("https://x.com/tlengoss", "_blank")} + onClick={() => window.open("https://x.com/checkcle_oss", "_blank")} > X (Twitter) diff --git a/application/src/components/settings/about-system/AboutSystem.tsx b/application/src/components/settings/about-system/AboutSystem.tsx index b06e7d8..677d2dd 100644 --- a/application/src/components/settings/about-system/AboutSystem.tsx +++ b/application/src/components/settings/about-system/AboutSystem.tsx @@ -95,7 +95,7 @@ export const AboutSystem: React.FC = () => { {t('viewDocumentation')} - diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/DEVELOPMENT_ROADMAP.md index abf3a9e..5afac95 100644 --- a/docs/DEVELOPMENT_ROADMAP.md +++ b/docs/DEVELOPMENT_ROADMAP.md @@ -67,6 +67,7 @@ The roadmap is divided into the following stages: **Status:** ๐Ÿ”ง In Progress #### Tentative Features: - [ ] โœ… Server and Service Table row clickable to detail page. +- [ ] โœ… Implement pagination for the SSL dashboard table - [ ] ๐Ÿ”ง Server Agent (RPM, Docker container, and general binary package) - [ ] ๐Ÿ”ง Notification System (Webhook, Telegram, Discord, Slack, Email, Google Chat) - [ ] ๐Ÿ”ง Improve Uptime Service and Server connection update based on status and notification. diff --git a/docs/README_ja.md b/docs/README_ja.md index f2c6938..417a3d9 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -162,7 +162,7 @@ CheckCleใฎๆ”นๅ–„ใซใ”ๅ”ๅŠ›ใ„ใŸใ ใ‘ใ‚‹ๆ–นๆณ•ใ‚’ใ”็ดนไป‹ใ—ใพใ™๏ผš - GitHubใƒชใƒใ‚ธใƒˆใƒช: โญ [CheckCle](https://github.com/operacle/checkcle.git) - ใ‚ณใƒŸใƒฅใƒ‹ใƒ†ใ‚ฃใƒใƒฃใƒณใƒใƒซ: ใƒ‡ใ‚ฃใ‚นใ‚ซใƒƒใ‚ทใƒงใƒณใ‚„issuesใงๅ‚ๅŠ ใ—ใฆใใ ใ•ใ„๏ผ - Discord: ใ‚ณใƒŸใƒฅใƒ‹ใƒ†ใ‚ฃใซๅ‚ๅŠ  [@discord](https://discord.gg/xs9gbubGwX) -- X: [@tlengoss](https://x.com/tlengoss) +- X: [@checkcle_oss](https://x.com/checkcle_oss) ## ๐Ÿ“œ ใƒฉใ‚คใ‚ปใƒณใ‚น diff --git a/docs/README_km.md b/docs/README_km.md index f605d66..43e10c2 100644 --- a/docs/README_km.md +++ b/docs/README_km.md @@ -163,7 +163,7 @@ services: - แžƒแŸ’แž›แžถแŸ†แž„แž•แŸ’แž‘แžปแž€แž€แžผแžŠ: โญ [CheckCle](https://github.com/operacle/checkcle.git) - แž‘แŸ†แž“แžถแž€แŸ‹แž‘แŸ†แž“แž„แž€แŸ’แžšแžปแž˜: Engage via discussions and issues! - แž”แžŽแŸ’แžแžถแž‰แžŸแž„แŸ’แž‚แž˜: Join our community [@discord](https://discord.gg/xs9gbubGwX) -- X: [@tlengoss](https://x.com/tlengoss) +- X: [@checkcle_oss](https://x.com/checkcle_oss) ## ๐Ÿ“œ License From cad092bc6bd25dc13f67197a45cbb7a9530aeeb8 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Mon, 28 Jul 2025 02:12:49 +0700 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=93=96=20Add=20sections=20for=20Pro?= =?UTF-8?q?ject=20Status=20and=20Sponsorship=20to=20the=20README.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 57 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index aeef6ac..5640b43 100644 --- a/README.md +++ b/README.md @@ -64,26 +64,9 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s * โœ… 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. +### Install CheckCle using one of the following methods: - -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 -docker run -d \ - --name checkcle \ - --restart unless-stopped \ - -p 8090:8090 \ - -v /opt/pb_data:/mnt/pb_data \ - --ulimit nofile=4096:8192 \ - operacle/checkcle:latest - -``` -3. Install with Docker compose Configuration. +1. Install with Docker Compose Configuration (Recommended) ```bash version: '3.9' @@ -103,6 +86,18 @@ services: hard: 8192 ``` +2. Install with docker run. Just copy ready docker run command below +```bash +docker run -d \ + --name checkcle \ + --restart unless-stopped \ + -p 8090:8090 \ + -v /opt/pb_data:/mnt/pb_data \ + --ulimit nofile=4096:8192 \ + operacle/checkcle:latest + +``` + 3. Admin Web Management Default URL: http://0.0.0.0:8090 @@ -126,6 +121,23 @@ services: --- +## Sponsors +๐Ÿ™ We're incredibly thankful to our sponsors. Your contributions enable us to maintain infrastructure (hosting, domains), run tests, and continue developing valuable features. + +### ๐Ÿฅˆ Silver Uptime Ally + + + + + +### ๐Ÿงก Ping Supporter + + + + + +--- + ## ๐Ÿ‘ฅ Contributors Thank you for contributing and continuously making CheckCle better, you're awesome ๐Ÿซถ @@ -141,16 +153,15 @@ Here are some ways you can help improve CheckCle: - ๐ŸŒŸ **Suggest Features** โ€“ Have an idea? Start a [Discussion](https://github.com/operacle/checkcle/discussions) or open a Feature Request issue. - ๐Ÿ›  **Submit Pull Requests** โ€“ Improve the code, fix bugs, add features, or enhance the docs. - ๐Ÿ“ **Improve Documentation** โ€“ Even a typo fix helps! -- ๐ŸŒ **Spread the Word** โ€“ Star โญ the repo, share it on socials, and invite others to contribute! +- ๐ŸŒ **Spread the Word** โ€“ Star โญ the [CheckCle](https://github.com/operacle/checkcle.git) repo, share it on socials, and invite others to contribute! --- ## ๐ŸŒ Stay Connected - Website: [checkcle.io](https://checkcle.io) - Documentation: [docs.checkcle.io](https://docs.checkcle.io) -- GitHub Repository: โญ [CheckCle](https://github.com/operacle/checkcle.git) -- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX) -- X: [@tlengoss](https://x.com/tlengoss) +- Chat on Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX) +- Follow us on X: [@checkcle_oss](https://x.com/checkcle_oss) ## ๐Ÿ“œ License From ff4938533cb00740e5a84a97e031850e62118bf1 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Tue, 29 Jul 2025 20:54:20 +0700 Subject: [PATCH 11/17] Fix: Add tar.gz fallback for unsupported systems - The script has been updated to include a fallback mechanism for systems that do not have `dpkg` or `rpm` package managers. This ensures that the agent can be installed on a wider range of Linux distributions, such as Arch Linux, by automatically using the `tar.gz` package when native package managers are not detected. --- scripts/server-agent.sh | 367 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 330 insertions(+), 37 deletions(-) diff --git a/scripts/server-agent.sh b/scripts/server-agent.sh index e73db52..5f5d6f5 100644 --- a/scripts/server-agent.sh +++ b/scripts/server-agent.sh @@ -1,4 +1,3 @@ - #!/bin/bash # CheckCle Server Monitoring Agent - One-Click Installation Script @@ -44,7 +43,7 @@ check_root() { fi } -# Detect system architecture and package format +# Detect system architecture and package format with fallback detect_system() { log_info "Detecting system architecture and package format..." @@ -64,28 +63,89 @@ detect_system() { ;; esac - # Detect package format preference - if command -v dpkg >/dev/null 2>&1; then - PACKAGE_FORMAT="deb" - PACKAGE_MANAGER="dpkg" - elif command -v rpm >/dev/null 2>&1; then - PACKAGE_FORMAT="rpm" - if command -v yum >/dev/null 2>&1; then - PACKAGE_MANAGER="yum" - elif command -v dnf >/dev/null 2>&1; then - PACKAGE_MANAGER="dnf" - else - PACKAGE_MANAGER="rpm" - fi - else - log_error "No supported package manager found (dpkg or rpm required)" - log_info "This script supports Debian/Ubuntu (.deb) and RHEL/CentOS/Fedora (.rpm) systems" - exit 1 + # Detect OS and preferred package format + PREFERRED_FORMAT="" + PREFERRED_MANAGER="" + + if [[ -f /etc/os-release ]]; then + OS_ID=$(grep "^ID=" /etc/os-release | cut -d'=' -f2 | tr -d '"') + OS_LIKE=$(grep "^ID_LIKE=" /etc/os-release 2>/dev/null | cut -d'=' -f2 | tr -d '"' || echo "") + + log_info "Detected OS: $OS_ID" + [[ -n "$OS_LIKE" ]] && log_info "OS family: $OS_LIKE" + + # Determine preferred package format based on OS + case "$OS_ID" in + debian|ubuntu|linuxmint|elementary|pop) + PREFERRED_FORMAT="deb" + PREFERRED_MANAGER="dpkg" + ;; + rhel|centos|fedora|rocky|almalinux|oracle|amazonlinux) + PREFERRED_FORMAT="rpm" + if command -v dnf >/dev/null 2>&1; then + PREFERRED_MANAGER="dnf" + elif command -v yum >/dev/null 2>&1; then + PREFERRED_MANAGER="yum" + else + PREFERRED_MANAGER="rpm" + fi + ;; + arch|manjaro|artix|endeavouros) + # Arch-based systems - use tar.gz fallback + log_info "Arch-based system detected, using tar.gz format" + PREFERRED_FORMAT="tar.gz" + PREFERRED_MANAGER="tar" + ;; + *) + # Check OS_LIKE for family detection + if [[ "$OS_LIKE" == *"debian"* ]] || [[ "$OS_LIKE" == *"ubuntu"* ]]; then + PREFERRED_FORMAT="deb" + PREFERRED_MANAGER="dpkg" + elif [[ "$OS_LIKE" == *"rhel"* ]] || [[ "$OS_LIKE" == *"fedora"* ]]; then + PREFERRED_FORMAT="rpm" + if command -v dnf >/dev/null 2>&1; then + PREFERRED_MANAGER="dnf" + elif command -v yum >/dev/null 2>&1; then + PREFERRED_MANAGER="yum" + else + PREFERRED_MANAGER="rpm" + fi + elif [[ "$OS_LIKE" == *"arch"* ]]; then + PREFERRED_FORMAT="tar.gz" + PREFERRED_MANAGER="tar" + fi + ;; + esac fi - # Construct package filename and URL - PACKAGE_FILENAME="monitoring-agent_1.0.0_${PACKAGE_ARCH}.${PACKAGE_FORMAT}" - PACKAGE_URL="${GITHUB_BASE_URL}/${PACKAGE_FILENAME}" + # If no preferred format detected, check available package managers + if [[ -z "$PREFERRED_FORMAT" ]]; then + if command -v dpkg >/dev/null 2>&1; then + PREFERRED_FORMAT="deb" + PREFERRED_MANAGER="dpkg" + elif command -v rpm >/dev/null 2>&1; then + PREFERRED_FORMAT="rpm" + if command -v dnf >/dev/null 2>&1; then + PREFERRED_MANAGER="dnf" + elif command -v yum >/dev/null 2>&1; then + PREFERRED_MANAGER="yum" + else + PREFERRED_MANAGER="rpm" + fi + else + # Fallback to tar.gz for unsupported systems + log_info "No native package manager found, using tar.gz fallback" + PREFERRED_FORMAT="tar.gz" + PREFERRED_MANAGER="tar" + fi + fi + + # Set initial package format and manager + PACKAGE_FORMAT="$PREFERRED_FORMAT" + PACKAGE_MANAGER="$PREFERRED_MANAGER" + + # Construct initial package filename and URL + construct_package_url log_success "System detection complete:" log_info " Architecture: $ARCH -> $PACKAGE_ARCH" @@ -94,6 +154,16 @@ detect_system() { log_info " Package URL: $PACKAGE_URL" } +# Construct package URL based on current format +construct_package_url() { + if [[ "$PACKAGE_FORMAT" == "tar.gz" ]]; then + PACKAGE_FILENAME="monitoring-agent_1.0.0_${PACKAGE_ARCH}.tar.gz" + else + PACKAGE_FILENAME="monitoring-agent_1.0.0_${PACKAGE_ARCH}.${PACKAGE_FORMAT}" + fi + PACKAGE_URL="${GITHUB_BASE_URL}/${PACKAGE_FILENAME}" +} + # Validate required environment variables - check both current and sudo environments validate_environment() { # Check if SERVER_TOKEN is available in current environment or passed as argument @@ -116,7 +186,7 @@ validate_environment() { [[ -n "$AGENT_ID" ]] && log_info "AGENT_ID: $AGENT_ID" } -# Download package based on detected system +# Download package with fallback to tar.gz download_package() { local temp_dir="/tmp/monitoring-agent-install" mkdir -p "$temp_dir" @@ -124,17 +194,211 @@ download_package() { log_info "Downloading monitoring agent package..." log_info "URL: $PACKAGE_URL" - if curl -L -f -o "$temp_dir/$PACKAGE_FILENAME" "$PACKAGE_URL"; then - DOWNLOADED_PACKAGE="$temp_dir/$PACKAGE_FILENAME" - log_success "Package downloaded successfully: $PACKAGE_FILENAME" + # Try to download the preferred package format first + if curl -L -f -s -o "$temp_dir/$PACKAGE_FILENAME" "$PACKAGE_URL" 2>/dev/null; then + # Verify the file was actually downloaded and has content + if [[ -s "$temp_dir/$PACKAGE_FILENAME" ]]; then + DOWNLOADED_PACKAGE="$temp_dir/$PACKAGE_FILENAME" + log_success "Package downloaded successfully: $PACKAGE_FILENAME" + return 0 + else + log_warning "Downloaded file is empty, trying fallback..." + rm -f "$temp_dir/$PACKAGE_FILENAME" + fi else - log_error "Failed to download package from: $PACKAGE_URL" - log_info "Please check:" - log_info " 1. Internet connectivity" - log_info " 2. Package availability for your architecture ($PACKAGE_ARCH)" - log_info " 3. GitHub repository access" + log_warning "Failed to download preferred package format: $PACKAGE_FORMAT" + fi + + # If preferred format failed and it's not tar.gz, try tar.gz fallback + if [[ "$PACKAGE_FORMAT" != "tar.gz" ]]; then + log_warning "Native package ($PACKAGE_FORMAT) not available, trying tar.gz fallback..." + + # Switch to tar.gz format + PACKAGE_FORMAT="tar.gz" + PACKAGE_MANAGER="tar" + construct_package_url + + log_info "Fallback URL: $PACKAGE_URL" + + if curl -L -f -s -o "$temp_dir/$PACKAGE_FILENAME" "$PACKAGE_URL" 2>/dev/null; then + # Verify the file was actually downloaded and has content + if [[ -s "$temp_dir/$PACKAGE_FILENAME" ]]; then + DOWNLOADED_PACKAGE="$temp_dir/$PACKAGE_FILENAME" + log_success "Fallback package downloaded successfully: $PACKAGE_FILENAME" + return 0 + else + log_error "Downloaded fallback file is empty" + rm -f "$temp_dir/$PACKAGE_FILENAME" + fi + else + log_error "Failed to download tar.gz fallback" + fi + fi + + # Both attempts failed + log_error "Failed to download package from: $PACKAGE_URL" + log_info "Please check:" + log_info " 1. Internet connectivity" + log_info " 2. Package availability for your architecture ($PACKAGE_ARCH)" + log_info " 3. GitHub repository access" + exit 1 +} + +# Install tar.gz package +install_tar_package() { + log_info "Installing monitoring agent from tar.gz package..." + + local temp_extract_dir="/tmp/monitoring-agent-extract" + mkdir -p "$temp_extract_dir" + + # Extract tar.gz package + if tar -xzf "$DOWNLOADED_PACKAGE" -C "$temp_extract_dir"; then + log_success "Package extracted successfully" + else + log_error "Failed to extract tar.gz package" exit 1 fi + + # Install files to their proper locations + log_info "Installing files to system directories..." + + # Create necessary directories + mkdir -p /usr/bin + mkdir -p /etc/monitoring-agent + mkdir -p /var/lib/monitoring-agent + mkdir -p /var/log/monitoring-agent + + # Detect systemd service directory based on OS + if [[ -d /lib/systemd/system ]]; then + SYSTEMD_DIR="/lib/systemd/system" + elif [[ -d /usr/lib/systemd/system ]]; then + SYSTEMD_DIR="/usr/lib/systemd/system" + else + SYSTEMD_DIR="/etc/systemd/system" + fi + mkdir -p "$SYSTEMD_DIR" + + # Copy files from extracted package + if [[ -f "$temp_extract_dir/usr/bin/monitoring-agent" ]]; then + cp "$temp_extract_dir/usr/bin/monitoring-agent" /usr/bin/ + chmod +x /usr/bin/monitoring-agent + log_success "Binary installed to /usr/bin/monitoring-agent" + else + log_error "Binary not found in package" + exit 1 + fi + + if [[ -f "$temp_extract_dir/etc/monitoring-agent/monitoring-agent.env" ]]; then + cp "$temp_extract_dir/etc/monitoring-agent/monitoring-agent.env" /etc/monitoring-agent/ + log_success "Configuration template installed" + fi + + # Create monitoring-agent user and group if they don't exist + if ! getent group monitoring-agent >/dev/null 2>&1; then + groupadd --system monitoring-agent + log_success "Created monitoring-agent group" + else + log_info "monitoring-agent group already exists" + fi + + if ! getent passwd monitoring-agent >/dev/null 2>&1; then + useradd --system --gid monitoring-agent --home-dir /var/lib/monitoring-agent --shell /bin/false --comment "Monitoring Agent" monitoring-agent + log_success "Created monitoring-agent user" + else + log_info "monitoring-agent user already exists" + fi + + # Set proper ownership and permissions + chown -R monitoring-agent:monitoring-agent /var/lib/monitoring-agent + chown -R monitoring-agent:monitoring-agent /var/log/monitoring-agent + chown -R monitoring-agent:monitoring-agent /etc/monitoring-agent + chmod 755 /var/lib/monitoring-agent + chmod 755 /var/log/monitoring-agent + chmod 755 /etc/monitoring-agent + + # Check if Docker is installed and configure service file accordingly + DOCKER_AVAILABLE=false + if command -v docker >/dev/null 2>&1 && getent group docker >/dev/null 2>&1; then + DOCKER_AVAILABLE=true + # Add monitoring-agent user to docker group if Docker is available + usermod -a -G docker monitoring-agent + log_info "Docker detected, added monitoring-agent user to docker group" + else + log_info "Docker not detected, skipping Docker group configuration" + fi + + # Create the systemd service file with proper Docker configuration + create_systemd_service_file "$SYSTEMD_DIR" "$DOCKER_AVAILABLE" + + # Clean up extraction directory + rm -rf "$temp_extract_dir" + + log_success "Tar.gz package installation completed" +} + +# Create systemd service file with Docker configuration +create_systemd_service_file() { + local systemd_dir="$1" + local docker_available="$2" + + log_info "Creating systemd service file at $systemd_dir/monitoring-agent.service" + + cat > "$systemd_dir/monitoring-agent.service" << 'EOF' +[Unit] +Description=CheckCle Server Monitoring Agent +Documentation=https://github.com/operacle/checkcle-server-agent +After=network.target +Wants=network.target + +[Service] +Type=simple +User=monitoring-agent +Group=monitoring-agent +EOF + + # Add Docker group only if Docker is available + if [[ "$docker_available" == "true" ]]; then + echo "# Add docker group for Docker monitoring access" >> "$systemd_dir/monitoring-agent.service" + echo "SupplementaryGroups=docker" >> "$systemd_dir/monitoring-agent.service" + else + echo "# Docker not available - skipping Docker group configuration" >> "$systemd_dir/monitoring-agent.service" + fi + + # Continue with the rest of the service configuration + cat >> "$systemd_dir/monitoring-agent.service" << 'EOF' +ExecStart=/usr/bin/monitoring-agent +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +# Security settings +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RemoveIPC=yes +RestrictRealtime=yes + +# Allow access to system information and configuration +ReadWritePaths=/var/log/monitoring-agent +ReadOnlyPaths=/proc /sys /etc/monitoring-agent + +# Network access +PrivateNetwork=no + +# Environment +EnvironmentFile=/etc/monitoring-agent/monitoring-agent.env +WorkingDirectory=/var/lib/monitoring-agent + +[Install] +WantedBy=multi-user.target +EOF + + log_success "Created systemd service file with Docker support: $docker_available" } # Install package based on detected package manager @@ -185,6 +449,10 @@ install_package() { fi ;; + tar) + install_tar_package + ;; + *) log_error "Unsupported package manager: $PACKAGE_MANAGER" exit 1 @@ -232,7 +500,7 @@ configure_agent() { log_info "Final configuration:" log_info " Server Name: $SERVER_NAME" log_info " Agent ID: $AGENT_ID" - log_info " PB API URL: $POCKETBASE_URL" + log_info " PocketBase URL: $POCKETBASE_URL" log_info " IP Address: $IP_ADDRESS" log_info " OS Type: $OS_TYPE" log_info " Health Check Port: $HEALTH_CHECK_PORT" @@ -341,7 +609,7 @@ test_installation() { show_post_install_info() { echo echo "=============================================" - echo " CheckCle Server Agent Installation Complete!" + echo " Installation Complete!" echo "=============================================" echo log_success "CheckCle Monitoring Agent installed and configured successfully" @@ -350,6 +618,11 @@ show_post_install_info() { echo " Architecture: $ARCH ($PACKAGE_ARCH)" echo " Package: $PACKAGE_FILENAME" echo " Package Manager: $PACKAGE_MANAGER" + if [[ "$PACKAGE_FORMAT" == "tar.gz" ]]; then + echo " Installation Type: Manual (tar.gz)" + else + echo " Installation Type: Native package ($PACKAGE_FORMAT)" + fi echo echo "Configuration: $CONFIG_FILE" echo "Service status: systemctl status monitoring-agent" @@ -416,7 +689,12 @@ case "${1:-}" in echo "System Requirements:" echo " - Linux with systemd" echo " - Supported architectures: x86_64 (amd64), aarch64/arm64" - echo " - Supported distributions: Debian/Ubuntu (.deb), RHEL/CentOS/Fedora (.rpm)" + echo " - Supported distributions: Debian/Ubuntu (.deb), RHEL/CentOS/Fedora (.rpm), or any Linux (tar.gz fallback)" + echo + echo "Package Installation Logic:" + echo " 1. Detects OS and tries native package format first (.deb for Debian-based, .rpm for RHEL-based)" + echo " 2. If native package is not available, automatically falls back to tar.gz" + echo " 3. tar.gz works on any Linux distribution with systemd" echo echo "Required Environment Variables:" echo " SERVER_TOKEN Server authentication token" @@ -444,13 +722,28 @@ case "${1:-}" in systemctl disable monitoring-agent 2>/dev/null || true # Remove based on detected package manager - if command -v dpkg >/dev/null 2>&1; then + if command -v dpkg >/dev/null 2>&1 && dpkg -l monitoring-agent >/dev/null 2>&1; then dpkg -r monitoring-agent 2>/dev/null || true - elif command -v rpm >/dev/null 2>&1; then + log_success "DEB package removed" + elif command -v rpm >/dev/null 2>&1 && rpm -q monitoring-agent >/dev/null 2>&1; then rpm -e monitoring-agent 2>/dev/null || true + log_success "RPM package removed" + else + # Manual removal for tar.gz installations + log_info "Performing manual cleanup for tar.gz installation..." + rm -f /usr/bin/monitoring-agent 2>/dev/null || true + rm -rf /etc/monitoring-agent 2>/dev/null || true + rm -f /lib/systemd/system/monitoring-agent.service 2>/dev/null || true + rm -f /usr/lib/systemd/system/monitoring-agent.service 2>/dev/null || true + rm -f /etc/systemd/system/monitoring-agent.service 2>/dev/null || true + userdel monitoring-agent 2>/dev/null || true + groupdel monitoring-agent 2>/dev/null || true + rm -rf /var/lib/monitoring-agent 2>/dev/null || true + rm -rf /var/log/monitoring-agent 2>/dev/null || true + log_success "Manual cleanup completed" fi - rm -rf /etc/monitoring-agent 2>/dev/null || true + systemctl daemon-reload 2>/dev/null || true log_success "Monitoring agent uninstalled" exit 0 ;; From 1d8b5172c535bb65111823be0b48497bce932eb1 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Tue, 29 Jul 2025 20:54:54 +0700 Subject: [PATCH 12/17] Added the OS logo icon for Arch Linux --- application/public/upload/os/arch.png | Bin 0 -> 59587 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 application/public/upload/os/arch.png diff --git a/application/public/upload/os/arch.png b/application/public/upload/os/arch.png new file mode 100644 index 0000000000000000000000000000000000000000..8a69099456464af44d5fd18b76c84713273b36a7 GIT binary patch literal 59587 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 pDsShN~|AF@D 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 literal 0 HcmV?d00001 From 952eb3d8d75d79abea4bd1f946d696fb4d5316de Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Tue, 29 Jul 2025 21:19:38 +0700 Subject: [PATCH 13/17] Update script URL to deploy from CDN --- application/src/components/servers/ManualInstallTab.tsx | 2 +- application/src/components/servers/OneClickInstallTab.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/components/servers/ManualInstallTab.tsx b/application/src/components/servers/ManualInstallTab.tsx index 9d289a8..757e5a0 100644 --- a/application/src/components/servers/ManualInstallTab.tsx +++ b/application/src/components/servers/ManualInstallTab.tsx @@ -25,7 +25,7 @@ export const ManualInstallTab: React.FC = ({ onDialogClose, }) => { const getManualInstallSteps = () => { - const scriptUrl = "https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/server-agent.sh"; + const scriptUrl = "https://cdn.checkcle.io/scripts/server-agent.sh"; return [ { diff --git a/application/src/components/servers/OneClickInstallTab.tsx b/application/src/components/servers/OneClickInstallTab.tsx index 1067d20..24e5f6f 100644 --- a/application/src/components/servers/OneClickInstallTab.tsx +++ b/application/src/components/servers/OneClickInstallTab.tsx @@ -27,7 +27,7 @@ export const OneClickInstallTab: React.FC = ({ onDialogClose, }) => { const getOneClickInstallCommand = () => { - const scriptUrl = "https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/server-agent.sh"; + const scriptUrl = "https://cdn.checkcle.io/scripts/server-agent.sh"; return `curl -L -o server-agent.sh "${scriptUrl}" chmod +x server-agent.sh From bc06a24adbaf42b9194140631280d4955e834739 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 31 Jul 2025 02:44:51 +0700 Subject: [PATCH 14/17] feat: Add Docker One-Click Install Tab - Adds a new "Docker One-Click" tab to the server dashboard and create new server dialog. This tab provides a one-click installation script for the Docker monitoring agent, dynamically populating it with server-specific details. --- .../servers/AddServerAgentDialog.tsx | 14 +- .../components/servers/DockerOneClickTab.tsx | 176 ++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 application/src/components/servers/DockerOneClickTab.tsx diff --git a/application/src/components/servers/AddServerAgentDialog.tsx b/application/src/components/servers/AddServerAgentDialog.tsx index 755bb3d..65c1645 100644 --- a/application/src/components/servers/AddServerAgentDialog.tsx +++ b/application/src/components/servers/AddServerAgentDialog.tsx @@ -14,6 +14,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getCurrentEndpoint } from "@/lib/pocketbase"; import { ServerAgentConfigForm } from "./ServerAgentConfigForm"; import { OneClickInstallTab } from "./OneClickInstallTab"; +import { DockerOneClickTab } from "./DockerOneClickTab"; import { ManualInstallTab } from "./ManualInstallTab"; interface AddServerAgentDialogProps { @@ -123,9 +124,10 @@ export const AddServerAgentDialog: React.FC = ({ - + Configure Agent One-Click Install + Docker One-Click Manual Installation @@ -150,6 +152,16 @@ export const AddServerAgentDialog: React.FC = ({ onDialogClose={handleDialogClose} /> + + + + void; +} + +export const DockerOneClickTab: React.FC = ({ + serverToken, + currentPocketBaseUrl, + formData, + serverId, + onDialogClose, +}) => { + const getDockerOneClickCommand = () => { + const scriptUrl = "https://cdn.checkcle.io/scripts/server-docker-agent.sh"; + + return `curl -L -o server-docker-agent.sh "${scriptUrl}" +chmod +x server-docker-agent.sh +SERVER_TOKEN="${serverToken}" \\ +POCKETBASE_URL="${currentPocketBaseUrl}" \\ +SERVER_NAME="${formData.serverName}" \\ +AGENT_ID="${serverId}" \\ +sudo -E bash ./server-docker-agent.sh`; + }; + + const getDirectDockerCommand = () => { + return `docker run -d \\ + --name monitoring-agent \\ + --restart unless-stopped \\ + -p 8081:8081 \\ + --group-add 999 \\ + -e AGENT_ID="${serverId}" \\ + -e SERVER_NAME="${formData.serverName}" \\ + -e SERVER_TOKEN="${serverToken}" \\ + -e POCKETBASE_URL="${currentPocketBaseUrl}" \\ + -e POCKETBASE_ENABLED=true \\ + -v /proc:/host/proc:ro \\ + -v /etc:/host/etc:ro \\ + -v /sys:/host/sys:ro \\ + -v /:/host/root:ro \\ + -v /var/run:/host/var/run:ro \\ + -v /dev:/host/dev:ro \\ + -v /var/run/docker.sock:/var/run/docker.sock:ro \\ + -v monitoring_data:/var/lib/monitoring-agent \\ + -v monitoring_logs:/var/log/monitoring-agent \\ + operacle/checkcle-server-agent:latest`; + }; + + const handleCopyOneClickCommand = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Copy one-click command button clicked'); + const command = getDockerOneClickCommand(); + console.log('Copying one-click command:', command); + await copyToClipboard(command); + }; + + const handleCopyDockerCommand = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Copy docker command button clicked'); + const command = getDirectDockerCommand(); + console.log('Copying docker command:', command); + await copyToClipboard(command); + }; + + return ( +
+ {/* One-Click Docker Installation */} + + + + + Docker One-Click Install (Recommended) + + + Automated Docker container installation with system monitoring capabilities + + + +
+ +
+
+                {getDockerOneClickCommand()}
+              
+ +
+
+ +
+

This script will automatically:

+
    +
  1. Download and setup the Docker monitoring agent
  2. +
  3. Configure all required environment variables
  4. +
  5. Start the container with proper system access
  6. +
  7. Setup monitoring data persistence
  8. +
+
+
+
+ + {/* Direct Docker Run Command */} + + + + + Direct Docker Run Command + + + If you prefer to run the Docker container directly without the script + + + +
+ +
+
+                {getDirectDockerCommand()}
+              
+ +
+
+ +
+

Prerequisites for direct Docker run:

+
    +
  1. Docker must be installed and running
  2. +
  3. The operacle/checkcle-server-agent image must be available
  4. +
  5. Run as root or with sudo privileges
  6. +
+
+
+
+ +
+ +
+
+ ); +}; \ No newline at end of file From 44bc2bc1e56223798b960ee6f6d4da4f973d9e6d Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 31 Jul 2025 02:46:27 +0700 Subject: [PATCH 15/17] feat: Create server-docker-agent.sh script - This script will automate the process of running the monitoring agent Docker container. It will take environment variables as input and construct the `docker run` command with the provided values, including volumes and ports, to ensure a seamless one-click installation experience. --- scripts/server-docker-agent.sh | 361 +++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 scripts/server-docker-agent.sh diff --git a/scripts/server-docker-agent.sh b/scripts/server-docker-agent.sh new file mode 100644 index 0000000..49948c3 --- /dev/null +++ b/scripts/server-docker-agent.sh @@ -0,0 +1,361 @@ + +#!/bin/bash + +# CheckCle Server Monitoring Agent - Docker One-Click Installation Script +# This script provides fully automated Docker installation using environment variables + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Helper functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if running as root +check_root() { + if [[ $EUID -ne 0 ]]; then + log_error "This script must be run as root (use sudo)" + exit 1 + fi +} + +# Check if Docker is installed +check_docker() { + if ! command -v docker >/dev/null 2>&1; then + log_error "Docker is not installed" + log_info "Please install Docker first:" + log_info " Ubuntu/Debian: sudo apt-get update && sudo apt-get install docker.io" + log_info " CentOS/RHEL: sudo yum install docker" + log_info " Or visit: https://docs.docker.com/engine/install/" + exit 1 + fi + + # Check if Docker daemon is running + if ! docker info >/dev/null 2>&1; then + log_error "Docker daemon is not running" + log_info "Start Docker with: sudo systemctl start docker" + exit 1 + fi + + log_success "Docker is installed and running" +} + +# Validate required environment variables +validate_environment() { + local missing_vars=() + + # Check required variables + [[ -z "$SERVER_TOKEN" ]] && missing_vars+=("SERVER_TOKEN") + [[ -z "$POCKETBASE_URL" ]] && missing_vars+=("POCKETBASE_URL") + [[ -z "$SERVER_NAME" ]] && missing_vars+=("SERVER_NAME") + [[ -z "$AGENT_ID" ]] && missing_vars+=("AGENT_ID") + + if [[ ${#missing_vars[@]} -ne 0 ]]; then + log_error "Missing required environment variables: ${missing_vars[*]}" + log_info "" + log_info "Usage examples:" + log_info " SERVER_TOKEN=your-token POCKETBASE_URL=http://host:8090 SERVER_NAME=server AGENT_ID=agent sudo -E bash $0" + log_info " sudo SERVER_TOKEN=your-token POCKETBASE_URL=http://host:8090 SERVER_NAME=server AGENT_ID=agent bash $0" + log_info "" + log_info "Required variables:" + log_info " SERVER_TOKEN Server authentication token" + log_info " POCKETBASE_URL PocketBase URL (e.g., http://194.233.80.126:8090)" + log_info " SERVER_NAME Server name (e.g., DB-Server01)" + log_info " AGENT_ID Agent identifier (e.g., agent_hogr3np88u7dq5xf9balzl)" + log_info "" + log_info "Optional variables:" + log_info " HEALTH_CHECK_PORT Health check port (default: 8081)" + log_info " CONTAINER_NAME Container name (default: monitoring-agent)" + log_info " DOCKER_IMAGE Docker image (default: operacle/checkcle-server-agent:latest)" + exit 1 + fi + + log_success "Environment validation passed" + log_info "SERVER_TOKEN: ${SERVER_TOKEN:0:8}..." + log_info "POCKETBASE_URL: $POCKETBASE_URL" + log_info "SERVER_NAME: $SERVER_NAME" + log_info "AGENT_ID: $AGENT_ID" +} + +# Set default values for optional variables +set_defaults() { + HEALTH_CHECK_PORT="${HEALTH_CHECK_PORT:-8081}" + CONTAINER_NAME="${CONTAINER_NAME:-monitoring-agent}" + DOCKER_IMAGE="${DOCKER_IMAGE:-operacle/checkcle-server-agent:latest}" + POCKETBASE_ENABLED="${POCKETBASE_ENABLED:-true}" + + log_info "Configuration:" + log_info " Container name: $CONTAINER_NAME" + log_info " Docker image: $DOCKER_IMAGE" + log_info " Health check port: $HEALTH_CHECK_PORT" + log_info " PocketBase enabled: $POCKETBASE_ENABLED" +} + +# Stop and remove existing container if it exists +cleanup_existing_container() { + if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + log_info "Stopping and removing existing container: $CONTAINER_NAME" + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true + log_success "Existing container removed" + fi +} + +# Create Docker volumes +create_volumes() { + log_info "Creating Docker volumes..." + + # Create volumes if they don't exist + if ! docker volume ls --format '{{.Name}}' | grep -q "^monitoring_data$"; then + docker volume create monitoring_data + log_success "Created monitoring_data volume" + else + log_info "monitoring_data volume already exists" + fi + + if ! docker volume ls --format '{{.Name}}' | grep -q "^monitoring_logs$"; then + docker volume create monitoring_logs + log_success "Created monitoring_logs volume" + else + log_info "monitoring_logs volume already exists" + fi +} + +# Get Docker group ID +get_docker_group_id() { + local docker_gid + docker_gid=$(getent group docker | cut -d: -f3) + if [[ -z "$docker_gid" ]]; then + log_warning "Docker group not found, using default GID 999" + docker_gid=999 + fi + echo "$docker_gid" +} + +# Run the Docker container +run_docker_container() { + log_info "Starting monitoring agent Docker container..." + + local docker_gid + docker_gid=$(get_docker_group_id) + + log_info "Docker group ID: $docker_gid" + + # Run the Docker container with all the required parameters + docker run -d \ + --name "$CONTAINER_NAME" \ + --restart unless-stopped \ + -p "$HEALTH_CHECK_PORT:8081" \ + --group-add "$docker_gid" \ + -e AGENT_ID="$AGENT_ID" \ + -e SERVER_NAME="$SERVER_NAME" \ + -e SERVER_TOKEN="$SERVER_TOKEN" \ + -e POCKETBASE_URL="$POCKETBASE_URL" \ + -e POCKETBASE_ENABLED="$POCKETBASE_ENABLED" \ + -v /proc:/host/proc:ro \ + -v /etc:/host/etc:ro \ + -v /sys:/host/sys:ro \ + -v /:/host/root:ro \ + -v /var/run:/host/var/run:ro \ + -v /dev:/host/dev:ro \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v monitoring_data:/var/lib/monitoring-agent \ + -v monitoring_logs:/var/log/monitoring-agent \ + "$DOCKER_IMAGE" + + log_success "Docker container started successfully" +} + +# Wait for container to be ready +wait_for_container() { + log_info "Waiting for container to be ready..." + + local max_attempts=30 + local attempt=0 + + while [[ $attempt -lt $max_attempts ]]; do + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + log_success "Container is running" + return 0 + fi + + sleep 1 + ((attempt++)) + done + + log_error "Container failed to start within expected time" + return 1 +} + +# Test the installation +test_installation() { + log_info "Testing installation..." + + # Check container status + if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + log_error "Container is not running" + log_info "Check container logs: docker logs $CONTAINER_NAME" + return 1 + fi + + # Wait a moment for the service to start + sleep 3 + + # Test health endpoint + log_info "Testing health endpoint at http://localhost:$HEALTH_CHECK_PORT/health" + + if curl -s -f "http://localhost:$HEALTH_CHECK_PORT/health" >/dev/null 2>&1; then + log_success "Health endpoint is responding" + else + log_warning "Health endpoint not responding yet (service may still be starting)" + fi + + # Show recent container logs + log_info "Recent container logs:" + docker logs --tail 10 "$CONTAINER_NAME" +} + +# Show post-installation information +show_post_install_info() { + echo + echo "=============================================" + echo " Docker Installation Complete!" + echo "=============================================" + echo + log_success "CheckCle Monitoring Agent Docker container installed and running successfully" + echo + echo "Container Information:" + echo " Container name: $CONTAINER_NAME" + echo " Docker image: $DOCKER_IMAGE" + echo " Health check port: $HEALTH_CHECK_PORT" + echo " Agent ID: $AGENT_ID" + echo " Server name: $SERVER_NAME" + echo + echo "Useful commands:" + echo " Container status: docker ps -f name=$CONTAINER_NAME" + echo " Container logs: docker logs -f $CONTAINER_NAME" + echo " Stop container: docker stop $CONTAINER_NAME" + echo " Start container: docker start $CONTAINER_NAME" + echo " Remove container: docker rm -f $CONTAINER_NAME" + echo " Health check: curl http://localhost:$HEALTH_CHECK_PORT/health" + echo + echo "The monitoring agent is now running in a Docker container and will appear in your dashboard." + echo +} + +# Main installation function +main() { + echo "=============================================" + echo " CheckCle Server Monitoring Agent" + echo " Docker One-Click Installation" + echo "=============================================" + echo + + check_root + check_docker + validate_environment + set_defaults + + log_info "Starting Docker container installation..." + + # Setup and run container + cleanup_existing_container + create_volumes + run_docker_container + + if wait_for_container; then + test_installation + show_post_install_info + else + log_error "Container installation failed" + log_info "Check container logs: docker logs $CONTAINER_NAME" + exit 1 + fi +} + +# Handle script arguments +case "${1:-}" in + --help|-h) + echo "CheckCle Server Monitoring Agent - Docker One-Click Installer" + echo + echo "Usage: SERVER_TOKEN=token POCKETBASE_URL=url SERVER_NAME=name AGENT_ID=id sudo bash $0" + echo + echo "System Requirements:" + echo " - Docker installed and running" + echo " - Root privileges (sudo)" + echo " - Internet access to pull Docker image" + echo + echo "Required Environment Variables:" + echo " SERVER_TOKEN Server authentication token" + echo " POCKETBASE_URL PocketBase URL (e.g., http://194.233.80.126:8090)" + echo " SERVER_NAME Server name (e.g., DB-Server01)" + echo " AGENT_ID Agent identifier" + echo + echo "Optional Environment Variables:" + echo " HEALTH_CHECK_PORT Health check port (default: 8081)" + echo " CONTAINER_NAME Container name (default: monitoring-agent)" + echo " DOCKER_IMAGE Docker image (default: operacle/checkcle-server-agent:latest)" + echo " POCKETBASE_ENABLED Enable PocketBase (default: true)" + echo + echo "Examples:" + echo " SERVER_TOKEN=abc123 POCKETBASE_URL=http://host:8090 SERVER_NAME=server AGENT_ID=agent sudo -E bash $0" + echo " sudo SERVER_TOKEN=abc123 POCKETBASE_URL=http://host:8090 SERVER_NAME=server AGENT_ID=agent bash $0" + echo + echo "Options:" + echo " --help, -h Show this help message" + echo " --uninstall Remove the monitoring agent container" + echo + exit 0 + ;; + --uninstall) + check_root + check_docker + set_defaults + + log_info "Uninstalling monitoring agent Docker container..." + + # Stop and remove container + if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true + log_success "Container removed" + else + log_info "Container not found" + fi + + # Optionally remove volumes (ask user) + echo -n "Remove data volumes? (y/N): " + read -r response + if [[ "$response" =~ ^[Yy]$ ]]; then + docker volume rm monitoring_data monitoring_logs >/dev/null 2>&1 || true + log_success "Data volumes removed" + else + log_info "Data volumes preserved" + fi + + log_success "Monitoring agent Docker container uninstalled" + exit 0 + ;; + *) + main "$@" + ;; +esac \ No newline at end of file From 1fac88dcc9e97e241ec55d42d773b53728845b15 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 31 Jul 2025 02:52:57 +0700 Subject: [PATCH 16/17] feat: add docker compose for server docker agent --- docker/server-docker-agent.yml | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docker/server-docker-agent.yml diff --git a/docker/server-docker-agent.yml b/docker/server-docker-agent.yml new file mode 100644 index 0000000..76823b2 --- /dev/null +++ b/docker/server-docker-agent.yml @@ -0,0 +1,35 @@ +name: checkcle-server-agent + +services: + checkcle-server-agent: + container_name: monitoring-agent + restart: unless-stopped + ports: + - "8081:8081" + group_add: + - 999 + environment: + AGENT_ID: agent_124343535 + SERVER_NAME: "test" + SERVER_TOKEN: srv_123243434 + POCKETBASE_URL: http://x.x.x.x:8090 + POCKETBASE_ENABLED: "true" + volumes: + - /proc:/host/proc:ro + - /etc:/host/etc:ro + - /sys:/host/sys:ro + - /:/host/root:ro + - /var/run:/host/var/run:ro + - /dev:/host/dev:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - monitoring_data:/var/lib/monitoring-agent + - monitoring_logs:/var/log/monitoring-agent + image: operacle/checkcle-server-agent:latest + +volumes: + monitoring_data: + external: true + name: monitoring_data + monitoring_logs: + external: true + name: monitoring_logs From 205bedfba58618263e9a961c6698bc80d4962fc0 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 31 Jul 2025 02:58:02 +0700 Subject: [PATCH 17/17] Updated project development status --- docs/DEVELOPMENT_ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/DEVELOPMENT_ROADMAP.md index 5afac95..a767df0 100644 --- a/docs/DEVELOPMENT_ROADMAP.md +++ b/docs/DEVELOPMENT_ROADMAP.md @@ -68,7 +68,7 @@ The roadmap is divided into the following stages: #### Tentative Features: - [ ] โœ… Server and Service Table row clickable to detail page. - [ ] โœ… Implement pagination for the SSL dashboard table -- [ ] ๐Ÿ”ง Server Agent (RPM, Docker container, and general binary package) +- [ ] โœ… Server Agent (RPM, Docker container, and general binary package) - [ ] ๐Ÿ”ง Notification System (Webhook, Telegram, Discord, Slack, Email, Google Chat) - [ ] ๐Ÿ”ง Improve Uptime Service and Server connection update based on status and notification. - [ ] ๐Ÿ”ง Improve SSL perform the initial check automatically after creation