mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-23 07:51:12 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a853d36a6f | ||
|
|
08f41bc9e0 | ||
|
|
6f7dbdb04b | ||
|
|
909698ac11 | ||
|
|
aaed443081 | ||
|
|
5b96b67db9 | ||
|
|
c6a2163e79 | ||
|
|
b55ee6a6e0 | ||
|
|
e983d39bd6 | ||
|
|
189de7a593 | ||
|
|
f57d87e1c0 | ||
|
|
470b204a8c | ||
|
|
fa1f0fd7d7 | ||
|
|
334357a12e |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -197,6 +197,40 @@ while IFS= read -r line; do
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Check if agent is already installed and working BEFORE enrollment
|
||||||
|
info " Checking if agent is already configured..."
|
||||||
|
config_check=$(timeout 10 pct exec "$vmid" -- bash -c "
|
||||||
|
if [[ -f /etc/patchmon/config.yml ]] && [[ -f /etc/patchmon/credentials.yml ]]; then
|
||||||
|
if [[ -f /usr/local/bin/patchmon-agent ]]; then
|
||||||
|
# Try to ping using existing configuration
|
||||||
|
if /usr/local/bin/patchmon-agent ping >/dev/null 2>&1; then
|
||||||
|
echo 'ping_success'
|
||||||
|
else
|
||||||
|
echo 'ping_failed'
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo 'binary_missing'
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo 'not_configured'
|
||||||
|
fi
|
||||||
|
" 2>/dev/null </dev/null || echo "error")
|
||||||
|
|
||||||
|
if [[ "$config_check" == "ping_success" ]]; then
|
||||||
|
info " ✓ Host already enrolled and agent ping successful - skipping enrollment"
|
||||||
|
((skipped_count++)) || true
|
||||||
|
echo ""
|
||||||
|
continue
|
||||||
|
elif [[ "$config_check" == "ping_failed" ]]; then
|
||||||
|
warn " ⚠ Agent configuration exists but ping failed - will re-enroll and reinstall"
|
||||||
|
elif [[ "$config_check" == "binary_missing" ]]; then
|
||||||
|
warn " ⚠ Config exists but agent binary missing - will re-enroll and reinstall"
|
||||||
|
elif [[ "$config_check" == "not_configured" ]]; then
|
||||||
|
info " ℹ Agent not yet configured - proceeding with enrollment"
|
||||||
|
else
|
||||||
|
warn " ⚠ Could not check agent status - proceeding with enrollment"
|
||||||
|
fi
|
||||||
|
|
||||||
# Call PatchMon auto-enrollment API
|
# Call PatchMon auto-enrollment API
|
||||||
info " Enrolling $friendly_name in PatchMon..."
|
info " Enrolling $friendly_name in PatchMon..."
|
||||||
|
|
||||||
@@ -230,40 +264,6 @@ while IFS= read -r line; do
|
|||||||
|
|
||||||
info " ✓ Host enrolled successfully: $api_id"
|
info " ✓ Host enrolled successfully: $api_id"
|
||||||
|
|
||||||
# Check if agent is already installed and working
|
|
||||||
info " Checking if agent is already configured..."
|
|
||||||
config_check=$(timeout 10 pct exec "$vmid" -- bash -c "
|
|
||||||
if [[ -f /etc/patchmon/config.yml ]] && [[ -f /etc/patchmon/credentials.yml ]]; then
|
|
||||||
if [[ -f /usr/local/bin/patchmon-agent ]]; then
|
|
||||||
# Try to ping using existing configuration
|
|
||||||
if /usr/local/bin/patchmon-agent ping >/dev/null 2>&1; then
|
|
||||||
echo 'ping_success'
|
|
||||||
else
|
|
||||||
echo 'ping_failed'
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo 'binary_missing'
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo 'not_configured'
|
|
||||||
fi
|
|
||||||
" 2>/dev/null </dev/null || echo "error")
|
|
||||||
|
|
||||||
if [[ "$config_check" == "ping_success" ]]; then
|
|
||||||
info " ✓ Host already enrolled and agent ping successful - skipping"
|
|
||||||
((skipped_count++)) || true
|
|
||||||
echo ""
|
|
||||||
continue
|
|
||||||
elif [[ "$config_check" == "ping_failed" ]]; then
|
|
||||||
warn " ⚠ Agent configuration exists but ping failed - will reinstall"
|
|
||||||
elif [[ "$config_check" == "binary_missing" ]]; then
|
|
||||||
warn " ⚠ Config exists but agent binary missing - will reinstall"
|
|
||||||
elif [[ "$config_check" == "not_configured" ]]; then
|
|
||||||
info " ℹ Agent not yet configured - proceeding with installation"
|
|
||||||
else
|
|
||||||
warn " ⚠ Could not check agent status - proceeding with installation"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure curl is installed in the container
|
# Ensure curl is installed in the container
|
||||||
info " Checking for curl in container..."
|
info " Checking for curl in container..."
|
||||||
curl_check=$(timeout 10 pct exec "$vmid" -- bash -c "command -v curl >/dev/null 2>&1 && echo 'installed' || echo 'missing'" 2>/dev/null </dev/null || echo "error")
|
curl_check=$(timeout 10 pct exec "$vmid" -- bash -c "command -v curl >/dev/null 2>&1 && echo 'installed' || echo 'missing'" 2>/dev/null </dev/null || echo "error")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-backend",
|
"name": "patchmon-backend",
|
||||||
"version": "1.3.4",
|
"version": "1.3.6",
|
||||||
"description": "Backend API for Linux Patch Monitoring System",
|
"description": "Backend API for Linux Patch Monitoring System",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -103,6 +103,7 @@ model hosts {
|
|||||||
gateway_ip String?
|
gateway_ip String?
|
||||||
hostname String?
|
hostname String?
|
||||||
kernel_version String?
|
kernel_version String?
|
||||||
|
installed_kernel_version String?
|
||||||
load_average Json?
|
load_average Json?
|
||||||
network_interfaces Json?
|
network_interfaces Json?
|
||||||
ram_installed Int?
|
ram_installed Int?
|
||||||
|
|||||||
@@ -588,8 +588,11 @@ router.get("/script", async (req, res) => {
|
|||||||
// Check for --force parameter
|
// Check for --force parameter
|
||||||
const force_install = req.query.force === "true" || req.query.force === "1";
|
const force_install = req.query.force === "true" || req.query.force === "1";
|
||||||
|
|
||||||
|
// Use bash for proxmox-lxc, sh for others
|
||||||
|
const shebang = script_type === "proxmox-lxc" ? "#!/bin/bash" : "#!/bin/sh";
|
||||||
|
|
||||||
// Inject the token credentials, server URL, curl flags, and force flag into the script
|
// Inject the token credentials, server URL, curl flags, and force flag into the script
|
||||||
const env_vars = `#!/bin/sh
|
const env_vars = `${shebang}
|
||||||
# PatchMon Auto-Enrollment Configuration (Auto-generated)
|
# PatchMon Auto-Enrollment Configuration (Auto-generated)
|
||||||
export PATCHMON_URL="${server_url}"
|
export PATCHMON_URL="${server_url}"
|
||||||
export AUTO_ENROLLMENT_KEY="${token.token_key}"
|
export AUTO_ENROLLMENT_KEY="${token.token_key}"
|
||||||
|
|||||||
@@ -242,33 +242,48 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
|||||||
orderBy: { last_update: "desc" },
|
orderBy: { last_update: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// OPTIMIZATION: Get all package counts in 2 batch queries instead of N*2 queries
|
// OPTIMIZATION: Get all package counts in 3 batch queries instead of N*3 queries
|
||||||
const hostIds = hosts.map((h) => h.id);
|
const hostIds = hosts.map((h) => h.id);
|
||||||
|
|
||||||
const [updateCounts, totalCounts] = await Promise.all([
|
const [updateCounts, securityUpdateCounts, totalCounts] = await Promise.all(
|
||||||
// Get update counts for all hosts at once
|
[
|
||||||
prisma.host_packages.groupBy({
|
// Get update counts for all hosts at once
|
||||||
by: ["host_id"],
|
prisma.host_packages.groupBy({
|
||||||
where: {
|
by: ["host_id"],
|
||||||
host_id: { in: hostIds },
|
where: {
|
||||||
needs_update: true,
|
host_id: { in: hostIds },
|
||||||
},
|
needs_update: true,
|
||||||
_count: { id: true },
|
},
|
||||||
}),
|
_count: { id: true },
|
||||||
// Get total counts for all hosts at once
|
}),
|
||||||
prisma.host_packages.groupBy({
|
// Get security update counts for all hosts at once
|
||||||
by: ["host_id"],
|
prisma.host_packages.groupBy({
|
||||||
where: {
|
by: ["host_id"],
|
||||||
host_id: { in: hostIds },
|
where: {
|
||||||
},
|
host_id: { in: hostIds },
|
||||||
_count: { id: true },
|
needs_update: true,
|
||||||
}),
|
is_security_update: true,
|
||||||
]);
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
// Get total counts for all hosts at once
|
||||||
|
prisma.host_packages.groupBy({
|
||||||
|
by: ["host_id"],
|
||||||
|
where: {
|
||||||
|
host_id: { in: hostIds },
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Create lookup maps for O(1) access
|
// Create lookup maps for O(1) access
|
||||||
const updateCountMap = new Map(
|
const updateCountMap = new Map(
|
||||||
updateCounts.map((item) => [item.host_id, item._count.id]),
|
updateCounts.map((item) => [item.host_id, item._count.id]),
|
||||||
);
|
);
|
||||||
|
const securityUpdateCountMap = new Map(
|
||||||
|
securityUpdateCounts.map((item) => [item.host_id, item._count.id]),
|
||||||
|
);
|
||||||
const totalCountMap = new Map(
|
const totalCountMap = new Map(
|
||||||
totalCounts.map((item) => [item.host_id, item._count.id]),
|
totalCounts.map((item) => [item.host_id, item._count.id]),
|
||||||
);
|
);
|
||||||
@@ -276,6 +291,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
|||||||
// Process hosts with counts from maps (no more DB queries!)
|
// Process hosts with counts from maps (no more DB queries!)
|
||||||
const hostsWithUpdateInfo = hosts.map((host) => {
|
const hostsWithUpdateInfo = hosts.map((host) => {
|
||||||
const updatesCount = updateCountMap.get(host.id) || 0;
|
const updatesCount = updateCountMap.get(host.id) || 0;
|
||||||
|
const securityUpdatesCount = securityUpdateCountMap.get(host.id) || 0;
|
||||||
const totalPackagesCount = totalCountMap.get(host.id) || 0;
|
const totalPackagesCount = totalCountMap.get(host.id) || 0;
|
||||||
|
|
||||||
// Calculate effective status based on reporting interval
|
// Calculate effective status based on reporting interval
|
||||||
@@ -292,6 +308,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
|||||||
return {
|
return {
|
||||||
...host,
|
...host,
|
||||||
updatesCount,
|
updatesCount,
|
||||||
|
securityUpdatesCount,
|
||||||
totalPackagesCount,
|
totalPackagesCount,
|
||||||
isStale,
|
isStale,
|
||||||
effectiveStatus,
|
effectiveStatus,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const {
|
|||||||
} = require("../middleware/permissions");
|
} = require("../middleware/permissions");
|
||||||
const { queueManager, QUEUE_NAMES } = require("../services/automation");
|
const { queueManager, QUEUE_NAMES } = require("../services/automation");
|
||||||
const { pushIntegrationToggle, isConnected } = require("../services/agentWs");
|
const { pushIntegrationToggle, isConnected } = require("../services/agentWs");
|
||||||
const agentVersionService = require("../services/agentVersionService");
|
|
||||||
const { compareVersions } = require("../services/automation/shared/utils");
|
const { compareVersions } = require("../services/automation/shared/utils");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -170,111 +169,101 @@ router.get("/agent/version", async (req, res) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Go agent version check
|
// Go agent version check
|
||||||
// Detect server architecture and map to Go architecture names
|
// Always check the server's local binary for the requested architecture
|
||||||
const os = require("node:os");
|
// The server's agents folder is the source of truth, not GitHub
|
||||||
const { exec } = require("node:child_process");
|
const { exec } = require("node:child_process");
|
||||||
const { promisify } = require("node:util");
|
const { promisify } = require("node:util");
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
const serverArch = os.arch();
|
const binaryName = `patchmon-agent-linux-${architecture}`;
|
||||||
// Map Node.js architecture to Go architecture names
|
const binaryPath = path.join(__dirname, "../../../agents", binaryName);
|
||||||
const archMap = {
|
|
||||||
x64: "amd64",
|
|
||||||
ia32: "386",
|
|
||||||
arm64: "arm64",
|
|
||||||
arm: "arm",
|
|
||||||
};
|
|
||||||
const serverGoArch = archMap[serverArch] || serverArch;
|
|
||||||
|
|
||||||
// If requested architecture matches server architecture, execute the binary
|
if (fs.existsSync(binaryPath)) {
|
||||||
if (architecture === serverGoArch) {
|
// Binary exists in server's agents folder - use its version
|
||||||
const binaryName = `patchmon-agent-linux-${architecture}`;
|
let serverVersion = null;
|
||||||
const binaryPath = path.join(__dirname, "../../../agents", binaryName);
|
|
||||||
|
|
||||||
if (!fs.existsSync(binaryPath)) {
|
// Try method 1: Execute binary (works for same architecture)
|
||||||
// Binary doesn't exist, fall back to GitHub
|
try {
|
||||||
console.log(`Binary ${binaryName} not found, falling back to GitHub`);
|
const { stdout } = await execAsync(`${binaryPath} --help`, {
|
||||||
} else {
|
timeout: 10000,
|
||||||
// Execute the binary to get its version
|
});
|
||||||
|
|
||||||
|
// Parse version from help output (e.g., "PatchMon Agent v1.3.1")
|
||||||
|
const versionMatch = stdout.match(
|
||||||
|
/PatchMon Agent v([0-9]+\.[0-9]+\.[0-9]+)/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (versionMatch) {
|
||||||
|
serverVersion = versionMatch[1];
|
||||||
|
}
|
||||||
|
} catch (execError) {
|
||||||
|
// Execution failed (likely cross-architecture) - try alternative method
|
||||||
|
console.warn(
|
||||||
|
`Failed to execute binary ${binaryName} to get version (may be cross-architecture): ${execError.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try method 2: Extract version using strings command (works for cross-architecture)
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(`${binaryPath} --help`, {
|
const { stdout: stringsOutput } = await execAsync(
|
||||||
timeout: 10000,
|
`strings "${binaryPath}" | grep -E "PatchMon Agent v[0-9]+\\.[0-9]+\\.[0-9]+" | head -1`,
|
||||||
});
|
{
|
||||||
|
timeout: 10000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Parse version from help output (e.g., "PatchMon Agent v1.3.1")
|
const versionMatch = stringsOutput.match(
|
||||||
const versionMatch = stdout.match(
|
|
||||||
/PatchMon Agent v([0-9]+\.[0-9]+\.[0-9]+)/i,
|
/PatchMon Agent v([0-9]+\.[0-9]+\.[0-9]+)/i,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (versionMatch) {
|
if (versionMatch) {
|
||||||
const serverVersion = versionMatch[1];
|
serverVersion = versionMatch[1];
|
||||||
const agentVersion = req.query.currentVersion || serverVersion;
|
console.log(
|
||||||
|
`✅ Extracted version ${serverVersion} from binary using strings command`,
|
||||||
// Proper semantic version comparison: only update if server version is NEWER
|
);
|
||||||
const hasUpdate =
|
|
||||||
compareVersions(serverVersion, agentVersion) > 0;
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
currentVersion: agentVersion,
|
|
||||||
latestVersion: serverVersion,
|
|
||||||
hasUpdate: hasUpdate,
|
|
||||||
downloadUrl: `/api/v1/hosts/agent/download?arch=${architecture}`,
|
|
||||||
releaseNotes: `PatchMon Agent v${serverVersion}`,
|
|
||||||
minServerVersion: null,
|
|
||||||
architecture: architecture,
|
|
||||||
agentType: "go",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (execError) {
|
} catch (stringsError) {
|
||||||
// Execution failed, fall back to GitHub
|
console.warn(
|
||||||
console.log(
|
`Failed to extract version using strings command: ${stringsError.message}`,
|
||||||
`Failed to execute binary ${binaryName}: ${execError.message}, falling back to GitHub`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to GitHub if architecture doesn't match or binary execution failed
|
// If we successfully got the version, return it
|
||||||
try {
|
if (serverVersion) {
|
||||||
const versionInfo = await agentVersionService.getVersionInfo();
|
const agentVersion = req.query.currentVersion || serverVersion;
|
||||||
const latestVersion = versionInfo.latestVersion;
|
|
||||||
const agentVersion =
|
|
||||||
req.query.currentVersion || latestVersion || "unknown";
|
|
||||||
|
|
||||||
if (!latestVersion) {
|
// Proper semantic version comparison: only update if server version is NEWER
|
||||||
return res.status(503).json({
|
const hasUpdate = compareVersions(serverVersion, agentVersion) > 0;
|
||||||
error: "Unable to determine latest version from GitHub releases",
|
|
||||||
|
return res.json({
|
||||||
currentVersion: agentVersion,
|
currentVersion: agentVersion,
|
||||||
latestVersion: null,
|
latestVersion: serverVersion,
|
||||||
hasUpdate: false,
|
hasUpdate: hasUpdate,
|
||||||
|
downloadUrl: `/api/v1/hosts/agent/download?arch=${architecture}`,
|
||||||
|
releaseNotes: `PatchMon Agent v${serverVersion}`,
|
||||||
|
minServerVersion: null,
|
||||||
|
architecture: architecture,
|
||||||
|
agentType: "go",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proper semantic version comparison: only update if latest version is NEWER
|
// If we couldn't get version, fall through to error response
|
||||||
const hasUpdate =
|
console.warn(
|
||||||
latestVersion !== null &&
|
`Could not determine version for binary ${binaryName} using any method`,
|
||||||
compareVersions(latestVersion, agentVersion) > 0;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
currentVersion: agentVersion,
|
|
||||||
latestVersion: latestVersion,
|
|
||||||
hasUpdate: hasUpdate,
|
|
||||||
downloadUrl: `/api/v1/hosts/agent/download?arch=${architecture}`,
|
|
||||||
releaseNotes: `PatchMon Agent v${latestVersion}`,
|
|
||||||
minServerVersion: null,
|
|
||||||
architecture: architecture,
|
|
||||||
agentType: "go",
|
|
||||||
});
|
|
||||||
} catch (serviceError) {
|
|
||||||
console.error(
|
|
||||||
"Failed to get version from agentVersionService:",
|
|
||||||
serviceError.message,
|
|
||||||
);
|
);
|
||||||
return res.status(500).json({
|
|
||||||
error: "Failed to get agent version from service",
|
|
||||||
details: serviceError.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Binary doesn't exist or couldn't get version - return error
|
||||||
|
// Don't fall back to GitHub - the server's agents folder is the source of truth
|
||||||
|
const agentVersion = req.query.currentVersion || "unknown";
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Agent binary not found for architecture: ${architecture}. Please ensure the binary is in the server's agents folder.`,
|
||||||
|
currentVersion: agentVersion,
|
||||||
|
latestVersion: null,
|
||||||
|
hasUpdate: false,
|
||||||
|
architecture: architecture,
|
||||||
|
agentType: "go",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Version check error:", error);
|
console.error("Version check error:", error);
|
||||||
@@ -517,6 +506,10 @@ router.post(
|
|||||||
.optional()
|
.optional()
|
||||||
.isString()
|
.isString()
|
||||||
.withMessage("Kernel version must be a string"),
|
.withMessage("Kernel version must be a string"),
|
||||||
|
body("installedKernelVersion")
|
||||||
|
.optional()
|
||||||
|
.isString()
|
||||||
|
.withMessage("Installed kernel version must be a string"),
|
||||||
body("selinuxStatus")
|
body("selinuxStatus")
|
||||||
.optional()
|
.optional()
|
||||||
.isIn(["enabled", "disabled", "permissive"])
|
.isIn(["enabled", "disabled", "permissive"])
|
||||||
@@ -598,6 +591,8 @@ router.post(
|
|||||||
// System Information
|
// System Information
|
||||||
if (req.body.kernelVersion)
|
if (req.body.kernelVersion)
|
||||||
updateData.kernel_version = req.body.kernelVersion;
|
updateData.kernel_version = req.body.kernelVersion;
|
||||||
|
if (req.body.installedKernelVersion)
|
||||||
|
updateData.installed_kernel_version = req.body.installedKernelVersion;
|
||||||
if (req.body.selinuxStatus)
|
if (req.body.selinuxStatus)
|
||||||
updateData.selinux_status = req.body.selinuxStatus;
|
updateData.selinux_status = req.body.selinuxStatus;
|
||||||
if (req.body.systemUptime)
|
if (req.body.systemUptime)
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ ENV NODE_ENV=development \
|
|||||||
|
|
||||||
RUN apk add --no-cache openssl tini curl libc6-compat
|
RUN apk add --no-cache openssl tini curl libc6-compat
|
||||||
|
|
||||||
USER node
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --chown=node:node package*.json ./
|
COPY --chown=node:node package*.json ./
|
||||||
@@ -20,7 +18,10 @@ COPY --chown=node:node agents ./agents_backup
|
|||||||
COPY --chown=node:node agents ./agents
|
COPY --chown=node:node agents ./agents
|
||||||
COPY --chmod=755 docker/backend.docker-entrypoint.sh ./entrypoint.sh
|
COPY --chmod=755 docker/backend.docker-entrypoint.sh ./entrypoint.sh
|
||||||
|
|
||||||
RUN npm install --workspace=backend --ignore-scripts && cd backend && npx prisma generate
|
USER node
|
||||||
|
|
||||||
|
RUN npm install --workspace=backend --ignore-scripts && cd backend && npx prisma generate && \
|
||||||
|
chmod -R u+w /app/node_modules/@prisma/engines 2>/dev/null || true
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
@@ -66,8 +67,6 @@ ENV NODE_ENV=production \
|
|||||||
|
|
||||||
RUN apk add --no-cache openssl tini curl libc6-compat
|
RUN apk add --no-cache openssl tini curl libc6-compat
|
||||||
|
|
||||||
USER node
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder --chown=node:node /app/backend ./backend
|
COPY --from=builder --chown=node:node /app/backend ./backend
|
||||||
@@ -76,6 +75,14 @@ COPY --chown=node:node agents ./agents_backup
|
|||||||
COPY --chown=node:node agents ./agents
|
COPY --chown=node:node agents ./agents
|
||||||
COPY --chmod=755 docker/backend.docker-entrypoint.sh ./entrypoint.sh
|
COPY --chmod=755 docker/backend.docker-entrypoint.sh ./entrypoint.sh
|
||||||
|
|
||||||
|
# Ensure Prisma engines directory is writable for rootless Docker (Prisma 6.1.0+ requirement)
|
||||||
|
# This must be done as root before switching to node user
|
||||||
|
# Order: chown first (sets ownership), then chmod (sets permissions)
|
||||||
|
RUN chown -R node:node /app/node_modules/@prisma/engines && \
|
||||||
|
chmod -R u+w /app/node_modules/@prisma/engines
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ VITE_API_URL=http://localhost:3001/api/v1
|
|||||||
|
|
||||||
# Application Metadata
|
# Application Metadata
|
||||||
VITE_APP_NAME=PatchMon
|
VITE_APP_NAME=PatchMon
|
||||||
VITE_APP_VERSION=1.3.4
|
VITE_APP_VERSION=1.3.6
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-frontend",
|
"name": "patchmon-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.4",
|
"version": "1.3.6",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ const GlobalSearch = () => {
|
|||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
|
className="block w-full rounded-lg border border-secondary-200 bg-white py-2.5 sm:py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400 min-h-[44px]"
|
||||||
placeholder="Search hosts, packages, repos, users..."
|
placeholder="Search hosts, packages, repos, users..."
|
||||||
value={query}
|
value={query}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
@@ -228,7 +228,8 @@ const GlobalSearch = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600 min-w-[44px] min-h-[44px] justify-center"
|
||||||
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -237,9 +238,9 @@ const GlobalSearch = () => {
|
|||||||
|
|
||||||
{/* Dropdown Results */}
|
{/* Dropdown Results */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
|
<div className="absolute z-50 mt-2 w-full sm:w-[calc(100vw-2rem)] sm:max-w-md rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800 left-0 sm:left-auto right-0 sm:right-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
<div className="px-4 py-2 text-center text-sm text-secondary-500 dark:text-white/70">
|
||||||
Searching...
|
Searching...
|
||||||
</div>
|
</div>
|
||||||
) : hasResults ? (
|
) : hasResults ? (
|
||||||
@@ -247,7 +248,7 @@ const GlobalSearch = () => {
|
|||||||
{/* Hosts */}
|
{/* Hosts */}
|
||||||
{results.hosts?.length > 0 && (
|
{results.hosts?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
|
||||||
Hosts
|
Hosts
|
||||||
</div>
|
</div>
|
||||||
{results.hosts.map((host, _idx) => {
|
{results.hosts.map((host, _idx) => {
|
||||||
@@ -260,7 +261,7 @@ const GlobalSearch = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
key={host.id}
|
key={host.id}
|
||||||
onClick={() => handleResultClick(host)}
|
onClick={() => handleResultClick(host)}
|
||||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
|
||||||
globalIdx === selectedIndex
|
globalIdx === selectedIndex
|
||||||
? "bg-primary-50 dark:bg-primary-900/20"
|
? "bg-primary-50 dark:bg-primary-900/20"
|
||||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
@@ -271,12 +272,14 @@ const GlobalSearch = () => {
|
|||||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||||
{display.primary}
|
{display.primary}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-secondary-400">•</span>
|
<span className="text-xs text-secondary-400 dark:text-white/50">
|
||||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
•
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
|
||||||
{display.secondary}
|
{display.secondary}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
|
||||||
{host.os_type}
|
{host.os_type}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -288,7 +291,7 @@ const GlobalSearch = () => {
|
|||||||
{/* Packages */}
|
{/* Packages */}
|
||||||
{results.packages?.length > 0 && (
|
{results.packages?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
|
||||||
Packages
|
Packages
|
||||||
</div>
|
</div>
|
||||||
{results.packages.map((pkg, _idx) => {
|
{results.packages.map((pkg, _idx) => {
|
||||||
@@ -301,7 +304,7 @@ const GlobalSearch = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
onClick={() => handleResultClick(pkg)}
|
onClick={() => handleResultClick(pkg)}
|
||||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
|
||||||
globalIdx === selectedIndex
|
globalIdx === selectedIndex
|
||||||
? "bg-primary-50 dark:bg-primary-900/20"
|
? "bg-primary-50 dark:bg-primary-900/20"
|
||||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
@@ -317,13 +320,13 @@ const GlobalSearch = () => {
|
|||||||
<span className="text-xs text-secondary-400">
|
<span className="text-xs text-secondary-400">
|
||||||
•
|
•
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
|
||||||
{display.secondary}
|
{display.secondary}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
|
||||||
{pkg.host_count} hosts
|
{pkg.host_count} hosts
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -335,7 +338,7 @@ const GlobalSearch = () => {
|
|||||||
{/* Repositories */}
|
{/* Repositories */}
|
||||||
{results.repositories?.length > 0 && (
|
{results.repositories?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
|
||||||
Repositories
|
Repositories
|
||||||
</div>
|
</div>
|
||||||
{results.repositories.map((repo, _idx) => {
|
{results.repositories.map((repo, _idx) => {
|
||||||
@@ -348,7 +351,7 @@ const GlobalSearch = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
key={repo.id}
|
key={repo.id}
|
||||||
onClick={() => handleResultClick(repo)}
|
onClick={() => handleResultClick(repo)}
|
||||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
|
||||||
globalIdx === selectedIndex
|
globalIdx === selectedIndex
|
||||||
? "bg-primary-50 dark:bg-primary-900/20"
|
? "bg-primary-50 dark:bg-primary-900/20"
|
||||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
@@ -359,12 +362,14 @@ const GlobalSearch = () => {
|
|||||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||||
{display.primary}
|
{display.primary}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-secondary-400">•</span>
|
<span className="text-xs text-secondary-400 dark:text-white/50">
|
||||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
•
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
|
||||||
{display.secondary}
|
{display.secondary}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
|
||||||
{repo.host_count} hosts
|
{repo.host_count} hosts
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -376,7 +381,7 @@ const GlobalSearch = () => {
|
|||||||
{/* Users */}
|
{/* Users */}
|
||||||
{results.users?.length > 0 && (
|
{results.users?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-white/80">
|
||||||
Users
|
Users
|
||||||
</div>
|
</div>
|
||||||
{results.users.map((user, _idx) => {
|
{results.users.map((user, _idx) => {
|
||||||
@@ -389,7 +394,7 @@ const GlobalSearch = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
key={user.id}
|
key={user.id}
|
||||||
onClick={() => handleResultClick(user)}
|
onClick={() => handleResultClick(user)}
|
||||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
className={`flex w-full items-center gap-2 px-3 py-3 sm:py-1.5 text-left transition-colors min-h-[44px] ${
|
||||||
globalIdx === selectedIndex
|
globalIdx === selectedIndex
|
||||||
? "bg-primary-50 dark:bg-primary-900/20"
|
? "bg-primary-50 dark:bg-primary-900/20"
|
||||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
@@ -400,12 +405,14 @@ const GlobalSearch = () => {
|
|||||||
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||||
{display.primary}
|
{display.primary}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-secondary-400">•</span>
|
<span className="text-xs text-secondary-400 dark:text-white/50">
|
||||||
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
•
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-500 dark:text-white/70 truncate">
|
||||||
{display.secondary}
|
{display.secondary}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-xs text-secondary-400">
|
<div className="flex-shrink-0 text-xs text-secondary-400 dark:text-white/60">
|
||||||
{user.role}
|
{user.role}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -415,7 +422,7 @@ const GlobalSearch = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : query.trim() ? (
|
) : query.trim() ? (
|
||||||
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
<div className="px-4 py-2 text-center text-sm text-secondary-500 dark:text-white/70">
|
||||||
No results found for "{query}"
|
No results found for "{query}"
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -22,7 +22,6 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
TrendingUp,
|
|
||||||
Users,
|
Users,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -59,6 +58,7 @@ const Dashboard = () => {
|
|||||||
const [packageTrendsHost, setPackageTrendsHost] = useState("all"); // host filter
|
const [packageTrendsHost, setPackageTrendsHost] = useState("all"); // host filter
|
||||||
const [systemStatsJobId, setSystemStatsJobId] = useState(null); // Track job ID for system statistics
|
const [systemStatsJobId, setSystemStatsJobId] = useState(null); // Track job ID for system statistics
|
||||||
const [isTriggeringJob, setIsTriggeringJob] = useState(false);
|
const [isTriggeringJob, setIsTriggeringJob] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 640);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -312,6 +312,15 @@ const Dashboard = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Track window size for responsive chart options
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsMobile(window.innerWidth < 640);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Helper function to check if a card should be displayed
|
// Helper function to check if a card should be displayed
|
||||||
const isCardEnabled = (cardId) => {
|
const isCardEnabled = (cardId) => {
|
||||||
const card = cardPreferences.find((c) => c.cardId === cardId);
|
const card = cardPreferences.find((c) => c.cardId === cardId);
|
||||||
@@ -358,11 +367,11 @@ const Dashboard = () => {
|
|||||||
const getGroupClassName = (cardType) => {
|
const getGroupClassName = (cardType) => {
|
||||||
switch (cardType) {
|
switch (cardType) {
|
||||||
case "stats":
|
case "stats":
|
||||||
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4";
|
return "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4";
|
||||||
case "charts":
|
case "charts":
|
||||||
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
|
return "grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6";
|
||||||
case "widecharts":
|
case "widecharts":
|
||||||
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
|
return "grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6";
|
||||||
case "fullwidth":
|
case "fullwidth":
|
||||||
return "space-y-6";
|
return "space-y-6";
|
||||||
default:
|
default:
|
||||||
@@ -377,7 +386,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||||
onClick={handleNeedsRebootClick}
|
onClick={handleNeedsRebootClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -405,7 +414,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||||
onClick={handleTotalHostsClick}
|
onClick={handleTotalHostsClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -434,7 +443,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||||
onClick={handleHostsNeedingUpdatesClick}
|
onClick={handleHostsNeedingUpdatesClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -463,7 +472,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||||
onClick={handleUpToDateClick}
|
onClick={handleUpToDateClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -492,7 +501,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||||
onClick={handleOutdatedPackagesClick}
|
onClick={handleOutdatedPackagesClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -521,7 +530,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||||
onClick={handleSecurityUpdatesClick}
|
onClick={handleSecurityUpdatesClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -550,7 +559,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||||
onClick={handleHostGroupsClick}
|
onClick={handleHostGroupsClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -579,7 +588,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||||
onClick={handleUsersClick}
|
onClick={handleUsersClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -608,7 +617,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-3 sm:p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left min-h-[44px]"
|
||||||
onClick={handleRepositoriesClick}
|
onClick={handleRepositoriesClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -741,7 +750,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
case "osDistribution":
|
case "osDistribution":
|
||||||
return (
|
return (
|
||||||
<div className="card p-6 w-full">
|
<div className="card p-4 sm:p-6 w-full">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
OS Distribution
|
OS Distribution
|
||||||
</h3>
|
</h3>
|
||||||
@@ -755,7 +764,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
case "osDistributionDoughnut":
|
case "osDistributionDoughnut":
|
||||||
return (
|
return (
|
||||||
<div className="card p-6 w-full">
|
<div className="card p-4 sm:p-6 w-full">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
OS Distribution
|
OS Distribution
|
||||||
</h3>
|
</h3>
|
||||||
@@ -769,7 +778,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
case "osDistributionBar":
|
case "osDistributionBar":
|
||||||
return (
|
return (
|
||||||
<div className="card p-6 w-full">
|
<div className="card p-4 sm:p-6 w-full">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
OS Distribution
|
OS Distribution
|
||||||
</h3>
|
</h3>
|
||||||
@@ -783,7 +792,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
className="card p-4 sm:p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
|
||||||
onClick={handleUpdateStatusClick}
|
onClick={handleUpdateStatusClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -808,7 +817,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
case "packagePriority":
|
case "packagePriority":
|
||||||
return (
|
return (
|
||||||
<div className="card p-6 w-full">
|
<div className="card p-4 sm:p-6 w-full">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
Outdated Packages by Priority
|
Outdated Packages by Priority
|
||||||
</h3>
|
</h3>
|
||||||
@@ -825,13 +834,13 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
case "packageTrends":
|
case "packageTrends":
|
||||||
return (
|
return (
|
||||||
<div className="card p-6 w-full">
|
<div className="card p-4 sm:p-6 w-full">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
Package Trends Over Time
|
Package Trends Over Time
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
|
||||||
{/* Refresh Button */}
|
{/* Refresh Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -869,7 +878,7 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={packageTrendsFetching || isTriggeringJob}
|
disabled={packageTrendsFetching || isTriggeringJob}
|
||||||
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
className="px-3 py-2.5 sm:py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 min-h-[44px]"
|
||||||
title={
|
title={
|
||||||
packageTrendsHost === "all"
|
packageTrendsHost === "all"
|
||||||
? "Trigger system statistics collection"
|
? "Trigger system statistics collection"
|
||||||
@@ -890,7 +899,7 @@ const Dashboard = () => {
|
|||||||
<select
|
<select
|
||||||
value={packageTrendsPeriod}
|
value={packageTrendsPeriod}
|
||||||
onChange={(e) => setPackageTrendsPeriod(e.target.value)}
|
onChange={(e) => setPackageTrendsPeriod(e.target.value)}
|
||||||
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
className="px-3 py-2.5 sm:py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 min-h-[44px]"
|
||||||
>
|
>
|
||||||
<option value="1">Last 24 hours</option>
|
<option value="1">Last 24 hours</option>
|
||||||
<option value="7">Last 7 days</option>
|
<option value="7">Last 7 days</option>
|
||||||
@@ -908,7 +917,7 @@ const Dashboard = () => {
|
|||||||
// Clear job ID message when host selection changes
|
// Clear job ID message when host selection changes
|
||||||
setSystemStatsJobId(null);
|
setSystemStatsJobId(null);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
className="px-3 py-2.5 sm:py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500 min-h-[44px]"
|
||||||
>
|
>
|
||||||
<option value="all">All Hosts</option>
|
<option value="all">All Hosts</option>
|
||||||
{packageTrendsData?.hosts?.length > 0 ? (
|
{packageTrendsData?.hosts?.length > 0 ? (
|
||||||
@@ -928,7 +937,7 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
{/* Job ID Message */}
|
{/* Job ID Message */}
|
||||||
{systemStatsJobId && packageTrendsHost === "all" && (
|
{systemStatsJobId && packageTrendsHost === "all" && (
|
||||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 ml-1">
|
<p className="text-xs text-secondary-600 dark:text-white/70 ml-1">
|
||||||
Ran collection job #{systemStatsJobId}
|
Ran collection job #{systemStatsJobId}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -946,7 +955,7 @@ const Dashboard = () => {
|
|||||||
options={packageTrendsChartOptions}
|
options={packageTrendsChartOptions}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full text-secondary-500 dark:text-secondary-400">
|
<div className="flex items-center justify-center h-full text-secondary-500 dark:text-white/70">
|
||||||
No data available
|
No data available
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -984,7 +993,7 @@ const Dashboard = () => {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-6">
|
<div className="card p-4 sm:p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
System Overview
|
System Overview
|
||||||
@@ -995,10 +1004,10 @@ const Dashboard = () => {
|
|||||||
<div className="text-2xl font-bold text-primary-600">
|
<div className="text-2xl font-bold text-primary-600">
|
||||||
{updatePercentage}%
|
{updatePercentage}%
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||||
Need Updates
|
Need Updates
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
<div className="text-xs text-secondary-400 dark:text-white/60">
|
||||||
{stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts}{" "}
|
{stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts}{" "}
|
||||||
hosts
|
hosts
|
||||||
</div>
|
</div>
|
||||||
@@ -1007,10 +1016,10 @@ const Dashboard = () => {
|
|||||||
<div className="text-2xl font-bold text-danger-600">
|
<div className="text-2xl font-bold text-danger-600">
|
||||||
{stats.cards.securityUpdates}
|
{stats.cards.securityUpdates}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||||
Security Issues
|
Security Issues
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
<div className="text-xs text-secondary-400 dark:text-white/60">
|
||||||
{securityPercentage}% of updates
|
{securityPercentage}% of updates
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1018,10 +1027,10 @@ const Dashboard = () => {
|
|||||||
<div className="text-2xl font-bold text-success-600">
|
<div className="text-2xl font-bold text-success-600">
|
||||||
{onlinePercentage}%
|
{onlinePercentage}%
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||||
Online
|
Online
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
<div className="text-xs text-secondary-400 dark:text-white/60">
|
||||||
{onlineHosts}/{stats.cards.totalHosts} hosts
|
{onlineHosts}/{stats.cards.totalHosts} hosts
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1029,10 +1038,10 @@ const Dashboard = () => {
|
|||||||
<div className="text-2xl font-bold text-secondary-600">
|
<div className="text-2xl font-bold text-secondary-600">
|
||||||
{avgPackagesPerHost}
|
{avgPackagesPerHost}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||||
Avg per Host
|
Avg per Host
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
<div className="text-xs text-secondary-400 dark:text-white/60">
|
||||||
outdated packages
|
outdated packages
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1043,7 +1052,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
case "recentUsers":
|
case "recentUsers":
|
||||||
return (
|
return (
|
||||||
<div className="card p-6">
|
<div className="card p-4 sm:p-6">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
Recent Users Logged in
|
Recent Users Logged in
|
||||||
</h3>
|
</h3>
|
||||||
@@ -1057,7 +1066,7 @@ const Dashboard = () => {
|
|||||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
{u.username}
|
{u.username}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||||
{u.last_login
|
{u.last_login
|
||||||
? formatRelativeTime(u.last_login)
|
? formatRelativeTime(u.last_login)
|
||||||
: "Never"}
|
: "Never"}
|
||||||
@@ -1065,7 +1074,7 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(!recentUsers || recentUsers.length === 0) && (
|
{(!recentUsers || recentUsers.length === 0) && (
|
||||||
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
|
<div className="text-center text-secondary-500 dark:text-white/70 py-4">
|
||||||
No users found
|
No users found
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1076,7 +1085,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
case "recentCollection":
|
case "recentCollection":
|
||||||
return (
|
return (
|
||||||
<div className="card p-6">
|
<div className="card p-4 sm:p-6">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
Recent Collection
|
Recent Collection
|
||||||
</h3>
|
</h3>
|
||||||
@@ -1094,7 +1103,7 @@ const Dashboard = () => {
|
|||||||
>
|
>
|
||||||
{host.friendly_name || host.hostname}
|
{host.friendly_name || host.hostname}
|
||||||
</button>
|
</button>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
<div className="text-sm text-secondary-500 dark:text-white/70">
|
||||||
{host.last_update
|
{host.last_update
|
||||||
? formatRelativeTime(host.last_update)
|
? formatRelativeTime(host.last_update)
|
||||||
: "Never"}
|
: "Never"}
|
||||||
@@ -1102,7 +1111,7 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(!recentCollection || recentCollection.length === 0) && (
|
{(!recentCollection || recentCollection.length === 0) && (
|
||||||
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
|
<div className="text-center text-secondary-500 dark:text-white/70 py-4">
|
||||||
No hosts found
|
No hosts found
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1154,13 +1163,13 @@ const Dashboard = () => {
|
|||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: "right",
|
position: isMobile ? "bottom" : "right",
|
||||||
labels: {
|
labels: {
|
||||||
color: isDark ? "#ffffff" : "#374151",
|
color: isDark ? "#ffffff" : "#374151",
|
||||||
font: {
|
font: {
|
||||||
size: 12,
|
size: isMobile ? 10 : 12,
|
||||||
},
|
},
|
||||||
padding: 15,
|
padding: isMobile ? 10 : 15,
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
pointStyle: "circle",
|
pointStyle: "circle",
|
||||||
},
|
},
|
||||||
@@ -1168,7 +1177,7 @@ const Dashboard = () => {
|
|||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
padding: {
|
padding: {
|
||||||
right: 20,
|
right: isMobile ? 10 : 20,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onClick: handleOSChartClick,
|
onClick: handleOSChartClick,
|
||||||
@@ -1179,13 +1188,13 @@ const Dashboard = () => {
|
|||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: "right",
|
position: isMobile ? "bottom" : "right",
|
||||||
labels: {
|
labels: {
|
||||||
color: isDark ? "#ffffff" : "#374151",
|
color: isDark ? "#ffffff" : "#374151",
|
||||||
font: {
|
font: {
|
||||||
size: 12,
|
size: isMobile ? 10 : 12,
|
||||||
},
|
},
|
||||||
padding: 15,
|
padding: isMobile ? 10 : 15,
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
pointStyle: "circle",
|
pointStyle: "circle",
|
||||||
},
|
},
|
||||||
@@ -1193,7 +1202,7 @@ const Dashboard = () => {
|
|||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
padding: {
|
padding: {
|
||||||
right: 20,
|
right: isMobile ? 10 : 20,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onClick: handleOSChartClick,
|
onClick: handleOSChartClick,
|
||||||
@@ -1584,10 +1593,10 @@ const Dashboard = () => {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
<h1 className="text-xl sm:text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||||
Welcome back, {user?.first_name || user?.username || "User"} 👋
|
Welcome back, {user?.first_name || user?.username || "User"} 👋
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
<p className="text-sm text-secondary-600 dark:text-white/80 mt-1">
|
||||||
Overview of your PatchMon infrastructure
|
Overview of your PatchMon infrastructure
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1595,7 +1604,7 @@ const Dashboard = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSettingsModal(true)}
|
onClick={() => setShowSettingsModal(true)}
|
||||||
className="btn-outline flex items-center gap-2"
|
className="hidden md:flex btn-outline items-center gap-2"
|
||||||
title="Customize dashboard layout"
|
title="Customize dashboard layout"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
@@ -1604,7 +1613,7 @@ const Dashboard = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
className="btn-outline flex items-center gap-2"
|
className="btn-outline flex items-center gap-2 min-h-[44px] min-w-[44px] justify-center"
|
||||||
title="Refresh dashboard data"
|
title="Refresh dashboard data"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
|
|||||||
@@ -1013,29 +1013,25 @@ const HostDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(host.kernel_version ||
|
{host.kernel_version && (
|
||||||
host.installed_kernel_version) && (
|
<div>
|
||||||
<div className="flex flex-col gap-2">
|
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||||
{host.kernel_version && (
|
Running Kernel
|
||||||
<div>
|
</p>
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
|
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
|
||||||
Running Kernel
|
{host.kernel_version}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
|
</div>
|
||||||
{host.kernel_version}
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
{host.installed_kernel_version && (
|
||||||
)}
|
<div>
|
||||||
{host.installed_kernel_version && (
|
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||||
<div>
|
Installed Kernel
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
|
</p>
|
||||||
Installed Kernel
|
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
|
||||||
</p>
|
{host.installed_kernel_version}
|
||||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
|
</p>
|
||||||
{host.installed_kernel_version}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Eye as EyeIcon,
|
Eye as EyeIcon,
|
||||||
EyeOff as EyeOffIcon,
|
EyeOff as EyeOffIcon,
|
||||||
Filter,
|
Filter,
|
||||||
|
FolderPlus,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -21,7 +22,6 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
|
||||||
Wifi,
|
Wifi,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -94,8 +94,8 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 sm:p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
Add New Host
|
Add New Host
|
||||||
@@ -125,7 +125,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, friendly_name: e.target.value })
|
setFormData({ ...formData, friendly_name: e.target.value })
|
||||||
}
|
}
|
||||||
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
|
className="block w-full px-3 py-3 sm:py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200 min-h-[44px]"
|
||||||
placeholder="server.example.com"
|
placeholder="server.example.com"
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
@@ -197,18 +197,18 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-2">
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200"
|
className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200 min-h-[44px] w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200"
|
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200 min-h-[44px] w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Creating..." : "Create Host"}
|
{isSubmitting ? "Creating..." : "Create Host"}
|
||||||
</button>
|
</button>
|
||||||
@@ -248,7 +248,6 @@ const Hosts = () => {
|
|||||||
const showFiltersParam = searchParams.get("showFilters");
|
const showFiltersParam = searchParams.get("showFilters");
|
||||||
const osFilterParam = searchParams.get("osFilter");
|
const osFilterParam = searchParams.get("osFilter");
|
||||||
const groupParam = searchParams.get("group");
|
const groupParam = searchParams.get("group");
|
||||||
const rebootParam = searchParams.get("reboot");
|
|
||||||
|
|
||||||
if (filter === "needsUpdates") {
|
if (filter === "needsUpdates") {
|
||||||
setShowFilters(true);
|
setShowFilters(true);
|
||||||
@@ -335,9 +334,15 @@ const Hosts = () => {
|
|||||||
{ id: "status", label: "Status", visible: true, order: 10 },
|
{ id: "status", label: "Status", visible: true, order: 10 },
|
||||||
{ id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
|
{ id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
|
||||||
{ id: "updates", label: "Updates", visible: true, order: 12 },
|
{ id: "updates", label: "Updates", visible: true, order: 12 },
|
||||||
{ id: "notes", label: "Notes", visible: false, order: 13 },
|
{
|
||||||
{ id: "last_update", label: "Last Update", visible: true, order: 14 },
|
id: "security_updates",
|
||||||
{ id: "actions", label: "Actions", visible: true, order: 15 },
|
label: "Security Updates",
|
||||||
|
visible: true,
|
||||||
|
order: 13,
|
||||||
|
},
|
||||||
|
{ id: "notes", label: "Notes", visible: false, order: 14 },
|
||||||
|
{ id: "last_update", label: "Last Update", visible: true, order: 15 },
|
||||||
|
{ id: "actions", label: "Actions", visible: true, order: 16 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const saved = localStorage.getItem("hosts-column-config");
|
const saved = localStorage.getItem("hosts-column-config");
|
||||||
@@ -644,11 +649,27 @@ const Hosts = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = (hostsToSelect) => {
|
||||||
if (selectedHosts.length === hosts.length) {
|
const hostIdsToSelect = hostsToSelect.map((host) => host.id);
|
||||||
setSelectedHosts([]);
|
const allSelected = hostIdsToSelect.every((id) =>
|
||||||
|
selectedHosts.includes(id),
|
||||||
|
);
|
||||||
|
if (allSelected) {
|
||||||
|
// Deselect all hosts in this group
|
||||||
|
setSelectedHosts((prev) =>
|
||||||
|
prev.filter((id) => !hostIdsToSelect.includes(id)),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setSelectedHosts(hosts.map((host) => host.id));
|
// Select all hosts in this group (merge with existing selections)
|
||||||
|
setSelectedHosts((prev) => {
|
||||||
|
const newSelection = [...prev];
|
||||||
|
hostIdsToSelect.forEach((id) => {
|
||||||
|
if (!newSelection.includes(id)) {
|
||||||
|
newSelection.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newSelection;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -781,6 +802,10 @@ const Hosts = () => {
|
|||||||
aValue = a.updatesCount || 0;
|
aValue = a.updatesCount || 0;
|
||||||
bValue = b.updatesCount || 0;
|
bValue = b.updatesCount || 0;
|
||||||
break;
|
break;
|
||||||
|
case "security_updates":
|
||||||
|
aValue = a.securityUpdatesCount || 0;
|
||||||
|
bValue = b.securityUpdatesCount || 0;
|
||||||
|
break;
|
||||||
case "needs_reboot":
|
case "needs_reboot":
|
||||||
// Sort by boolean: false (0) comes before true (1)
|
// Sort by boolean: false (0) comes before true (1)
|
||||||
aValue = a.needs_reboot ? 1 : 0;
|
aValue = a.needs_reboot ? 1 : 0;
|
||||||
@@ -947,9 +972,15 @@ const Hosts = () => {
|
|||||||
{ id: "status", label: "Status", visible: true, order: 10 },
|
{ id: "status", label: "Status", visible: true, order: 10 },
|
||||||
{ id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
|
{ id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
|
||||||
{ id: "updates", label: "Updates", visible: true, order: 12 },
|
{ id: "updates", label: "Updates", visible: true, order: 12 },
|
||||||
{ id: "notes", label: "Notes", visible: false, order: 13 },
|
{
|
||||||
{ id: "last_update", label: "Last Update", visible: true, order: 14 },
|
id: "security_updates",
|
||||||
{ id: "actions", label: "Actions", visible: true, order: 15 },
|
label: "Security Updates",
|
||||||
|
visible: true,
|
||||||
|
order: 13,
|
||||||
|
},
|
||||||
|
{ id: "notes", label: "Notes", visible: false, order: 14 },
|
||||||
|
{ id: "last_update", label: "Last Update", visible: true, order: 15 },
|
||||||
|
{ id: "actions", label: "Actions", visible: true, order: 16 },
|
||||||
];
|
];
|
||||||
updateColumnConfig(defaultConfig);
|
updateColumnConfig(defaultConfig);
|
||||||
};
|
};
|
||||||
@@ -1091,11 +1122,17 @@ const Hosts = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full mr-1.5 ${
|
className={`w-2 h-2 rounded-full ${wsStatus.connected ? "mr-1.5" : "mr-1.5 md:mr-0"} ${
|
||||||
wsStatus.connected ? "bg-green-500 animate-pulse" : "bg-red-500"
|
wsStatus.connected ? "bg-green-500 animate-pulse" : "bg-red-500"
|
||||||
}`}
|
}`}
|
||||||
></div>
|
></div>
|
||||||
{wsStatus.connected ? (wsStatus.secure ? "WSS" : "WS") : "Offline"}
|
<span className="hidden md:inline">
|
||||||
|
{wsStatus.connected
|
||||||
|
? wsStatus.secure
|
||||||
|
? "WSS"
|
||||||
|
: "WS"
|
||||||
|
: "Offline"}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1135,6 +1172,19 @@ const Hosts = () => {
|
|||||||
{host.updatesCount || 0}
|
{host.updatesCount || 0}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
case "security_updates":
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/packages?host=${host.id}&filter=security-updates`)
|
||||||
|
}
|
||||||
|
className="text-sm text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 font-medium hover:underline"
|
||||||
|
title="View security updates for this host"
|
||||||
|
>
|
||||||
|
{host.securityUpdatesCount || 0}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
case "last_update":
|
case "last_update":
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||||
@@ -1190,7 +1240,7 @@ const Hosts = () => {
|
|||||||
navigate("/hosts", { replace: true });
|
navigate("/hosts", { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpToDateClick = () => {
|
const _handleUpToDateClick = () => {
|
||||||
// Filter to show only up-to-date hosts
|
// Filter to show only up-to-date hosts
|
||||||
setStatusFilter("active");
|
setStatusFilter("active");
|
||||||
setShowFilters(true);
|
setShowFilters(true);
|
||||||
@@ -1257,14 +1307,14 @@ const Hosts = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
<div className="min-h-0 flex flex-col md:h-[calc(100vh-7rem)] md:overflow-hidden">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||||
Hosts
|
Hosts
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
<p className="text-sm text-secondary-600 dark:text-white/80 mt-1">
|
||||||
Manage and monitor your connected hosts
|
Manage and monitor your connected hosts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1292,7 +1342,7 @@ const Hosts = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Summary */}
|
{/* Stats Summary */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
|
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
|
||||||
@@ -1376,7 +1426,7 @@ const Hosts = () => {
|
|||||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
{connectedCount}
|
{connectedCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 hidden sm:inline">
|
||||||
Connected
|
Connected
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1385,7 +1435,7 @@ const Hosts = () => {
|
|||||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
{offlineCount}
|
{offlineCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 hidden sm:inline">
|
||||||
Offline
|
Offline
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1398,37 +1448,39 @@ const Hosts = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hosts List */}
|
{/* Hosts List */}
|
||||||
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
<div className="card flex-1 flex flex-col md:overflow-hidden min-h-0">
|
||||||
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
|
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col md:overflow-hidden min-h-0">
|
||||||
<div className="flex items-center justify-end mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-end gap-3 mb-4">
|
||||||
{selectedHosts.length > 0 && (
|
{selectedHosts.length > 0 && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
<span className="text-sm text-secondary-600">
|
<span className="text-sm text-secondary-600 dark:text-white/80 flex-shrink-0">
|
||||||
{selectedHosts.length} host
|
{selectedHosts.length} host
|
||||||
{selectedHosts.length !== 1 ? "s" : ""} selected
|
{selectedHosts.length !== 1 ? "s" : ""} selected
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowBulkAssignModal(true)}
|
onClick={() => setShowBulkAssignModal(true)}
|
||||||
className="btn-outline flex items-center gap-2"
|
className="btn-outline flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<Users className="h-4 w-4" />
|
<FolderPlus className="h-4 w-4 flex-shrink-0" />
|
||||||
Assign to Group
|
<span className="hidden sm:inline">Assign to Group</span>
|
||||||
|
<span className="sm:hidden">Assign</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowBulkDeleteModal(true)}
|
onClick={() => setShowBulkDeleteModal(true)}
|
||||||
className="btn-danger flex items-center gap-2"
|
className="btn-danger flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4 flex-shrink-0" />
|
||||||
Delete
|
<span>Delete</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedHosts([])}
|
onClick={() => setSelectedHosts([])}
|
||||||
className="text-sm text-secondary-500 hover:text-secondary-700"
|
className="text-xs sm:text-sm text-secondary-500 dark:text-white/70 hover:text-secondary-700 dark:hover:text-white/90 min-h-[44px] px-2"
|
||||||
>
|
>
|
||||||
Clear Selection
|
<span className="hidden sm:inline">Clear Selection</span>
|
||||||
|
<span className="sm:hidden">Clear</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1450,28 +1502,28 @@ const Hosts = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
className={`btn-outline flex items-center gap-2 ${showFilters ? "bg-primary-50 border-primary-300" : ""}`}
|
className={`btn-outline flex items-center gap-1.5 sm:gap-2 px-2 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm ${showFilters ? "bg-primary-50 border-primary-300" : ""}`}
|
||||||
>
|
>
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4 flex-shrink-0" />
|
||||||
Filters
|
<span className="hidden sm:inline">Filters</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowColumnSettings(true)}
|
onClick={() => setShowColumnSettings(true)}
|
||||||
className="btn-outline flex items-center gap-2"
|
className="btn-outline flex items-center gap-1.5 sm:gap-2 px-2 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<Columns className="h-4 w-4" />
|
<Columns className="h-4 w-4 flex-shrink-0" />
|
||||||
Columns
|
<span className="hidden sm:inline">Columns</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={groupBy}
|
value={groupBy}
|
||||||
onChange={(e) => setGroupBy(e.target.value)}
|
onChange={(e) => setGroupBy(e.target.value)}
|
||||||
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[120px]"
|
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-xs sm:text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[100px] sm:min-w-[120px] min-h-[44px]"
|
||||||
>
|
>
|
||||||
<option value="none">No Grouping</option>
|
<option value="none">No Grouping</option>
|
||||||
<option value="group">By Group</option>
|
<option value="group">By Group</option>
|
||||||
@@ -1483,18 +1535,18 @@ const Hosts = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setHideStale(!hideStale)}
|
onClick={() => setHideStale(!hideStale)}
|
||||||
className={`btn-outline flex items-center gap-2 ${hideStale ? "bg-primary-50 border-primary-300" : ""}`}
|
className={`btn-outline flex items-center gap-1.5 sm:gap-2 px-2 sm:px-4 py-2 min-h-[44px] text-xs sm:text-sm ${hideStale ? "bg-primary-50 border-primary-300" : ""}`}
|
||||||
>
|
>
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||||
Hide Stale
|
<span className="hidden sm:inline">Hide Stale</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advanced Filters */}
|
{/* Advanced Filters */}
|
||||||
{showFilters && (
|
{showFilters && (
|
||||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg border dark:border-secondary-600">
|
<div className="bg-secondary-50 dark:bg-secondary-700 p-3 sm:p-4 rounded-lg border dark:border-secondary-600">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor={hostGroupFilterId}
|
htmlFor={hostGroupFilterId}
|
||||||
@@ -1506,7 +1558,7 @@ const Hosts = () => {
|
|||||||
id={hostGroupFilterId}
|
id={hostGroupFilterId}
|
||||||
value={groupFilter}
|
value={groupFilter}
|
||||||
onChange={(e) => setGroupFilter(e.target.value)}
|
onChange={(e) => setGroupFilter(e.target.value)}
|
||||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2.5 sm:py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white min-h-[44px]"
|
||||||
>
|
>
|
||||||
<option value="all">All Groups</option>
|
<option value="all">All Groups</option>
|
||||||
<option value="ungrouped">Ungrouped</option>
|
<option value="ungrouped">Ungrouped</option>
|
||||||
@@ -1528,7 +1580,7 @@ const Hosts = () => {
|
|||||||
id={statusFilterId}
|
id={statusFilterId}
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2.5 sm:py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white min-h-[44px]"
|
||||||
>
|
>
|
||||||
<option value="all">All Status</option>
|
<option value="all">All Status</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
@@ -1548,7 +1600,7 @@ const Hosts = () => {
|
|||||||
id={osFilterId}
|
id={osFilterId}
|
||||||
value={osFilter}
|
value={osFilter}
|
||||||
onChange={(e) => setOsFilter(e.target.value)}
|
onChange={(e) => setOsFilter(e.target.value)}
|
||||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2.5 sm:py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white min-h-[44px]"
|
||||||
>
|
>
|
||||||
<option value="all">All OS</option>
|
<option value="all">All OS</option>
|
||||||
{uniqueOsTypes.map((osType) => (
|
{uniqueOsTypes.map((osType) => (
|
||||||
@@ -1569,7 +1621,7 @@ const Hosts = () => {
|
|||||||
setGroupBy("none");
|
setGroupBy("none");
|
||||||
setHideStale(false);
|
setHideStale(false);
|
||||||
}}
|
}}
|
||||||
className="btn-outline w-full"
|
className="btn-outline w-full min-h-[44px]"
|
||||||
>
|
>
|
||||||
Clear Filters
|
Clear Filters
|
||||||
</button>
|
</button>
|
||||||
@@ -1579,7 +1631,7 @@ const Hosts = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 md:overflow-hidden">
|
||||||
{!hosts || hosts.length === 0 ? (
|
{!hosts || hosts.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
@@ -1600,7 +1652,7 @@ const Hosts = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full overflow-auto">
|
<div className="md:h-full overflow-auto">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{Object.entries(groupedHosts).map(
|
{Object.entries(groupedHosts).map(
|
||||||
([groupName, groupHosts]) => (
|
([groupName, groupHosts]) => (
|
||||||
@@ -1614,24 +1666,246 @@ const Hosts = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table for this group */}
|
{/* Mobile Card Layout */}
|
||||||
<div className="overflow-x-auto">
|
<div className="md:hidden space-y-3">
|
||||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
{groupHosts.map((host) => {
|
||||||
|
const isInactive =
|
||||||
|
(host.effectiveStatus || host.status) ===
|
||||||
|
"inactive";
|
||||||
|
const isSelected = selectedHosts.includes(host.id);
|
||||||
|
const wsStatus = wsStatusMap[host.api_id];
|
||||||
|
const groupIds =
|
||||||
|
host.host_group_memberships?.map(
|
||||||
|
(membership) => membership.host_groups.id,
|
||||||
|
) || [];
|
||||||
|
const groups =
|
||||||
|
hostGroups?.filter((g) =>
|
||||||
|
groupIds.includes(g.id),
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={host.id}
|
||||||
|
className={`card p-4 space-y-3 ${
|
||||||
|
isSelected
|
||||||
|
? "ring-2 ring-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||||
|
: isInactive
|
||||||
|
? "bg-red-50 dark:bg-red-900/20"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header with select and main info */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "select",
|
||||||
|
) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleSelectHost(host.id)
|
||||||
|
}
|
||||||
|
className="flex-shrink-0 min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{isSelected ? (
|
||||||
|
<CheckSquare className="h-5 w-5 text-primary-600" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-5 w-5 text-secondary-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "host",
|
||||||
|
) && (
|
||||||
|
<Link
|
||||||
|
to={`/hosts/${host.id}`}
|
||||||
|
className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 block truncate"
|
||||||
|
>
|
||||||
|
{host.friendly_name || "Unnamed Host"}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "hostname",
|
||||||
|
) &&
|
||||||
|
host.hostname && (
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400 font-mono truncate">
|
||||||
|
{host.hostname}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "actions",
|
||||||
|
) && (
|
||||||
|
<Link
|
||||||
|
to={`/hosts/${host.id}`}
|
||||||
|
className="btn-primary text-sm px-3 py-2 min-h-[44px] flex items-center gap-1 flex-shrink-0"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status and connection info */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "status",
|
||||||
|
) && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300">
|
||||||
|
{(host.effectiveStatus || host.status)
|
||||||
|
.charAt(0)
|
||||||
|
.toUpperCase() +
|
||||||
|
(
|
||||||
|
host.effectiveStatus || host.status
|
||||||
|
).slice(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "ws_status",
|
||||||
|
) &&
|
||||||
|
wsStatus && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${
|
||||||
|
wsStatus.connected
|
||||||
|
? "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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${wsStatus.connected ? "mr-1.5" : "mr-1.5 md:mr-0"} ${
|
||||||
|
wsStatus.connected
|
||||||
|
? "bg-green-500 animate-pulse"
|
||||||
|
: "bg-red-500"
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
<span className="hidden md:inline">
|
||||||
|
{wsStatus.connected
|
||||||
|
? wsStatus.secure
|
||||||
|
? "WSS"
|
||||||
|
: "WS"
|
||||||
|
: "Offline"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "needs_reboot",
|
||||||
|
) &&
|
||||||
|
(host.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">
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Reboot Required
|
||||||
|
</span>
|
||||||
|
) : null)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OS and Group info */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "os",
|
||||||
|
) && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<OSIcon
|
||||||
|
osType={host.os_type}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-secondary-700 dark:text-secondary-300">
|
||||||
|
{getOSDisplayName(host.os_type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "group",
|
||||||
|
) &&
|
||||||
|
groups.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<span className="text-secondary-500 dark:text-secondary-400">
|
||||||
|
Groups:
|
||||||
|
</span>
|
||||||
|
{groups.map((g, idx) => (
|
||||||
|
<span
|
||||||
|
key={g.id}
|
||||||
|
className="text-secondary-700 dark:text-secondary-300"
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
{idx < groups.length - 1 ? "," : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Updates info */}
|
||||||
|
<div className="flex items-center gap-4 pt-2 border-t border-secondary-200 dark:border-secondary-600">
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "updates",
|
||||||
|
) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/packages?host=${host.id}&filter=outdated`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium min-h-[44px] flex items-center"
|
||||||
|
>
|
||||||
|
{host.updatesCount || 0} Updates
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "security_updates",
|
||||||
|
) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/packages?host=${host.id}&filter=security-updates`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-sm text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 font-medium min-h-[44px] flex items-center"
|
||||||
|
>
|
||||||
|
{host.securityUpdatesCount || 0} Security
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{visibleColumns.some(
|
||||||
|
(col) => col.id === "last_update",
|
||||||
|
) && (
|
||||||
|
<div className="text-xs text-secondary-500 dark:text-secondary-400 ml-auto">
|
||||||
|
Updated{" "}
|
||||||
|
{formatRelativeTime(host.last_update)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Table Layout */}
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
|
<table
|
||||||
|
className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600"
|
||||||
|
style={{ minWidth: "max-content" }}
|
||||||
|
>
|
||||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||||
<tr>
|
<tr>
|
||||||
{visibleColumns.map((column) => (
|
{visibleColumns.map((column) => (
|
||||||
<th
|
<th
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
className="px-3 sm:px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{column.id === "select" ? (
|
{column.id === "select" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSelectAll}
|
onClick={() =>
|
||||||
|
handleSelectAll(groupHosts)
|
||||||
|
}
|
||||||
className="flex items-center gap-2 hover:text-secondary-700"
|
className="flex items-center gap-2 hover:text-secondary-700"
|
||||||
>
|
>
|
||||||
{selectedHosts.length ===
|
{groupHosts.every((host) =>
|
||||||
groupHosts.length ? (
|
selectedHosts.includes(host.id),
|
||||||
|
) ? (
|
||||||
<CheckSquare className="h-4 w-4" />
|
<CheckSquare className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Square className="h-4 w-4" />
|
<Square className="h-4 w-4" />
|
||||||
@@ -1731,6 +2005,17 @@ const Hosts = () => {
|
|||||||
{column.label}
|
{column.label}
|
||||||
{getSortIcon("updates")}
|
{getSortIcon("updates")}
|
||||||
</button>
|
</button>
|
||||||
|
) : column.id === "security_updates" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleSort("security_updates")
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 hover:text-secondary-700"
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
{getSortIcon("security_updates")}
|
||||||
|
</button>
|
||||||
) : column.id === "needs_reboot" ? (
|
) : column.id === "needs_reboot" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1784,7 +2069,7 @@ const Hosts = () => {
|
|||||||
{visibleColumns.map((column) => (
|
{visibleColumns.map((column) => (
|
||||||
<td
|
<td
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className="px-4 py-2 whitespace-nowrap text-center"
|
className="px-3 sm:px-4 py-2 whitespace-nowrap text-center"
|
||||||
>
|
>
|
||||||
{renderCellContent(column, host)}
|
{renderCellContent(column, host)}
|
||||||
</td>
|
</td>
|
||||||
@@ -2126,7 +2411,6 @@ const ColumnSettingsModal = ({
|
|||||||
key={column.id}
|
key={column.id}
|
||||||
type="button"
|
type="button"
|
||||||
draggable
|
draggable
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`Drag to reorder ${column.label} column`}
|
aria-label={`Drag to reorder ${column.label} column`}
|
||||||
onDragStart={(e) => handleDragStart(e, index)}
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -2137,7 +2421,7 @@ const ColumnSettingsModal = ({
|
|||||||
// Focus handling for keyboard users
|
// Focus handling for keyboard users
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`flex items-center justify-between p-2.5 border rounded-lg cursor-move w-full text-left transition-colors ${
|
className={`flex items-center justify-between p-2.5 border rounded-lg cursor-move w-full transition-colors ${
|
||||||
draggedIndex === index
|
draggedIndex === index
|
||||||
? "opacity-50"
|
? "opacity-50"
|
||||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
@@ -2151,12 +2435,20 @@ const ColumnSettingsModal = ({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleVisibility(column.id)}
|
onClick={(e) => {
|
||||||
className={`p-1 rounded transition-colors flex-shrink-0 ${
|
e.stopPropagation();
|
||||||
|
onToggleVisibility(column.id);
|
||||||
|
}}
|
||||||
|
className={`p-1 rounded transition-colors flex-shrink-0 min-w-[44px] min-h-[44px] flex items-center justify-center ${
|
||||||
column.visible
|
column.visible
|
||||||
? "text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
? "text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
: "text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
: "text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||||
}`}
|
}`}
|
||||||
|
aria-label={
|
||||||
|
column.visible
|
||||||
|
? `Hide ${column.label} column`
|
||||||
|
: `Show ${column.label} column`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{column.visible ? (
|
{column.visible ? (
|
||||||
<EyeIcon className="h-4 w-4" />
|
<EyeIcon className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1586,7 +1586,7 @@ const Integrations = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={`curl ${curl_flags} "${getEnrollmentUrl()}" | sh`}
|
value={`curl ${curl_flags} "${getEnrollmentUrl()}" | ${selected_script_type === "proxmox-lxc" ? "bash" : "sh"}`}
|
||||||
readOnly
|
readOnly
|
||||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
@@ -1594,7 +1594,7 @@ const Integrations = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copy_to_clipboard(
|
copy_to_clipboard(
|
||||||
`curl ${curl_flags} "${getEnrollmentUrl()}" | sh`,
|
`curl ${curl_flags} "${getEnrollmentUrl()}" | ${selected_script_type === "proxmox-lxc" ? "bash" : "sh"}`,
|
||||||
"enrollment-command",
|
"enrollment-command",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon",
|
"name": "patchmon",
|
||||||
"version": "1.3.4",
|
"version": "1.3.6",
|
||||||
"description": "Linux Patch Monitoring System",
|
"description": "Linux Patch Monitoring System",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user