mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-05 06:23:22 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bdd0b5830 | ||
|
|
98cadb1ff1 | ||
|
|
42a6b7e19c | ||
|
|
e35f96d30f | ||
|
|
08f82bc795 | ||
|
|
c497c1db2a | ||
|
|
5b7e7216e8 | ||
|
|
fe5fb92e48 | ||
|
|
c8d54facb9 | ||
|
|
f97b300158 | ||
|
|
17ffa48158 | ||
|
|
16821d6b5e |
@@ -6,7 +6,7 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
|
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
|
||||||
API_VERSION="v1"
|
API_VERSION="v1"
|
||||||
AGENT_VERSION="1.2.3"
|
AGENT_VERSION="1.2.4"
|
||||||
CONFIG_FILE="/etc/patchmon/agent.conf"
|
CONFIG_FILE="/etc/patchmon/agent.conf"
|
||||||
CREDENTIALS_FILE="/etc/patchmon/credentials"
|
CREDENTIALS_FILE="/etc/patchmon/credentials"
|
||||||
LOG_FILE="/var/log/patchmon-agent.log"
|
LOG_FILE="/var/log/patchmon-agent.log"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-backend",
|
"name": "patchmon-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.2.4",
|
||||||
"description": "Backend API for Linux Patch Monitoring System",
|
"description": "Backend API for Linux Patch Monitoring System",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "ssh_key_path" TEXT;
|
||||||
@@ -180,6 +180,7 @@ model Settings {
|
|||||||
updateInterval Int @map("update_interval") @default(60) // Update interval in minutes
|
updateInterval Int @map("update_interval") @default(60) // Update interval in minutes
|
||||||
autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates
|
autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates
|
||||||
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
|
||||||
|
sshKeyPath String? @map("ssh_key_path") // Optional SSH key path for deploy key authentication
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,16 @@ router.put('/', authenticateToken, requireManageSettings, [
|
|||||||
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
|
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
|
||||||
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
|
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
|
||||||
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
|
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
|
||||||
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string')
|
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'),
|
||||||
|
body('sshKeyPath').optional().custom((value) => {
|
||||||
|
if (value && value.trim().length === 0) {
|
||||||
|
return true; // Allow empty string
|
||||||
|
}
|
||||||
|
if (value && value.trim().length < 1) {
|
||||||
|
throw new Error('SSH key path must be a non-empty string');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log('Settings update request body:', req.body);
|
console.log('Settings update request body:', req.body);
|
||||||
@@ -133,8 +142,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
|||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl } = req.body;
|
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath } = req.body;
|
||||||
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl });
|
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath });
|
||||||
|
|
||||||
// Construct server URL from components
|
// Construct server URL from components
|
||||||
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
|
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
|
||||||
@@ -165,7 +174,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
|||||||
frontendUrl,
|
frontendUrl,
|
||||||
updateInterval: updateInterval || 60,
|
updateInterval: updateInterval || 60,
|
||||||
autoUpdate: autoUpdate || false,
|
autoUpdate: autoUpdate || false,
|
||||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||||
|
sshKeyPath: sshKeyPath || null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log('Settings updated successfully:', settings);
|
console.log('Settings updated successfully:', settings);
|
||||||
@@ -186,7 +196,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
|||||||
frontendUrl,
|
frontendUrl,
|
||||||
updateInterval: updateInterval || 60,
|
updateInterval: updateInterval || 60,
|
||||||
autoUpdate: autoUpdate || false,
|
autoUpdate: autoUpdate || false,
|
||||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||||
|
sshKeyPath: sshKeyPath || null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
309
backend/src/routes/versionRoutes.js
Normal file
309
backend/src/routes/versionRoutes.js
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const { requireManageSettings } = require('../middleware/permissions');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get current version info
|
||||||
|
router.get('/current', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// For now, return hardcoded version - this should match your agent version
|
||||||
|
const currentVersion = '1.2.4';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
version: currentVersion,
|
||||||
|
buildDate: new Date().toISOString(),
|
||||||
|
environment: process.env.NODE_ENV || 'development'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting current version:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get current version' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test SSH key permissions and GitHub access
|
||||||
|
router.post('/test-ssh-key', authenticateToken, requireManageSettings, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sshKeyPath, githubRepoUrl } = req.body;
|
||||||
|
|
||||||
|
if (!sshKeyPath || !githubRepoUrl) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'SSH key path and GitHub repo URL are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse repository info
|
||||||
|
let owner, repo;
|
||||||
|
if (githubRepoUrl.includes('git@github.com:')) {
|
||||||
|
const match = githubRepoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
|
||||||
|
if (match) {
|
||||||
|
[, owner, repo] = match;
|
||||||
|
}
|
||||||
|
} else if (githubRepoUrl.includes('github.com/')) {
|
||||||
|
const match = githubRepoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||||
|
if (match) {
|
||||||
|
[, owner, repo] = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!owner || !repo) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid GitHub repository URL format'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SSH key file exists and is readable
|
||||||
|
try {
|
||||||
|
require('fs').accessSync(sshKeyPath);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'SSH key file not found or not accessible',
|
||||||
|
details: `Cannot access: ${sshKeyPath}`,
|
||||||
|
suggestion: 'Check the file path and ensure the application has read permissions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test SSH connection to GitHub
|
||||||
|
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o ConnectTimeout=10`
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test with a simple git command
|
||||||
|
const { stdout } = await execAsync(
|
||||||
|
`git ls-remote --heads ${sshRepoUrl} | head -n 1`,
|
||||||
|
{
|
||||||
|
timeout: 15000,
|
||||||
|
env: env
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stdout.trim()) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'SSH key is working correctly',
|
||||||
|
details: {
|
||||||
|
sshKeyPath,
|
||||||
|
repository: `${owner}/${repo}`,
|
||||||
|
testResult: 'Successfully connected to GitHub'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'SSH connection succeeded but no data returned',
|
||||||
|
suggestion: 'Check repository access permissions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (sshError) {
|
||||||
|
console.error('SSH test error:', sshError.message);
|
||||||
|
|
||||||
|
if (sshError.message.includes('Permission denied')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'SSH key permission denied',
|
||||||
|
details: 'The SSH key exists but GitHub rejected the connection',
|
||||||
|
suggestion: 'Verify the SSH key is added to the repository as a deploy key with read access'
|
||||||
|
});
|
||||||
|
} else if (sshError.message.includes('Host key verification failed')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Host key verification failed',
|
||||||
|
suggestion: 'This is normal for first-time connections. The key will be added to known_hosts automatically.'
|
||||||
|
});
|
||||||
|
} else if (sshError.message.includes('Connection timed out')) {
|
||||||
|
return res.status(408).json({
|
||||||
|
error: 'Connection timed out',
|
||||||
|
suggestion: 'Check your internet connection and GitHub status'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'SSH connection failed',
|
||||||
|
details: sshError.message,
|
||||||
|
suggestion: 'Check the SSH key format and repository URL'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSH key test error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to test SSH key',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for updates from GitHub
|
||||||
|
router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get GitHub repo URL from settings
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!owner || !repo) {
|
||||||
|
return res.status(400).json({ error: 'Invalid GitHub repository URL format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use SSH with deploy keys (secure approach)
|
||||||
|
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: latestTag } = await execAsync(
|
||||||
|
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
|
||||||
|
{
|
||||||
|
timeout: 10000,
|
||||||
|
env: env
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
latestRelease: {
|
||||||
|
tagName: latestTag.trim(),
|
||||||
|
version: latestVersion,
|
||||||
|
repository: `${owner}/${repo}`,
|
||||||
|
sshUrl: sshRepoUrl,
|
||||||
|
sshKeyUsed: sshKeyPath
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (sshError) {
|
||||||
|
console.error('SSH Git error:', sshError.message);
|
||||||
|
|
||||||
|
if (sshError.message.includes('Permission denied') || sshError.message.includes('Host key verification failed')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'SSH access denied to repository',
|
||||||
|
suggestion: 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sshError.message.includes('not found') || sshError.message.includes('does not exist')) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Repository not found',
|
||||||
|
suggestion: 'Check that the repository URL is correct and accessible with the deploy key.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sshError.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/'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to fetch repository information',
|
||||||
|
details: sshError.message,
|
||||||
|
suggestion: 'Check deploy key configuration and repository access permissions.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for updates:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to check for updates',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple version comparison function
|
||||||
|
function 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -16,6 +16,7 @@ const permissionsRoutes = require('./routes/permissionsRoutes');
|
|||||||
const settingsRoutes = require('./routes/settingsRoutes');
|
const settingsRoutes = require('./routes/settingsRoutes');
|
||||||
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
|
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
|
||||||
const repositoryRoutes = require('./routes/repositoryRoutes');
|
const repositoryRoutes = require('./routes/repositoryRoutes');
|
||||||
|
const versionRoutes = require('./routes/versionRoutes');
|
||||||
|
|
||||||
// Initialize Prisma client
|
// Initialize Prisma client
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -134,6 +135,7 @@ app.use(`/api/${apiVersion}/permissions`, permissionsRoutes);
|
|||||||
app.use(`/api/${apiVersion}/settings`, settingsRoutes);
|
app.use(`/api/${apiVersion}/settings`, settingsRoutes);
|
||||||
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
|
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
|
||||||
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
|
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
|
||||||
|
app.use(`/api/${apiVersion}/version`, versionRoutes);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-frontend",
|
"name": "patchmon-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.2.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 } from 'lucide-react';
|
||||||
import { settingsAPI, agentVersionAPI } from '../utils/api';
|
import { settingsAPI, agentVersionAPI, versionAPI } from '../utils/api';
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -11,7 +11,9 @@ const Settings = () => {
|
|||||||
frontendUrl: 'http://localhost:3000',
|
frontendUrl: 'http://localhost:3000',
|
||||||
updateInterval: 60,
|
updateInterval: 60,
|
||||||
autoUpdate: false,
|
autoUpdate: false,
|
||||||
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git'
|
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
|
||||||
|
sshKeyPath: '',
|
||||||
|
useCustomSshKey: false
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
@@ -37,6 +39,22 @@ const Settings = () => {
|
|||||||
isDefault: false
|
isDefault: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Version checking state
|
||||||
|
const [versionInfo, setVersionInfo] = useState({
|
||||||
|
currentVersion: '1.2.4',
|
||||||
|
latestVersion: null,
|
||||||
|
isUpdateAvailable: false,
|
||||||
|
checking: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sshTestResult, setSshTestResult] = useState({
|
||||||
|
testing: false,
|
||||||
|
success: null,
|
||||||
|
message: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Fetch current settings
|
// Fetch current settings
|
||||||
@@ -57,7 +75,9 @@ const Settings = () => {
|
|||||||
frontendUrl: settings.frontendUrl || 'http://localhost:3000',
|
frontendUrl: settings.frontendUrl || 'http://localhost:3000',
|
||||||
updateInterval: settings.updateInterval || 60,
|
updateInterval: settings.updateInterval || 60,
|
||||||
autoUpdate: settings.autoUpdate || false,
|
autoUpdate: settings.autoUpdate || false,
|
||||||
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||||
|
sshKeyPath: settings.sshKeyPath || '',
|
||||||
|
useCustomSshKey: !!settings.sshKeyPath
|
||||||
};
|
};
|
||||||
console.log('Setting form data to:', newFormData);
|
console.log('Setting form data to:', newFormData);
|
||||||
setFormData(newFormData);
|
setFormData(newFormData);
|
||||||
@@ -80,12 +100,15 @@ const Settings = () => {
|
|||||||
queryClient.invalidateQueries(['settings']);
|
queryClient.invalidateQueries(['settings']);
|
||||||
// Update form data with the returned data
|
// Update form data with the returned data
|
||||||
setFormData({
|
setFormData({
|
||||||
serverProtocol: data.serverProtocol || 'http',
|
serverProtocol: data.settings?.serverProtocol || data.serverProtocol || 'http',
|
||||||
serverHost: data.serverHost || 'localhost',
|
serverHost: data.settings?.serverHost || data.serverHost || 'localhost',
|
||||||
serverPort: data.serverPort || 3001,
|
serverPort: data.settings?.serverPort || data.serverPort || 3001,
|
||||||
frontendUrl: data.frontendUrl || 'http://localhost:3000',
|
frontendUrl: data.settings?.frontendUrl || data.frontendUrl || 'http://localhost:3000',
|
||||||
updateInterval: data.updateInterval || 60,
|
updateInterval: data.settings?.updateInterval || data.updateInterval || 60,
|
||||||
autoUpdate: data.autoUpdate || false
|
autoUpdate: data.settings?.autoUpdate || data.autoUpdate || false,
|
||||||
|
githubRepoUrl: data.settings?.githubRepoUrl || data.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||||
|
sshKeyPath: data.settings?.sshKeyPath || data.sshKeyPath || '',
|
||||||
|
useCustomSshKey: !!(data.settings?.sshKeyPath || data.sshKeyPath)
|
||||||
});
|
});
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
@@ -152,6 +175,67 @@ const Settings = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Version checking functions
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
setVersionInfo(prev => ({ ...prev, checking: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await versionAPI.checkUpdates();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
setVersionInfo({
|
||||||
|
currentVersion: data.currentVersion,
|
||||||
|
latestVersion: data.latestVersion,
|
||||||
|
isUpdateAvailable: data.isUpdateAvailable,
|
||||||
|
checking: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Version check error:', error);
|
||||||
|
setVersionInfo(prev => ({
|
||||||
|
...prev,
|
||||||
|
checking: false,
|
||||||
|
error: error.response?.data?.error || 'Failed to check for updates'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testSshKey = async () => {
|
||||||
|
if (!formData.sshKeyPath || !formData.githubRepoUrl) {
|
||||||
|
setSshTestResult({
|
||||||
|
testing: false,
|
||||||
|
success: false,
|
||||||
|
message: null,
|
||||||
|
error: 'Please enter both SSH key path and GitHub repository URL'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSshTestResult({ testing: true, success: null, message: null, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await versionAPI.testSshKey({
|
||||||
|
sshKeyPath: formData.sshKeyPath,
|
||||||
|
githubRepoUrl: formData.githubRepoUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
setSshTestResult({
|
||||||
|
testing: false,
|
||||||
|
success: true,
|
||||||
|
message: response.data.message,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSH key test error:', error);
|
||||||
|
setSshTestResult({
|
||||||
|
testing: false,
|
||||||
|
success: false,
|
||||||
|
message: null,
|
||||||
|
error: error.response?.data?.error || 'Failed to test SSH key'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleInputChange = (field, value) => {
|
const handleInputChange = (field, value) => {
|
||||||
console.log(`handleInputChange: ${field} = ${value}`);
|
console.log(`handleInputChange: ${field} = ${value}`);
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
@@ -167,7 +251,16 @@ const Settings = () => {
|
|||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
updateSettingsMutation.mutate(formData);
|
|
||||||
|
// Only include sshKeyPath if the toggle is enabled
|
||||||
|
const dataToSubmit = { ...formData };
|
||||||
|
if (!dataToSubmit.useCustomSshKey) {
|
||||||
|
dataToSubmit.sshKeyPath = '';
|
||||||
|
}
|
||||||
|
// Remove the frontend-only field
|
||||||
|
delete dataToSubmit.useCustomSshKey;
|
||||||
|
|
||||||
|
updateSettingsMutation.mutate(dataToSubmit);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
@@ -199,7 +292,16 @@ const Settings = () => {
|
|||||||
console.log('Saving settings:', formData);
|
console.log('Saving settings:', formData);
|
||||||
if (validateForm()) {
|
if (validateForm()) {
|
||||||
console.log('Validation passed, calling mutation');
|
console.log('Validation passed, calling mutation');
|
||||||
updateSettingsMutation.mutate(formData);
|
|
||||||
|
// Prepare data for submission
|
||||||
|
const dataToSubmit = { ...formData };
|
||||||
|
if (!dataToSubmit.useCustomSshKey) {
|
||||||
|
dataToSubmit.sshKeyPath = '';
|
||||||
|
}
|
||||||
|
// Remove the frontend-only field
|
||||||
|
delete dataToSubmit.useCustomSshKey;
|
||||||
|
|
||||||
|
updateSettingsMutation.mutate(dataToSubmit);
|
||||||
} else {
|
} else {
|
||||||
console.log('Validation failed:', errors);
|
console.log('Validation failed:', errors);
|
||||||
}
|
}
|
||||||
@@ -688,13 +790,91 @@ const Settings = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="useCustomSshKey"
|
||||||
|
checked={formData.useCustomSshKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
handleInputChange('useCustomSshKey', checked);
|
||||||
|
if (!checked) {
|
||||||
|
handleInputChange('sshKeyPath', '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="useCustomSshKey" className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||||
|
Set custom SSH key path
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.useCustomSshKey && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||||
|
SSH Key Path
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.sshKeyPath || ''}
|
||||||
|
onChange={(e) => handleInputChange('sshKeyPath', e.target.value)}
|
||||||
|
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="/root/.ssh/id_ed25519"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Path to your SSH deploy key. If not set, will auto-detect from common locations.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={testSshKey}
|
||||||
|
disabled={sshTestResult.testing || !formData.sshKeyPath || !formData.githubRepoUrl}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{sshTestResult.testing ? 'Testing...' : 'Test SSH Key'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{sshTestResult.success && (
|
||||||
|
<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
|
||||||
|
<p className="text-sm text-green-800 dark:text-green-200">
|
||||||
|
{sshTestResult.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sshTestResult.error && (
|
||||||
|
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mr-2" />
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200">
|
||||||
|
{sshTestResult.error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!formData.useCustomSshKey && (
|
||||||
|
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Using auto-detection for SSH key location
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
<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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version</span>
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">1.2.3</span>
|
<span className="text-lg font-mono text-secondary-900 dark:text-white">{versionInfo.currentVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||||
@@ -702,52 +882,91 @@ const Settings = () => {
|
|||||||
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
<Download 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">Latest Version</span>
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Latest Version</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">Checking...</span>
|
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||||
|
{versionInfo.checking ? (
|
||||||
|
<span className="text-blue-600 dark:text-blue-400">Checking...</span>
|
||||||
|
) : versionInfo.latestVersion ? (
|
||||||
|
<span className={versionInfo.isUpdateAvailable ? 'text-orange-600 dark:text-orange-400' : 'text-green-600 dark:text-green-400'}>
|
||||||
|
{versionInfo.latestVersion}
|
||||||
|
{versionInfo.isUpdateAvailable && ' (Update Available!)'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary-500 dark:text-secondary-400">Not checked</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={checkForUpdates}
|
||||||
// TODO: Implement version check
|
disabled={versionInfo.checking}
|
||||||
console.log('Checking for updates...');
|
|
||||||
}}
|
|
||||||
className="btn-primary flex items-center gap-2"
|
className="btn-primary flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Check for Updates
|
{versionInfo.checking ? 'Checking...' : 'Check for Updates'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button for Version Settings */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
// TODO: Implement update notification
|
onClick={handleSave}
|
||||||
console.log('Enable update notifications');
|
disabled={!isDirty || updateSettingsMutation.isPending}
|
||||||
}}
|
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
|
||||||
className="btn-outline flex items-center gap-2"
|
!isDirty || updateSettingsMutation.isPending
|
||||||
|
? 'bg-secondary-400 cursor-not-allowed'
|
||||||
|
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-4 w-4" />
|
{updateSettingsMutation.isPending ? (
|
||||||
Enable Notifications
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{versionInfo.error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Version Check Failed</h3>
|
||||||
|
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{versionInfo.error}
|
||||||
|
</p>
|
||||||
|
{versionInfo.error.includes('private') && (
|
||||||
|
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||||
|
For private repositories, you may need to configure GitHub authentication or make the repository public.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Message for Version Settings */}
|
||||||
|
{updateSettingsMutation.isSuccess && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-300">Settings saved successfully!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<AlertCircle className="h-5 w-5 text-amber-400 dark:text-amber-300" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">Setup Instructions</h3>
|
|
||||||
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
|
||||||
<p className="mb-2">To enable version checking, you need to:</p>
|
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-4">
|
|
||||||
<li>Create a version tag (e.g., v1.2.3) in your GitHub repository</li>
|
|
||||||
<li>Ensure the repository is publicly accessible or configure access tokens</li>
|
|
||||||
<li>Click "Check for Updates" to verify the connection</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ export const formatDate = (date) => {
|
|||||||
return new Date(date).toLocaleString()
|
return new Date(date).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Version API
|
||||||
|
export const versionAPI = {
|
||||||
|
getCurrent: () => api.get('/version/current'),
|
||||||
|
checkUpdates: () => api.get('/version/check-updates'),
|
||||||
|
testSshKey: (data) => api.post('/version/test-ssh-key', data),
|
||||||
|
}
|
||||||
|
|
||||||
export const formatRelativeTime = (date) => {
|
export const formatRelativeTime = (date) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diff = now - new Date(date)
|
const diff = now - new Date(date)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon",
|
"name": "patchmon",
|
||||||
"version": "1.0.0",
|
"version": "1.2.4",
|
||||||
"description": "Linux Patch Monitoring System",
|
"description": "Linux Patch Monitoring System",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
Reference in New Issue
Block a user