mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-12-20 12:29:58 -06:00
1. Staggered agent intervals - So if agent report is every 60 minutes then Host1 will be 10 past whilst another host might be 22 past the hour. This is so we don't overwhelm the PatchMon server when all reports are being sent at once.
2. Reboot detection information now stored - This is so that if "Reboot required flag" is showing then by hovering over it , it will show the reason 3. `patchmon-agent report --json` will output the whole body of data it would normally send to PatchMon , but it will output it in the console This is very useful so that PatchMon agent information can actually be used with other tools and show data to be diagnosed. 4. Mobile Ui mostly done 5. Fixed Some prisma version issue affecting Kubernetes deployments so I had to set prisma version statically in package json files 6. Persistent Docker toggle so no more in-memory configurations (thanks to the community for initiating it) 7. config.yml file to be written and compared with the configuration for that host upon startup for better sync of settings 8. Fixed where even if servers Agent auto-update was turned off then it would not honour this and auto-update anyway, the logic for this has been fixed to honour both Server-wide auto-update settings as well per-host 9. Improved network information page to show ipv6 and support multiple interfaces
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.3.6",
|
||||
"version": "1.3.7",
|
||||
"description": "Backend API for Linux Patch Monitoring System",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add docker_enabled field to hosts table
|
||||
-- This field persists the Docker integration enabled state across container restarts
|
||||
-- Fixes GitHub issue #352
|
||||
|
||||
ALTER TABLE "hosts" ADD COLUMN "docker_enabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add reboot_reason field to hosts table
|
||||
-- This field stores detailed technical information about why a reboot is required
|
||||
-- Includes kernel versions, detection method, and other relevant details
|
||||
|
||||
ALTER TABLE "hosts" ADD COLUMN "reboot_reason" TEXT;
|
||||
|
||||
@@ -112,6 +112,8 @@ model hosts {
|
||||
system_uptime String?
|
||||
notes String?
|
||||
needs_reboot Boolean? @default(false)
|
||||
reboot_reason String?
|
||||
docker_enabled Boolean @default(false)
|
||||
host_packages host_packages[]
|
||||
host_repositories host_repositories[]
|
||||
host_group_memberships host_group_memberships[]
|
||||
|
||||
@@ -21,6 +21,35 @@ const prisma = getPrismaClient();
|
||||
// This stores the last known state from successful toggles
|
||||
const integrationStateCache = new Map();
|
||||
|
||||
// Middleware to validate API credentials
|
||||
const validateApiCredentials = async (req, res, next) => {
|
||||
try {
|
||||
const apiId = req.headers["x-api-id"] || req.body.apiId;
|
||||
const apiKey = req.headers["x-api-key"] || req.body.apiKey;
|
||||
|
||||
if (!apiId || !apiKey) {
|
||||
return res.status(401).json({ error: "API ID and Key required" });
|
||||
}
|
||||
|
||||
const host = await prisma.hosts.findFirst({
|
||||
where: {
|
||||
api_id: apiId,
|
||||
api_key: apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(401).json({ error: "Invalid API credentials" });
|
||||
}
|
||||
|
||||
req.hostRecord = host;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("API credential validation error:", error);
|
||||
res.status(500).json({ error: "API credential validation failed" });
|
||||
}
|
||||
};
|
||||
|
||||
// Secure endpoint to download the agent script/binary (requires API authentication)
|
||||
router.get("/agent/download", async (req, res) => {
|
||||
try {
|
||||
@@ -130,11 +159,32 @@ router.get("/agent/download", async (req, res) => {
|
||||
});
|
||||
|
||||
// Version check endpoint for agents
|
||||
router.get("/agent/version", async (req, res) => {
|
||||
router.get("/agent/version", validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Check general server auto_update setting
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const serverAutoUpdateEnabled = settings?.auto_update;
|
||||
|
||||
// Check per-host auto_update setting (req.hostRecord is set by validateApiCredentials middleware)
|
||||
const host = req.hostRecord;
|
||||
const hostAutoUpdateEnabled = host?.auto_update;
|
||||
|
||||
// Determine if auto-update is disabled
|
||||
const autoUpdateDisabled =
|
||||
!serverAutoUpdateEnabled || !hostAutoUpdateEnabled;
|
||||
let autoUpdateDisabledReason = null;
|
||||
if (!serverAutoUpdateEnabled && !hostAutoUpdateEnabled) {
|
||||
autoUpdateDisabledReason =
|
||||
"Auto-update is disabled in server settings and for this host";
|
||||
} else if (!serverAutoUpdateEnabled) {
|
||||
autoUpdateDisabledReason = "Auto-update is disabled in server settings";
|
||||
} else if (!hostAutoUpdateEnabled) {
|
||||
autoUpdateDisabledReason = "Auto-update is disabled for this host";
|
||||
}
|
||||
|
||||
// Get architecture parameter (default to amd64 for Go agents)
|
||||
const architecture = req.query.arch || "amd64";
|
||||
const agentType = req.query.type || "go"; // "go" or "legacy"
|
||||
@@ -163,9 +213,16 @@ router.get("/agent/version", async (req, res) => {
|
||||
|
||||
res.json({
|
||||
currentVersion: currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
hasUpdate: false,
|
||||
autoUpdateDisabled: autoUpdateDisabled,
|
||||
autoUpdateDisabledReason: autoUpdateDisabled
|
||||
? autoUpdateDisabledReason
|
||||
: null,
|
||||
downloadUrl: `/api/v1/hosts/agent/download`,
|
||||
releaseNotes: `PatchMon Agent v${currentVersion}`,
|
||||
minServerVersion: null,
|
||||
agentType: "legacy",
|
||||
});
|
||||
} else {
|
||||
// Go agent version check
|
||||
@@ -235,10 +292,15 @@ router.get("/agent/version", async (req, res) => {
|
||||
// Proper semantic version comparison: only update if server version is NEWER
|
||||
const hasUpdate = compareVersions(serverVersion, agentVersion) > 0;
|
||||
|
||||
// Return update info, but indicate if auto-update is disabled
|
||||
return res.json({
|
||||
currentVersion: agentVersion,
|
||||
latestVersion: serverVersion,
|
||||
hasUpdate: hasUpdate,
|
||||
hasUpdate: hasUpdate && !autoUpdateDisabled, // Only true if update available AND auto-update enabled
|
||||
autoUpdateDisabled: autoUpdateDisabled,
|
||||
autoUpdateDisabledReason: autoUpdateDisabled
|
||||
? autoUpdateDisabledReason
|
||||
: null,
|
||||
downloadUrl: `/api/v1/hosts/agent/download?arch=${architecture}`,
|
||||
releaseNotes: `PatchMon Agent v${serverVersion}`,
|
||||
minServerVersion: null,
|
||||
@@ -261,6 +323,10 @@ router.get("/agent/version", async (req, res) => {
|
||||
currentVersion: agentVersion,
|
||||
latestVersion: null,
|
||||
hasUpdate: false,
|
||||
autoUpdateDisabled: autoUpdateDisabled,
|
||||
autoUpdateDisabledReason: autoUpdateDisabled
|
||||
? autoUpdateDisabledReason
|
||||
: null,
|
||||
architecture: architecture,
|
||||
agentType: "go",
|
||||
});
|
||||
@@ -278,35 +344,6 @@ const generateApiCredentials = () => {
|
||||
return { apiId, apiKey };
|
||||
};
|
||||
|
||||
// Middleware to validate API credentials
|
||||
const validateApiCredentials = async (req, res, next) => {
|
||||
try {
|
||||
const apiId = req.headers["x-api-id"] || req.body.apiId;
|
||||
const apiKey = req.headers["x-api-key"] || req.body.apiKey;
|
||||
|
||||
if (!apiId || !apiKey) {
|
||||
return res.status(401).json({ error: "API ID and Key required" });
|
||||
}
|
||||
|
||||
const host = await prisma.hosts.findFirst({
|
||||
where: {
|
||||
api_id: apiId,
|
||||
api_key: apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(401).json({ error: "Invalid API credentials" });
|
||||
}
|
||||
|
||||
req.hostRecord = host;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("API credential validation error:", error);
|
||||
res.status(500).json({ error: "API credential validation failed" });
|
||||
}
|
||||
};
|
||||
|
||||
// Admin endpoint to create a new host manually (replaces auto-registration)
|
||||
router.post(
|
||||
"/create",
|
||||
@@ -324,6 +361,10 @@ router.post(
|
||||
.optional()
|
||||
.isUUID()
|
||||
.withMessage("Each host group ID must be a valid UUID"),
|
||||
body("docker_enabled")
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage("Docker enabled must be a boolean"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
@@ -332,7 +373,7 @@ router.post(
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { friendly_name, hostGroupIds } = req.body;
|
||||
const { friendly_name, hostGroupIds, docker_enabled } = req.body;
|
||||
|
||||
// Generate unique API credentials for this host
|
||||
const { apiId, apiKey } = generateApiCredentials();
|
||||
@@ -363,6 +404,7 @@ router.post(
|
||||
api_id: apiId,
|
||||
api_key: apiKey,
|
||||
status: "pending", // Will change to 'active' when agent connects
|
||||
docker_enabled: docker_enabled ?? false, // Set integration state if provided
|
||||
updated_at: new Date(),
|
||||
// Create host group memberships if hostGroupIds are provided
|
||||
host_group_memberships:
|
||||
@@ -602,6 +644,8 @@ router.post(
|
||||
// Reboot Status
|
||||
if (req.body.needsReboot !== undefined)
|
||||
updateData.needs_reboot = req.body.needsReboot;
|
||||
if (req.body.rebootReason !== undefined)
|
||||
updateData.reboot_reason = req.body.rebootReason;
|
||||
|
||||
// If this is the first update (status is 'pending'), change to 'active'
|
||||
if (host.status === "pending") {
|
||||
@@ -875,6 +919,38 @@ router.get("/info", validateApiCredentials, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get integration status for agent (uses API credentials)
|
||||
router.get("/integrations", validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { id: req.hostRecord.id },
|
||||
select: {
|
||||
id: true,
|
||||
docker_enabled: true,
|
||||
// Future: add other integration fields here
|
||||
},
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
// Return integration states from database (source of truth)
|
||||
const integrations = {
|
||||
docker: host.docker_enabled ?? false,
|
||||
// Future integrations can be added here
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
integrations: integrations,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Get integration status error:", error);
|
||||
res.status(500).json({ error: "Failed to get integration status" });
|
||||
}
|
||||
});
|
||||
|
||||
// Ping endpoint for health checks (now uses API credentials)
|
||||
router.post("/ping", validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
@@ -915,6 +991,13 @@ router.post("/ping", validateApiCredentials, async (req, res) => {
|
||||
agentStartup: isStartup,
|
||||
};
|
||||
|
||||
// Include integration states in ping response for initial agent configuration
|
||||
// This allows agent to sync config.yml with database state during setup
|
||||
response.integrations = {
|
||||
docker: req.hostRecord.docker_enabled ?? false,
|
||||
// Future integrations can be added here
|
||||
};
|
||||
|
||||
// Check if this is a crontab update trigger
|
||||
if (req.body.triggerCrontabUpdate && req.hostRecord.auto_update) {
|
||||
console.log(
|
||||
@@ -1589,12 +1672,14 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
// Add job to queue
|
||||
// Add job to queue with bypass_settings flag for true force updates
|
||||
// This allows the force endpoint to bypass auto_update settings
|
||||
const job = await queue.add(
|
||||
"update_agent",
|
||||
{
|
||||
api_id: host.api_id,
|
||||
type: "update_agent",
|
||||
bypass_settings: true, // Force endpoint bypasses settings
|
||||
},
|
||||
{
|
||||
attempts: 3,
|
||||
@@ -2149,7 +2234,12 @@ router.get(
|
||||
// Get host to verify it exists
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
select: { id: true, api_id: true, friendly_name: true },
|
||||
select: {
|
||||
id: true,
|
||||
api_id: true,
|
||||
friendly_name: true,
|
||||
docker_enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
@@ -2159,11 +2249,11 @@ router.get(
|
||||
// Check if agent is connected
|
||||
const connected = isConnected(host.api_id);
|
||||
|
||||
// Get integration states from cache (or defaults if not cached)
|
||||
// Default: all integrations are disabled
|
||||
// Get integration states from database (persisted) with cache fallback
|
||||
// Database is source of truth, cache is used for quick WebSocket lookups
|
||||
const cachedState = integrationStateCache.get(host.api_id) || {};
|
||||
const integrations = {
|
||||
docker: cachedState.docker || false, // Default: disabled
|
||||
docker: host.docker_enabled ?? cachedState.docker ?? false,
|
||||
// Future integrations can be added here
|
||||
};
|
||||
|
||||
@@ -2214,7 +2304,12 @@ router.post(
|
||||
// Get host to verify it exists
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
select: { id: true, api_id: true, friendly_name: true },
|
||||
select: {
|
||||
id: true,
|
||||
api_id: true,
|
||||
friendly_name: true,
|
||||
docker_enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
@@ -2244,7 +2339,15 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
// Update cache with new state
|
||||
// Persist integration state to database
|
||||
if (integrationName === "docker") {
|
||||
await prisma.hosts.update({
|
||||
where: { id: hostId },
|
||||
data: { docker_enabled: enabled },
|
||||
});
|
||||
}
|
||||
|
||||
// Update cache with new state (for quick WebSocket lookups)
|
||||
if (!integrationStateCache.has(host.api_id)) {
|
||||
integrationStateCache.set(host.api_id, {});
|
||||
}
|
||||
|
||||
@@ -623,6 +623,49 @@ class AgentVersionService {
|
||||
`🔍 Checking update for agent ${agentApiId} (version: ${agentVersion})`,
|
||||
);
|
||||
|
||||
// Check general server auto_update setting
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const prisma = getPrismaClient();
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (!settings || !settings.auto_update) {
|
||||
console.log(
|
||||
`⚠️ Auto-update is disabled in server settings, skipping update check for agent ${agentApiId}`,
|
||||
);
|
||||
return {
|
||||
needsUpdate: false,
|
||||
reason: "auto-update-disabled-server",
|
||||
message: "Auto-update is disabled in server settings",
|
||||
};
|
||||
}
|
||||
|
||||
// Check per-host auto_update setting
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { api_id: agentApiId },
|
||||
select: { auto_update: true },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
console.log(
|
||||
`⚠️ Host not found for agent ${agentApiId}, skipping update check`,
|
||||
);
|
||||
return {
|
||||
needsUpdate: false,
|
||||
reason: "host-not-found",
|
||||
message: "Host not found",
|
||||
};
|
||||
}
|
||||
|
||||
if (!host.auto_update) {
|
||||
console.log(
|
||||
`⚠️ Auto-update is disabled for host ${agentApiId}, skipping update check`,
|
||||
);
|
||||
return {
|
||||
needsUpdate: false,
|
||||
reason: "auto-update-disabled-host",
|
||||
message: "Auto-update is disabled for this host",
|
||||
};
|
||||
}
|
||||
|
||||
// Get current server version info
|
||||
const versionInfo = await this.getVersionInfo();
|
||||
|
||||
@@ -712,6 +755,22 @@ class AgentVersionService {
|
||||
`🔍 Checking updates for all connected agents (force: ${force})`,
|
||||
);
|
||||
|
||||
// Check general server auto_update setting
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const prisma = getPrismaClient();
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (!settings || !settings.auto_update) {
|
||||
console.log(
|
||||
`⚠️ Auto-update is disabled in server settings, skipping bulk update check`,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: "Auto-update is disabled in server settings",
|
||||
updatedAgents: 0,
|
||||
totalAgents: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Import agentWs service to get connected agents
|
||||
const { pushUpdateNotificationToAll } = require("./agentWs");
|
||||
|
||||
|
||||
@@ -310,9 +310,37 @@ function pushUpdateNotification(apiId, updateInfo) {
|
||||
async function pushUpdateNotificationToAll(updateInfo) {
|
||||
let notifiedCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Get all hosts with their auto_update settings
|
||||
const hosts = await prisma.hosts.findMany({
|
||||
where: {
|
||||
api_id: { in: Array.from(apiIdToSocket.keys()) },
|
||||
},
|
||||
select: {
|
||||
api_id: true,
|
||||
auto_update: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a map for quick lookup
|
||||
const hostAutoUpdateMap = new Map();
|
||||
for (const host of hosts) {
|
||||
hostAutoUpdateMap.set(host.api_id, host.auto_update);
|
||||
}
|
||||
|
||||
for (const [apiId, ws] of apiIdToSocket) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
// Check per-host auto_update setting
|
||||
const hostAutoUpdate = hostAutoUpdateMap.get(apiId);
|
||||
if (hostAutoUpdate === false) {
|
||||
skippedCount++;
|
||||
console.log(
|
||||
`⚠️ Skipping update notification for agent ${apiId} (auto-update disabled for host)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
safeSend(
|
||||
ws,
|
||||
@@ -336,10 +364,11 @@ async function pushUpdateNotificationToAll(updateInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
const totalAgents = apiIdToSocket.size;
|
||||
console.log(
|
||||
`📤 Update notification sent to ${notifiedCount} agents, ${failedCount} failed`,
|
||||
`📤 Update notification sent to ${notifiedCount} agents, ${failedCount} failed, ${skippedCount} skipped (auto-update disabled)`,
|
||||
);
|
||||
return { notifiedCount, failedCount };
|
||||
return { notifiedCount, failedCount, skippedCount, totalAgents };
|
||||
}
|
||||
|
||||
// Notify all subscribers when connection status changes
|
||||
|
||||
@@ -250,6 +250,40 @@ class QueueManager {
|
||||
const { update_interval } = job.data;
|
||||
agentWs.pushSettingsUpdate(api_id, update_interval);
|
||||
} else if (type === "update_agent") {
|
||||
// Check if bypass_settings flag is set (for true force updates)
|
||||
const bypassSettings = job.data.bypass_settings === true;
|
||||
|
||||
if (!bypassSettings) {
|
||||
// Check general server auto_update setting
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (!settings || !settings.auto_update) {
|
||||
console.log(
|
||||
`⚠️ Auto-update is disabled in server settings, skipping update_agent command for agent ${api_id}`,
|
||||
);
|
||||
throw new Error("Auto-update is disabled in server settings");
|
||||
}
|
||||
|
||||
// Check per-host auto_update setting
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { api_id: api_id },
|
||||
select: { auto_update: true },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
console.log(
|
||||
`⚠️ Host not found for agent ${api_id}, skipping update_agent command`,
|
||||
);
|
||||
throw new Error("Host not found");
|
||||
}
|
||||
|
||||
if (!host.auto_update) {
|
||||
console.log(
|
||||
`⚠️ Auto-update is disabled for host ${api_id}, skipping update_agent command`,
|
||||
);
|
||||
throw new Error("Auto-update is disabled for this host");
|
||||
}
|
||||
}
|
||||
|
||||
// Force agent to update by sending WebSocket command
|
||||
const ws = agentWs.getConnectionByApiId(api_id);
|
||||
if (ws && ws.readyState === 1) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "patchmon-frontend",
|
||||
"private": true,
|
||||
"version": "1.3.6",
|
||||
"version": "1.3.7",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -496,7 +496,10 @@ const HostDetail = () => {
|
||||
{getStatusText(isStale, host.stats.outdated_packages > 0)}
|
||||
</div>
|
||||
{host.needs_reboot && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
||||
title={host.reboot_reason || "Reboot required"}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reboot Required
|
||||
</span>
|
||||
@@ -842,53 +845,30 @@ const HostDetail = () => {
|
||||
</div>
|
||||
|
||||
{/* Network Card */}
|
||||
{(host.ip ||
|
||||
host.gateway_ip ||
|
||||
host.dns_servers ||
|
||||
host.network_interfaces) && (
|
||||
{(host.dns_servers || host.network_interfaces) && (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Wifi className="h-5 w-5 text-primary-600" />
|
||||
Network
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{host.ip && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
IP Address
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
|
||||
{host.ip}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{host.gateway_ip && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Gateway IP
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
|
||||
{host.gateway_ip}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{host.dns_servers &&
|
||||
Array.isArray(host.dns_servers) &&
|
||||
host.dns_servers.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-2">
|
||||
DNS Servers
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{host.dns_servers.map((dns) => (
|
||||
<p
|
||||
<div
|
||||
key={dns}
|
||||
className="font-medium text-secondary-900 dark:text-white font-mono text-sm"
|
||||
className="bg-secondary-50 dark:bg-secondary-700 p-2 rounded border border-secondary-200 dark:border-secondary-600"
|
||||
>
|
||||
{dns}
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
|
||||
{dns}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -898,17 +878,125 @@ const HostDetail = () => {
|
||||
Array.isArray(host.network_interfaces) &&
|
||||
host.network_interfaces.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-3">
|
||||
Network Interfaces
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-4">
|
||||
{host.network_interfaces.map((iface) => (
|
||||
<p
|
||||
<div
|
||||
key={iface.name}
|
||||
className="font-medium text-secondary-900 dark:text-white text-sm"
|
||||
className="border border-secondary-200 dark:border-secondary-700 rounded-lg p-3 bg-secondary-50 dark:bg-secondary-900/50"
|
||||
>
|
||||
{iface.name}
|
||||
</p>
|
||||
{/* Interface Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-secondary-900 dark:text-white text-sm">
|
||||
{iface.name}
|
||||
</p>
|
||||
{iface.type && (
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 bg-secondary-200 dark:bg-secondary-700 px-2 py-0.5 rounded">
|
||||
{iface.type}
|
||||
</span>
|
||||
)}
|
||||
{iface.status && (
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||
iface.status === "up"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{iface.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interface Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs mb-3">
|
||||
{iface.macAddress && (
|
||||
<div>
|
||||
<p className="text-secondary-500 dark:text-secondary-400 mb-0.5">
|
||||
MAC Address
|
||||
</p>
|
||||
<p className="font-mono text-secondary-900 dark:text-white">
|
||||
{iface.macAddress}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{iface.mtu && (
|
||||
<div>
|
||||
<p className="text-secondary-500 dark:text-secondary-400 mb-0.5">
|
||||
MTU
|
||||
</p>
|
||||
<p className="text-secondary-900 dark:text-white">
|
||||
{iface.mtu}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{iface.linkSpeed && iface.linkSpeed > 0 && (
|
||||
<div>
|
||||
<p className="text-secondary-500 dark:text-secondary-400 mb-0.5">
|
||||
Link Speed
|
||||
</p>
|
||||
<p className="text-secondary-900 dark:text-white">
|
||||
{iface.linkSpeed} Mbps
|
||||
{iface.duplex &&
|
||||
` (${iface.duplex} duplex)`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Addresses */}
|
||||
{iface.addresses &&
|
||||
Array.isArray(iface.addresses) &&
|
||||
iface.addresses.length > 0 && (
|
||||
<div className="space-y-2 pt-2 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<p className="text-xs font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
IP Addresses
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{iface.addresses.map((addr, idx) => (
|
||||
<div
|
||||
key={`${addr.address}-${addr.family}-${idx}`}
|
||||
className="bg-white dark:bg-secondary-800 rounded p-2 border border-secondary-200 dark:border-secondary-700"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
addr.family === "inet6"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
}`}
|
||||
>
|
||||
{addr.family === "inet6"
|
||||
? "inet6"
|
||||
: "inet"}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-secondary-900 dark:text-white">
|
||||
{addr.address}
|
||||
{addr.netmask && (
|
||||
<span className="text-secondary-500 dark:text-secondary-400 ml-1">
|
||||
{addr.netmask}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{addr.gateway && (
|
||||
<div className="text-xs text-secondary-600 dark:text-secondary-400 ml-1">
|
||||
Gateway:{" "}
|
||||
<span className="font-mono">
|
||||
{addr.gateway}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1689,74 +1777,161 @@ const HostDetail = () => {
|
||||
|
||||
{/* Network Information */}
|
||||
{activeTab === "network" &&
|
||||
(host.ip ||
|
||||
host.gateway_ip ||
|
||||
host.dns_servers ||
|
||||
host.network_interfaces) && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{host.ip && (
|
||||
(host.dns_servers || host.network_interfaces) && (
|
||||
<div className="space-y-6">
|
||||
{/* DNS Servers */}
|
||||
{host.dns_servers &&
|
||||
Array.isArray(host.dns_servers) &&
|
||||
host.dns_servers.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
IP Address
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
|
||||
{host.ip}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{host.gateway_ip && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Gateway IP
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
|
||||
{host.gateway_ip}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{host.dns_servers &&
|
||||
Array.isArray(host.dns_servers) &&
|
||||
host.dns_servers.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
DNS Servers
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{host.dns_servers.map((dns) => (
|
||||
<p
|
||||
key={dns}
|
||||
className="font-medium text-secondary-900 dark:text-white font-mono text-sm"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Wifi className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
DNS Servers
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{host.dns_servers.map((dns) => (
|
||||
<div
|
||||
key={dns}
|
||||
className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg border border-secondary-200 dark:border-secondary-600"
|
||||
>
|
||||
<p className="font-mono text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{dns}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{host.network_interfaces &&
|
||||
Array.isArray(host.network_interfaces) &&
|
||||
host.network_interfaces.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Network Interfaces
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{host.network_interfaces.map((iface) => (
|
||||
<p
|
||||
key={iface.name}
|
||||
className="font-medium text-secondary-900 dark:text-white text-sm"
|
||||
>
|
||||
{iface.name}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{/* Network Interfaces */}
|
||||
{host.network_interfaces &&
|
||||
Array.isArray(host.network_interfaces) &&
|
||||
host.network_interfaces.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Wifi className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
Network Interfaces
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{host.network_interfaces.map((iface) => (
|
||||
<div
|
||||
key={iface.name}
|
||||
className="border border-secondary-200 dark:border-secondary-700 rounded-lg p-4 bg-secondary-50 dark:bg-secondary-900/50"
|
||||
>
|
||||
{/* Interface Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-secondary-900 dark:text-white text-sm">
|
||||
{iface.name}
|
||||
</p>
|
||||
{iface.type && (
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 bg-secondary-200 dark:bg-secondary-700 px-2 py-0.5 rounded">
|
||||
{iface.type}
|
||||
</span>
|
||||
)}
|
||||
{iface.status && (
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||
iface.status === "up"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{iface.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interface Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs mb-3">
|
||||
{iface.macAddress && (
|
||||
<div>
|
||||
<p className="text-secondary-500 dark:text-secondary-400 mb-0.5">
|
||||
MAC Address
|
||||
</p>
|
||||
<p className="font-mono text-secondary-900 dark:text-white">
|
||||
{iface.macAddress}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{iface.mtu && (
|
||||
<div>
|
||||
<p className="text-secondary-500 dark:text-secondary-400 mb-0.5">
|
||||
MTU
|
||||
</p>
|
||||
<p className="text-secondary-900 dark:text-white">
|
||||
{iface.mtu}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{iface.linkSpeed && iface.linkSpeed > 0 && (
|
||||
<div>
|
||||
<p className="text-secondary-500 dark:text-secondary-400 mb-0.5">
|
||||
Link Speed
|
||||
</p>
|
||||
<p className="text-secondary-900 dark:text-white">
|
||||
{iface.linkSpeed} Mbps
|
||||
{iface.duplex &&
|
||||
` (${iface.duplex} duplex)`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Addresses */}
|
||||
{iface.addresses &&
|
||||
Array.isArray(iface.addresses) &&
|
||||
iface.addresses.length > 0 && (
|
||||
<div className="space-y-2 pt-3 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<p className="text-xs font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
IP Addresses
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{iface.addresses.map((addr, idx) => (
|
||||
<div
|
||||
key={`${addr.address}-${addr.family}-${idx}`}
|
||||
className="bg-white dark:bg-secondary-800 rounded p-2 border border-secondary-200 dark:border-secondary-700"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
addr.family === "inet6"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
}`}
|
||||
>
|
||||
{addr.family === "inet6"
|
||||
? "inet6"
|
||||
: "inet"}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-secondary-900 dark:text-white">
|
||||
{addr.address}
|
||||
{addr.netmask && (
|
||||
<span className="text-secondary-500 dark:text-secondary-400 ml-1">
|
||||
{addr.netmask}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{addr.gateway && (
|
||||
<div className="text-xs text-secondary-600 dark:text-secondary-400 ml-1">
|
||||
Gateway:{" "}
|
||||
<span className="font-mono">
|
||||
{addr.gateway}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CheckCircle,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Columns,
|
||||
ExternalLink,
|
||||
@@ -44,9 +45,11 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
friendly_name: "",
|
||||
hostGroupIds: [], // Changed to array for multiple selection
|
||||
docker_enabled: false, // Integration states
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [integrationsExpanded, setIntegrationsExpanded] = useState(false);
|
||||
|
||||
// Fetch host groups for selection
|
||||
const { data: hostGroups } = useQuery({
|
||||
@@ -66,7 +69,12 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const response = await adminHostsAPI.create(formData);
|
||||
console.log("Host created successfully:", formData.friendly_name);
|
||||
onSuccess(response.data);
|
||||
setFormData({ friendly_name: "", hostGroupIds: [] });
|
||||
setFormData({
|
||||
friendly_name: "",
|
||||
hostGroupIds: [],
|
||||
docker_enabled: false,
|
||||
});
|
||||
setIntegrationsExpanded(false);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Full error object:", err);
|
||||
@@ -189,6 +197,65 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Integrations Section */}
|
||||
<div className="border-2 border-secondary-200 dark:border-secondary-700 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntegrationsExpanded(!integrationsExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50 transition-colors rounded-t-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Integrations
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
(Optional)
|
||||
</span>
|
||||
</div>
|
||||
{integrationsExpanded ? (
|
||||
<ChevronUp className="h-5 w-5 text-secondary-400 dark:text-secondary-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-secondary-400 dark:text-secondary-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{integrationsExpanded && (
|
||||
<div className="p-4 pt-0 space-y-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
{/* Docker Integration */}
|
||||
<label className="flex items-center justify-between p-3 border-2 rounded-lg transition-all duration-200 cursor-pointer bg-white dark:bg-secondary-700 hover:border-secondary-400 dark:hover:border-secondary-500 border-secondary-300 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<Server className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Docker Integration
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Enable Docker container monitoring for this host
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.docker_enabled}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
docker_enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Integration settings will be synced to the agent's config.yml
|
||||
during installation.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">
|
||||
@@ -1812,7 +1879,13 @@ const Hosts = () => {
|
||||
) &&
|
||||
host.needs_reboot && (
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
||||
title={
|
||||
host.reboot_reason ||
|
||||
"Reboot required"
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reboot Required
|
||||
</span>
|
||||
|
||||
@@ -369,7 +369,10 @@ const PackageDetail = () => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
{host.needsReboot && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
||||
title={host.rebootReason || "Reboot required"}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reboot Required
|
||||
</span>
|
||||
@@ -450,7 +453,10 @@ const PackageDetail = () => {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{host.needsReboot ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
||||
title={host.rebootReason || "Reboot required"}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Required
|
||||
</span>
|
||||
|
||||
@@ -610,7 +610,12 @@ const RepositoryDetail = () => {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{hostRepo.hosts.needs_reboot ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
||||
title={
|
||||
hostRepo.hosts.reboot_reason || "Reboot required"
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Required
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon",
|
||||
"version": "1.3.6",
|
||||
"version": "1.3.7",
|
||||
"description": "Linux Patch Monitoring System",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
|
||||
Reference in New Issue
Block a user