added server.js for frontend when not using nginx in deployment

This commit is contained in:
Muhammad Ibrahim
2025-09-18 22:59:50 +01:00
parent 2d7a3c3103
commit 51d6dd63b1
10 changed files with 355 additions and 200 deletions

View File

@@ -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;

View File

@@ -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 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" repositoryType String @map("repository_type") @default("public") // "public" or "private"
sshKeyPath String? @map("ssh_key_path") // Optional SSH key path for deploy key authentication 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()) createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt updatedAt DateTime @map("updated_at") @updatedAt

View File

@@ -142,201 +142,35 @@ router.post('/test-ssh-key', authenticateToken, requireManageSettings, async (re
// Check for updates from GitHub // Check for updates from GitHub
router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => { router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => {
try { try {
// Get GitHub repo URL from settings // Get cached update information from settings
const settings = await prisma.settings.findFirst(); const settings = await prisma.settings.findFirst();
console.log('Settings retrieved for version check:', {
id: settings?.id,
githubRepoUrl: settings?.githubRepoUrl,
repositoryType: settings?.repositoryType
});
if (!settings || !settings.githubRepoUrl) { if (!settings) {
return res.status(400).json({ error: 'GitHub repository URL not configured' }); return res.status(400).json({ error: 'Settings not found' });
} }
// 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'; const currentVersion = '1.2.4';
const latestVersion = settings.latestVersion || currentVersion;
// Simple version comparison (assumes semantic versioning) const isUpdateAvailable = settings.updateAvailable || false;
const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0; const lastUpdateCheck = settings.lastUpdateCheck;
res.json({ res.json({
currentVersion, currentVersion,
latestVersion, latestVersion,
isUpdateAvailable, isUpdateAvailable,
repositoryType, lastUpdateCheck,
repositoryType: settings.repositoryType || 'public',
latestRelease: { latestRelease: {
tagName: latestTag.trim(), tagName: latestVersion ? `v${latestVersion}` : null,
version: latestVersion, version: latestVersion,
repository: `${owner}/${repo}`, repository: settings.githubRepoUrl ? settings.githubRepoUrl.split('/').slice(-2).join('/') : null,
accessMethod: isPrivate ? 'ssh' : 'api', accessMethod: settings.repositoryType === 'private' ? 'ssh' : 'api'
...(isPrivate && { sshUrl: `git@github.com:${owner}/${repo}.git`, sshKeyUsed: settings.sshKeyPath })
} }
}); });
} catch (error) { } catch (error) {
console.error('Version check error:', error.message); console.error('Error getting update information:', error);
res.status(500).json({ error: 'Failed to get update information' });
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.'
});
} }
}); });

View File

@@ -18,6 +18,7 @@ const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes'
const repositoryRoutes = require('./routes/repositoryRoutes'); const repositoryRoutes = require('./routes/repositoryRoutes');
const versionRoutes = require('./routes/versionRoutes'); const versionRoutes = require('./routes/versionRoutes');
const tfaRoutes = require('./routes/tfaRoutes'); const tfaRoutes = require('./routes/tfaRoutes');
const updateScheduler = require('./services/updateScheduler');
// Initialize Prisma client // Initialize Prisma client
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -160,6 +161,7 @@ process.on('SIGTERM', async () => {
if (process.env.ENABLE_LOGGING === 'true') { if (process.env.ENABLE_LOGGING === 'true') {
logger.info('SIGTERM received, shutting down gracefully'); logger.info('SIGTERM received, shutting down gracefully');
} }
updateScheduler.stop();
await prisma.$disconnect(); await prisma.$disconnect();
process.exit(0); process.exit(0);
}); });
@@ -168,6 +170,7 @@ process.on('SIGINT', async () => {
if (process.env.ENABLE_LOGGING === 'true') { if (process.env.ENABLE_LOGGING === 'true') {
logger.info('SIGINT received, shutting down gracefully'); logger.info('SIGINT received, shutting down gracefully');
} }
updateScheduler.stop();
await prisma.$disconnect(); await prisma.$disconnect();
process.exit(0); process.exit(0);
}); });
@@ -178,6 +181,9 @@ app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`); logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`); logger.info(`Environment: ${process.env.NODE_ENV}`);
} }
// Start update scheduler
updateScheduler.start();
}); });
module.exports = app; module.exports = app;

View File

@@ -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;

View File

@@ -17,7 +17,9 @@
"axios": "^1.6.2", "axios": "^1.6.2",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cors": "^2.8.5",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"express": "^4.18.2",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",

29
frontend/server.js Normal file
View File

@@ -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')}`);
});

View File

@@ -1,4 +1,6 @@
import React, { createContext, useContext, useState } from 'react' import React, { createContext, useContext, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { versionAPI } from '../utils/api'
const UpdateNotificationContext = createContext() const UpdateNotificationContext = createContext()
@@ -11,20 +13,29 @@ export const useUpdateNotification = () => {
} }
export const UpdateNotificationProvider = ({ children }) => { export const UpdateNotificationProvider = ({ children }) => {
const [updateAvailable, setUpdateAvailable] = useState(false) const [dismissed, setDismissed] = useState(false)
const [updateInfo, setUpdateInfo] = useState(null)
// 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 = () => { const dismissNotification = () => {
setUpdateAvailable(false) setDismissed(true)
setUpdateInfo(null)
} }
const value = { const value = {
updateAvailable, updateAvailable,
updateInfo, updateInfo,
dismissNotification, dismissNotification,
isLoading: false, isLoading,
error: null error
} }
return ( return (

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 { settingsAPI, agentVersionAPI, versionAPI } from '../utils/api';
import { useUpdateNotification } from '../contexts/UpdateNotificationContext'; import { useUpdateNotification } from '../contexts/UpdateNotificationContext';
import UpgradeNotificationIcon from '../components/UpgradeNotificationIcon'; import UpgradeNotificationIcon from '../components/UpgradeNotificationIcon';
@@ -195,6 +195,7 @@ const Settings = () => {
currentVersion: data.currentVersion, currentVersion: data.currentVersion,
latestVersion: data.latestVersion, latestVersion: data.latestVersion,
isUpdateAvailable: data.isUpdateAvailable, isUpdateAvailable: data.isUpdateAvailable,
lastUpdateCheck: data.lastUpdateCheck,
checking: false, checking: false,
error: null error: null
}); });
@@ -950,6 +951,22 @@ const Settings = () => {
</div> </div>
</div> </div>
{/* Last Checked Time */}
{versionInfo.lastUpdateCheck && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Last Checked</span>
</div>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{new Date(versionInfo.lastUpdateCheck).toLocaleString()}
</span>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
Updates are checked automatically every 24 hours
</p>
</div>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button

2
package-lock.json generated
View File

@@ -56,7 +56,9 @@
"axios": "^1.6.2", "axios": "^1.6.2",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cors": "^2.8.5",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"express": "^4.18.2",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",