diff --git a/backend/prisma/migrations/20250918192635_add_update_check_fields/migration.sql b/backend/prisma/migrations/20250918192635_add_update_check_fields/migration.sql new file mode 100644 index 0000000..4d69a7d --- /dev/null +++ b/backend/prisma/migrations/20250918192635_add_update_check_fields/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "settings" ADD COLUMN "last_update_check" TIMESTAMP(3), +ADD COLUMN "latest_version" TEXT, +ADD COLUMN "update_available" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1c07cef..0bd52d8 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -187,6 +187,9 @@ model Settings { githubRepoUrl String @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking repositoryType String @map("repository_type") @default("public") // "public" or "private" sshKeyPath String? @map("ssh_key_path") // Optional SSH key path for deploy key authentication + lastUpdateCheck DateTime? @map("last_update_check") // When the system last checked for updates + updateAvailable Boolean @map("update_available") @default(false) // Whether an update is available + latestVersion String? @map("latest_version") // Latest available version createdAt DateTime @map("created_at") @default(now()) updatedAt DateTime @map("updated_at") @updatedAt diff --git a/backend/src/routes/versionRoutes.js b/backend/src/routes/versionRoutes.js index 5f92b5c..db35b6a 100644 --- a/backend/src/routes/versionRoutes.js +++ b/backend/src/routes/versionRoutes.js @@ -142,202 +142,36 @@ router.post('/test-ssh-key', authenticateToken, requireManageSettings, async (re // Check for updates from GitHub router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => { try { - // Get GitHub repo URL from settings + // Get cached update information from settings const settings = await prisma.settings.findFirst(); - console.log('Settings retrieved for version check:', { - id: settings?.id, - githubRepoUrl: settings?.githubRepoUrl, - repositoryType: settings?.repositoryType + + if (!settings) { + return res.status(400).json({ error: 'Settings not found' }); + } + + const currentVersion = '1.2.4'; + const latestVersion = settings.latestVersion || currentVersion; + const isUpdateAvailable = settings.updateAvailable || false; + const lastUpdateCheck = settings.lastUpdateCheck; + + res.json({ + currentVersion, + latestVersion, + isUpdateAvailable, + lastUpdateCheck, + repositoryType: settings.repositoryType || 'public', + latestRelease: { + tagName: latestVersion ? `v${latestVersion}` : null, + version: latestVersion, + repository: settings.githubRepoUrl ? settings.githubRepoUrl.split('/').slice(-2).join('/') : null, + accessMethod: settings.repositoryType === 'private' ? 'ssh' : 'api' + } }); - - if (!settings || !settings.githubRepoUrl) { - return res.status(400).json({ error: 'GitHub repository URL not configured' }); - } - // Extract owner and repo from GitHub URL - // Support both SSH and HTTPS formats: - // git@github.com:owner/repo.git - // https://github.com/owner/repo.git - const repoUrl = settings.githubRepoUrl; - console.log('Using repository URL:', repoUrl); - let owner, repo; - - if (repoUrl.includes('git@github.com:')) { - const match = repoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/); - if (match) { - [, owner, repo] = match; - } - } else if (repoUrl.includes('github.com/')) { - const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/); - if (match) { - [, owner, repo] = match; - } - } - - console.log('Extracted owner and repo:', { owner, repo }); - - if (!owner || !repo) { - return res.status(400).json({ error: 'Invalid GitHub repository URL format' }); - } - - // Determine repository type and set up appropriate access method - const repositoryType = settings.repositoryType || 'public'; - const isPrivate = repositoryType === 'private'; - - let latestTag; - - if (isPrivate) { - // Use SSH with deploy keys for private repositories - const sshRepoUrl = `git@github.com:${owner}/${repo}.git`; - - try { - let sshKeyPath = null; - - // First, try to use the configured SSH key path from settings - if (settings.sshKeyPath) { - try { - require('fs').accessSync(settings.sshKeyPath); - sshKeyPath = settings.sshKeyPath; - console.log(`Using configured SSH key at: ${sshKeyPath}`); - } catch (e) { - console.warn(`Configured SSH key path not accessible: ${settings.sshKeyPath}`); - } - } - - // If no configured path or it's not accessible, try common locations - if (!sshKeyPath) { - const possibleKeyPaths = [ - '/root/.ssh/id_ed25519', // Root user (if service runs as root) - '/root/.ssh/id_rsa', // Root user RSA key - '/home/patchmon/.ssh/id_ed25519', // PatchMon user - '/home/patchmon/.ssh/id_rsa', // PatchMon user RSA key - '/var/www/.ssh/id_ed25519', // Web user - '/var/www/.ssh/id_rsa' // Web user RSA key - ]; - - for (const path of possibleKeyPaths) { - try { - require('fs').accessSync(path); - sshKeyPath = path; - console.log(`Found SSH key at: ${path}`); - break; - } catch (e) { - // Key not found at this path, try next - } - } - } - - if (!sshKeyPath) { - throw new Error('No SSH deploy key found. Please configure the SSH key path in settings or ensure a deploy key is installed in one of the expected locations.'); - } - - const env = { - ...process.env, - GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` - }; - - // Fetch the latest tag using SSH with deploy key - const { stdout: sshLatestTag } = await execAsync( - `git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`, - { - timeout: 10000, - env: env - } - ); - - latestTag = sshLatestTag; - } catch (sshError) { - console.error('SSH Git error:', sshError.message); - throw sshError; - } - } else { - // Use GitHub API for public repositories (no authentication required) - try { - const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; - - const response = await fetch(httpsRepoUrl, { - method: 'GET', - headers: { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'PatchMon-Server/1.2.4' - }, - timeout: 10000 - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); - } - - const releaseData = await response.json(); - latestTag = releaseData.tag_name; - } catch (apiError) { - console.error('GitHub API error:', apiError.message); - throw apiError; - } - } - - const latestVersion = latestTag.trim().replace('v', ''); // Remove 'v' prefix - const currentVersion = '1.2.4'; - - // Simple version comparison (assumes semantic versioning) - const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0; - - res.json({ - currentVersion, - latestVersion, - isUpdateAvailable, - repositoryType, - latestRelease: { - tagName: latestTag.trim(), - version: latestVersion, - repository: `${owner}/${repo}`, - accessMethod: isPrivate ? 'ssh' : 'api', - ...(isPrivate && { sshUrl: `git@github.com:${owner}/${repo}.git`, sshKeyUsed: settings.sshKeyPath }) - } - }); - - } catch (error) { - console.error('Version check error:', error.message); - - if (error.message.includes('Permission denied') || error.message.includes('Host key verification failed')) { - return res.status(403).json({ - error: 'Access denied to repository', - suggestion: isPrivate - ? 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.' - : 'Check that the repository URL is correct and the repository is public.' - }); - } - - if (error.message.includes('not found') || error.message.includes('does not exist')) { - return res.status(404).json({ - error: 'Repository not found', - suggestion: 'Check that the repository URL is correct and accessible.' - }); - } - - if (error.message.includes('No SSH deploy key found')) { - return res.status(400).json({ - error: 'No SSH deploy key found', - suggestion: 'Please install a deploy key in one of the expected locations: /root/.ssh/, /home/patchmon/.ssh/, or /var/www/.ssh/' - }); - } - - if (error.message.includes('GitHub API error')) { - return res.status(500).json({ - error: 'Failed to fetch repository information via GitHub API', - details: error.message, - suggestion: 'Check that the repository URL is correct and the repository is public.' - }); - } - - return res.status(500).json({ - error: 'Failed to fetch repository information', - details: error.message, - suggestion: isPrivate - ? 'Check deploy key configuration and repository access permissions.' - : 'Check repository URL and ensure it is accessible.' - }); - } + } catch (error) { + console.error('Error getting update information:', error); + res.status(500).json({ error: 'Failed to get update information' }); + } }); // Simple version comparison function diff --git a/backend/src/server.js b/backend/src/server.js index a6d1216..cd35708 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -18,6 +18,7 @@ const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes' const repositoryRoutes = require('./routes/repositoryRoutes'); const versionRoutes = require('./routes/versionRoutes'); const tfaRoutes = require('./routes/tfaRoutes'); +const updateScheduler = require('./services/updateScheduler'); // Initialize Prisma client const prisma = new PrismaClient(); @@ -160,6 +161,7 @@ process.on('SIGTERM', async () => { if (process.env.ENABLE_LOGGING === 'true') { logger.info('SIGTERM received, shutting down gracefully'); } + updateScheduler.stop(); await prisma.$disconnect(); process.exit(0); }); @@ -168,6 +170,7 @@ process.on('SIGINT', async () => { if (process.env.ENABLE_LOGGING === 'true') { logger.info('SIGINT received, shutting down gracefully'); } + updateScheduler.stop(); await prisma.$disconnect(); process.exit(0); }); @@ -178,6 +181,9 @@ app.listen(PORT, () => { logger.info(`Server running on port ${PORT}`); logger.info(`Environment: ${process.env.NODE_ENV}`); } + + // Start update scheduler + updateScheduler.start(); }); module.exports = app; \ No newline at end of file diff --git a/backend/src/services/updateScheduler.js b/backend/src/services/updateScheduler.js new file mode 100644 index 0000000..01b8fb9 --- /dev/null +++ b/backend/src/services/updateScheduler.js @@ -0,0 +1,247 @@ +const { PrismaClient } = require('@prisma/client'); +const { exec } = require('child_process'); +const { promisify } = require('util'); + +const prisma = new PrismaClient(); +const execAsync = promisify(exec); + +class UpdateScheduler { + constructor() { + this.isRunning = false; + this.intervalId = null; + this.checkInterval = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + } + + // Start the scheduler + start() { + if (this.isRunning) { + console.log('Update scheduler is already running'); + return; + } + + console.log('🔄 Starting update scheduler...'); + this.isRunning = true; + + // Run initial check + this.checkForUpdates(); + + // Schedule regular checks + this.intervalId = setInterval(() => { + this.checkForUpdates(); + }, this.checkInterval); + + console.log(`✅ Update scheduler started - checking every ${this.checkInterval / (60 * 60 * 1000)} hours`); + } + + // Stop the scheduler + stop() { + if (!this.isRunning) { + console.log('Update scheduler is not running'); + return; + } + + console.log('🛑 Stopping update scheduler...'); + this.isRunning = false; + + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + console.log('✅ Update scheduler stopped'); + } + + // Check for updates + async checkForUpdates() { + try { + console.log('🔍 Checking for updates...'); + + // Get settings + const settings = await prisma.settings.findFirst(); + if (!settings || !settings.githubRepoUrl) { + console.log('⚠️ No GitHub repository configured, skipping update check'); + return; + } + + // Extract owner and repo from GitHub URL + const repoUrl = settings.githubRepoUrl; + let owner, repo; + + if (repoUrl.includes('git@github.com:')) { + const match = repoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/); + if (match) { + [, owner, repo] = match; + } + } else if (repoUrl.includes('github.com/')) { + const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?$/); + if (match) { + [, owner, repo] = match; + } + } + + if (!owner || !repo) { + console.log('⚠️ Could not parse GitHub repository URL, skipping update check'); + return; + } + + let latestVersion; + const isPrivate = settings.repositoryType === 'private'; + + if (isPrivate) { + // Use SSH for private repositories + latestVersion = await this.checkPrivateRepo(settings, owner, repo); + } else { + // Use GitHub API for public repositories + latestVersion = await this.checkPublicRepo(owner, repo); + } + + if (!latestVersion) { + console.log('⚠️ Could not determine latest version, skipping update check'); + return; + } + + const currentVersion = '1.2.4'; + const isUpdateAvailable = this.compareVersions(latestVersion, currentVersion) > 0; + + // Update settings with check results + await prisma.settings.update({ + where: { id: settings.id }, + data: { + lastUpdateCheck: new Date(), + updateAvailable: isUpdateAvailable, + latestVersion: latestVersion + } + }); + + console.log(`✅ Update check completed - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`); + + } catch (error) { + console.error('❌ Error checking for updates:', error.message); + + // Update last check time even on error + try { + const settings = await prisma.settings.findFirst(); + if (settings) { + await prisma.settings.update({ + where: { id: settings.id }, + data: { + lastUpdateCheck: new Date(), + updateAvailable: false + } + }); + } + } catch (updateError) { + console.error('❌ Error updating last check time:', updateError.message); + } + } + } + + // Check private repository using SSH + async checkPrivateRepo(settings, owner, repo) { + try { + let sshKeyPath = settings.sshKeyPath; + + // Try to find SSH key if not configured + if (!sshKeyPath) { + const possibleKeyPaths = [ + '/root/.ssh/id_ed25519', + '/root/.ssh/id_rsa', + '/home/patchmon/.ssh/id_ed25519', + '/home/patchmon/.ssh/id_rsa', + '/var/www/.ssh/id_ed25519', + '/var/www/.ssh/id_rsa' + ]; + + for (const path of possibleKeyPaths) { + try { + require('fs').accessSync(path); + sshKeyPath = path; + break; + } catch (e) { + // Key not found at this path, try next + } + } + } + + if (!sshKeyPath) { + throw new Error('No SSH deploy key found'); + } + + const sshRepoUrl = `git@github.com:${owner}/${repo}.git`; + const env = { + ...process.env, + GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` + }; + + const { stdout: sshLatestTag } = await execAsync( + `git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`, + { + timeout: 10000, + env: env + } + ); + + return sshLatestTag.trim().replace('v', ''); + } catch (error) { + console.error('SSH Git error:', error.message); + throw error; + } + } + + // Check public repository using GitHub API + async checkPublicRepo(owner, repo) { + try { + const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + + const response = await fetch(httpsRepoUrl, { + method: 'GET', + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'PatchMon-Server/1.2.4' + } + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + + const releaseData = await response.json(); + return releaseData.tag_name.replace('v', ''); + } catch (error) { + console.error('GitHub API error:', error.message); + throw error; + } + } + + // Compare version strings (semantic versioning) + compareVersions(version1, version2) { + const v1parts = version1.split('.').map(Number); + const v2parts = version2.split('.').map(Number); + + const maxLength = Math.max(v1parts.length, v2parts.length); + + for (let i = 0; i < maxLength; i++) { + const v1part = v1parts[i] || 0; + const v2part = v2parts[i] || 0; + + if (v1part > v2part) return 1; + if (v1part < v2part) return -1; + } + + return 0; + } + + // Get scheduler status + getStatus() { + return { + isRunning: this.isRunning, + checkInterval: this.checkInterval, + nextCheck: this.isRunning ? new Date(Date.now() + this.checkInterval) : null + }; + } +} + +// Create singleton instance +const updateScheduler = new UpdateScheduler(); + +module.exports = updateScheduler; diff --git a/frontend/package.json b/frontend/package.json index 8244850..d9321df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,9 @@ "axios": "^1.6.2", "chart.js": "^4.4.0", "clsx": "^2.0.0", + "cors": "^2.8.5", "date-fns": "^2.30.0", + "express": "^4.18.2", "lucide-react": "^0.294.0", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", diff --git a/frontend/server.js b/frontend/server.js new file mode 100644 index 0000000..106d9aa --- /dev/null +++ b/frontend/server.js @@ -0,0 +1,29 @@ +import express from 'express'; +import path from 'path'; +import cors from 'cors'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Enable CORS for API calls +app.use(cors({ + origin: process.env.CORS_ORIGIN || '*', + credentials: true +})); + +// Serve static files from dist directory +app.use(express.static(path.join(__dirname, 'dist'))); + +// Handle SPA routing - serve index.html for all routes +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, 'dist', 'index.html')); +}); + +app.listen(PORT, () => { + console.log(`Frontend server running on port ${PORT}`); + console.log(`Serving from: ${path.join(__dirname, 'dist')}`); +}); diff --git a/frontend/src/contexts/UpdateNotificationContext.jsx b/frontend/src/contexts/UpdateNotificationContext.jsx index 37070d6..4da30fc 100644 --- a/frontend/src/contexts/UpdateNotificationContext.jsx +++ b/frontend/src/contexts/UpdateNotificationContext.jsx @@ -1,4 +1,6 @@ import React, { createContext, useContext, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { versionAPI } from '../utils/api' const UpdateNotificationContext = createContext() @@ -11,20 +13,29 @@ export const useUpdateNotification = () => { } export const UpdateNotificationProvider = ({ children }) => { - const [updateAvailable, setUpdateAvailable] = useState(false) - const [updateInfo, setUpdateInfo] = useState(null) + const [dismissed, setDismissed] = useState(false) + + // Query for update information + const { data: updateData, isLoading, error } = useQuery({ + queryKey: ['updateCheck'], + queryFn: () => versionAPI.checkUpdates().then(res => res.data), + refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes + retry: 1 + }) + + const updateAvailable = updateData?.isUpdateAvailable && !dismissed + const updateInfo = updateData const dismissNotification = () => { - setUpdateAvailable(false) - setUpdateInfo(null) + setDismissed(true) } const value = { updateAvailable, updateInfo, dismissNotification, - isLoading: false, - error: null + isLoading, + error } return ( diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index fb6ced8..51a1f0e 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Save, Server, Globe, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon } from 'lucide-react'; +import { Save, Server, Globe, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon, Clock } from 'lucide-react'; import { settingsAPI, agentVersionAPI, versionAPI } from '../utils/api'; import { useUpdateNotification } from '../contexts/UpdateNotificationContext'; import UpgradeNotificationIcon from '../components/UpgradeNotificationIcon'; @@ -195,6 +195,7 @@ const Settings = () => { currentVersion: data.currentVersion, latestVersion: data.latestVersion, isUpdateAvailable: data.isUpdateAvailable, + lastUpdateCheck: data.lastUpdateCheck, checking: false, error: null }); @@ -950,6 +951,22 @@ const Settings = () => { + {/* Last Checked Time */} + {versionInfo.lastUpdateCheck && ( +
+
+ + Last Checked +
+ + {new Date(versionInfo.lastUpdateCheck).toLocaleString()} + +

+ Updates are checked automatically every 24 hours +

+
+ )} +