Compare commits

..

14 Commits

Author SHA1 Message Date
Muhammad Ibrahim
a853d36a6f Mobile fixes #1 2025-11-22 14:34:53 +00:00
9 Technology Group LTD
08f41bc9e0 Merge pull request #336 from PatchMon/release/1-3-5
put bash in for proxmox
2025-11-18 21:47:21 +00:00
Muhammad Ibrahim
6f7dbdb04b put bash in for proxmox 2025-11-18 21:46:37 +00:00
9 Technology Group LTD
909698ac11 Merge pull request #334 from PatchMon/release/1-3-5
Release/1 3 6
2025-11-18 20:36:12 +00:00
Muhammad Ibrahim
aaed443081 new binary 2025-11-18 20:13:12 +00:00
Muhammad Ibrahim
5b96b67db9 Fixed some prisma issue on kernel displaying
Fixed multi host selection button to not select all
updating to 1.3.6
2025-11-18 20:08:34 +00:00
9 Technology Group LTD
c6a2163e79 Merge pull request #333 from PatchMon/release/1-3-5
Release/1 3 5
2025-11-18 18:15:41 +00:00
Muhammad Ibrahim
b55ee6a6e0 Fixing proxmox auto-enrolment script 2025-11-18 18:10:38 +00:00
9 Technology Group LTD
e983d39bd6 Merge pull request #331 from PatchMon/release/1-3-5
fixing host route of version checking for other architectures
2025-11-17 22:09:47 +00:00
Muhammad Ibrahim
189de7a593 fixed linting 2025-11-17 21:59:19 +00:00
Muhammad Ibrahim
f57d87e1c0 fixing host route of version checking for other architectures 2025-11-17 21:49:05 +00:00
9 Technology Group LTD
470b204a8c Merge pull request #328 from PatchMon/release/1-3-5
Fixing critical bug on agent version handling causing agents to fill …
2025-11-17 19:38:17 +00:00
Muhammad Ibrahim
fa1f0fd7d7 Fixing critical bug on agent version handling causing agents to fill up deisk 2025-11-17 19:30:29 +00:00
Muhammad Ibrahim
334357a12e Added arm64 support for running PatchMon in Docker
Appended with bash for proxmox auto-enrolment
Fixed rootless docker issues for prisma 6.1 requirements
2025-11-17 18:40:28 +00:00
20 changed files with 886 additions and 345 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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")

View File

@@ -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",

View File

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

View File

@@ -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}"

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

@@ -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>
)} )}

View File

@@ -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" />

View File

@@ -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",
) )
} }

View File

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